diff --git a/.github/workflows/ci-guards.yml b/.github/workflows/ci-guards.yml index ad8fbbea9846..41fe2dea7187 100644 --- a/.github/workflows/ci-guards.yml +++ b/.github/workflows/ci-guards.yml @@ -636,6 +636,10 @@ jobs: if: ${{ matrix.group == 'ci' && steps.fast-guard.outputs.skip != 'true' }} run: python3 tests/test_ci_owned_build_state.py + - name: Validate canonical root ownership boundary + if: ${{ matrix.group == 'ci' && steps.fast-guard.outputs.skip != 'true' }} + run: python3 tests/test_ci_take_canonical_root.py + - name: Validate warm-state distance recording and routing if: ${{ matrix.group == 'ci' && steps.fast-guard.outputs.skip != 'true' }} run: python3 tests/test_ci_warm_distance.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eb3a75a1075..37a5342e7737 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1719,7 +1719,7 @@ jobs: name: "Dogfood build #${{ github.event.pull_request.number }}" needs: [changes, static-preflight] if: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref != 'feat-cmux-next' && contains(github.event.pull_request.labels.*.name, 'dev-build') && github.event.action != 'unlabeled' && (github.event.action != 'labeled' || github.event.label.name == 'dev-build') && github.repository_owner == 'manaflow-ai' && github.event.pull_request.head.repo.full_name == github.repository && (github.event.pull_request.author_association == 'MEMBER' || github.event.pull_request.author_association == 'OWNER') && needs.changes.result == 'success' && needs.static-preflight.result == 'success' && needs.changes.outputs.macos != 'false' }} - runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || github.repository_owner != 'manaflow-ai' && 'macos-26' || 'blacksmith-6vcpu-macos-26' }} + runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-26' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-26' }} timeout-minutes: 45 # This job is intentionally outside ci-status, so a build or publication # failure is visible without becoming a merge requirement. @@ -1735,6 +1735,9 @@ jobs: submodules: recursive persist-credentials: false + - name: Select Xcode + run: ./scripts/select-ci-xcode.sh + - name: Download the pinned GhosttyKit run: ./scripts/download-prebuilt-ghosttykit.sh diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift index 4bf8574b4312..629c7b1b1fc8 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Billing/ProUpgradeSource.swift @@ -32,6 +32,8 @@ public enum ProUpgradeSource: String, CaseIterable, Sendable { case machinesPanelMachineAction = "mac_machines_panel_machine_action" /// New machine sheet refused because the free plan is at its limit. case newMachineAtLimit = "mac_new_machine_at_limit" + /// "Upgrade Plan" on a create that failed at the plan's machine limit. + case createFailedAtLimit = "mac_create_failed_at_limit" /// New machine sheet "Upgrade to Max" under the locked 32 GB / 64 GB sizes. case newMachineSheetMaxUpgrade = "mac_new_machine_sheet_max_upgrade" /// Link inside the `vm_memory_requires_plan` error text (`VMClient`). diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift index 67aacfe57421..400ffe34387d 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Machines/MachinePlanSnapshot.swift @@ -44,6 +44,16 @@ public struct MachinePlanSnapshot: Equatable, Sendable { /// upgrade affordance after the server returns `vm_requires_pro`. public var isPaidPlan: Bool { Self.isPaidPlanID(planId) } + /// Whether a plan with more machines exists to upgrade to. Max, Team and + /// Founders are the top of the ladder; everything else (free, Go, Pro, an + /// unknown id) can move up. + public var hasHigherPlan: Bool { + switch planId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "max", "team", "founders": return false + default: return true + } + } + public static func isPaidPlanID(_ planId: String) -> Bool { switch planId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "go", "pro", "max", "team", "founders": diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift index 39e74cdc1ade..778984cd3ead 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeDevicesSection.swift @@ -8,7 +8,8 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { incomingAccessEnabled: Bool = false, discoveryManaged: Bool = false, incomingAccessManaged: Bool = false, - available: Bool = true + available: Bool = true, + isRefreshing: Bool = false ) { self.count = count self.discoveryEnabled = discoveryEnabled @@ -16,6 +17,7 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { self.discoveryManaged = discoveryManaged self.incomingAccessManaged = incomingAccessManaged self.available = available + self.isRefreshing = isRefreshing } public var count: Int = 0 @@ -25,9 +27,11 @@ public struct CloudTreeDevicesSection: Equatable, Sendable { public var incomingAccessManaged: Bool = false /// Whether Cloud availability permits either device preference. public var available: Bool = true + /// A refresh of the devices list is running; the header's refresh icon spins. + public var isRefreshing: Bool = false /// Both independent actions stay visible below the devices, preceded by - /// "No other Macs yet" when the list is empty. + /// "No other devices yet" when the list is empty. public var inlineRowCount: Int { (count == 0 ? 1 : 0) + 2 } diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreePlaceholder.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreePlaceholder.swift index 1a3cc73af4da..554fddd02d31 100644 --- a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreePlaceholder.swift +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreePlaceholder.swift @@ -4,9 +4,10 @@ public struct CloudTreePlaceholder: Equatable, Sendable { case dimmed case connecting case error - /// An action row that opens the New Machine sheet: the empty state of - /// the Cloud Machines section. + /// An action row that opens the New Machine sheet. case createMachine + /// Plain guidance with no icon or action, like "No cloud machines yet". + case empty } public let text: String diff --git a/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift new file mode 100644 index 000000000000..9d729ab2f9ce --- /dev/null +++ b/Packages/macOS/CmuxCloud/Sources/CmuxCloud/Tree/CloudTreeSectionRefresh.swift @@ -0,0 +1,11 @@ +import Foundation + +/// A section header's refresh icon: present while the section has something +/// to refresh, with a spinner in its place while a refresh runs. +public struct CloudTreeSectionRefresh: Equatable, Sendable { + public var isRefreshing: Bool + + public init(isRefreshing: Bool = false) { + self.isRefreshing = isRefreshing + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/DeviceAccessControl.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/DeviceAccessControl.swift index dc37d63f4755..3b227047b2da 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/DeviceAccessControl.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Models/DeviceAccessControl.swift @@ -33,8 +33,8 @@ public struct DeviceAccessControl { switch preference { case .discovery: return isOn - ? String(localized: "devices.discovery.stop", defaultValue: "Stop discovering other Macs", bundle: .module) - : String(localized: "devices.discovery.toggle", defaultValue: "Discover other Macs") + ? String(localized: "devices.discovery.stop", defaultValue: "Stop discovering other devices", bundle: .module) + : String(localized: "devices.discovery.toggle", defaultValue: "Discover other devices") case .incomingAccess: return isOn ? String(localized: "devices.incoming.hide", defaultValue: "Hide this Mac from My Devices", bundle: .module) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Devices.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Devices.swift index 7c2fd18ae850..2381d00e5725 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Devices.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Devices.swift @@ -27,7 +27,7 @@ extension Array where Element == CuratedSettingEntry { .init( section: .computers, id: "discovery", - title: String(localized: "devices.discovery.toggle", defaultValue: "Discover other Macs"), + title: String(localized: "devices.discovery.toggle", defaultValue: "Discover other devices"), detailText: String(localized: "devices.discovery.help", defaultValue: "Find and connect to other Macs signed in to your account. Turning this off disconnects their panes without closing their terminals."), synonyms: "Discover other Macs discovery find connect other computers my devices" ) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings index 18aeb8fc5e79..d533578262b9 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Resources/Localizable.xcstrings @@ -7101,55 +7101,55 @@ "en": { "stringUnit": { "state": "translated", - "value": "Stop discovering other Macs" + "value": "Stop discovering other devices" } }, "ar": { "stringUnit": { "state": "translated", - "value": "إيقاف اكتشاف أجهزة Mac الأخرى" + "value": "إيقاف اكتشاف الأجهزة الأخرى" } }, "de": { "stringUnit": { "state": "translated", - "value": "Andere Macs nicht mehr finden" + "value": "Andere Geräte nicht mehr finden" } }, "es": { "stringUnit": { "state": "translated", - "value": "Dejar de descubrir otros Mac" + "value": "Dejar de descubrir otros dispositivos" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Arrêter de rechercher d’autres Mac" + "value": "Arrêter de rechercher d’autres appareils" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ほかのMacの検出を停止" + "value": "ほかのデバイスの検出を停止" } }, "ko": { "stringUnit": { "state": "translated", - "value": "다른 Mac 검색 중지" + "value": "다른 기기 검색 중지" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "停止发现其他 Mac" + "value": "停止发现其他设备" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "停止尋找其他 Mac" + "value": "停止尋找其他裝置" } } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index 94580edf76a1..833cd2f9fa43 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -64,10 +64,10 @@ public struct ComputersSection: View { } else if discoveryManaged { SettingsCardNote(String(localized: "devices.managed", defaultValue: "Disabled by your administrator.")) } else if !discoveryEnabled { - SettingsCardNote(String(localized: "devices.discovery.settingsDisabled", defaultValue: "Turn on Discover other Macs to see your devices.")) + SettingsCardNote(String(localized: "devices.discovery.settingsDisabled", defaultValue: "Turn on Discover other devices to see your devices.")) } else if snapshot.computers.isEmpty { VStack(alignment: .leading, spacing: 6) { - Label(String(localized: "devices.empty.title", defaultValue: "No other Macs yet"), systemImage: "desktopcomputer") + Label(String(localized: "devices.empty.title", defaultValue: "No other devices yet"), systemImage: "desktopcomputer") .font(.callout.weight(.medium)) Text(String(localized: "devices.empty.help", defaultValue: "Sign in to cmux on another Mac and make it discoverable in Settings › Devices.")) .font(.callout) diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DeviceAccessControlTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DeviceAccessControlTests.swift index 6536692e7cf8..72d512231f5b 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DeviceAccessControlTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DeviceAccessControlTests.swift @@ -8,7 +8,7 @@ struct DeviceAccessControlTests { let incoming = DeviceAccessControl(.incomingAccess, enabled: enabled, managed: false) let discovery = DeviceAccessControl(.discovery, enabled: enabled, managed: false) #expect(incoming.title == (enabled ? "Hide this Mac from My Devices" : "Make this Mac discoverable")) - #expect(discovery.title == (enabled ? "Stop discovering other Macs" : "Discover other Macs")) + #expect(discovery.title == (enabled ? "Stop discovering other devices" : "Discover other devices")) #expect(incoming.isOn == enabled) #expect(discovery.isOn == enabled) #expect(incoming.isEnabled && discovery.isEnabled) @@ -20,7 +20,7 @@ struct DeviceAccessControlTests { let control = DeviceAccessControl(preference, enabled: true, managed: managed, unavailable: !managed) #expect(!control.isOn) #expect(!control.isEnabled) - #expect(control.title == (preference == .incomingAccess ? "Make this Mac discoverable" : "Discover other Macs")) + #expect(control.title == (preference == .incomingAccess ? "Make this Mac discoverable" : "Discover other devices")) if managed { #expect(control.help == "Disabled by your administrator.") } } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index caa2d7a1ac1a..91badb5c144e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -195972,16 +195972,16 @@ "value": "إلغاء" } }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Cloud nije omogućen. Podešavanje možete ponovo pokrenuti kada budete spremni." + "bs": { + "stringUnit": { + "state": "translated", + "value": "Cloud nije omogućen. Podešavanje možete ponovo pokrenuti kada budete spremni." } }, "da": { "stringUnit": { "state": "translated", - "value": "Cloud blev ikke aktiveret. Du kan starte opsætningen igen, når du er klar." + "value": "Cloud blev ikke aktiveret. Du kan starte opsætningen igen, når du er klar." } }, "de": { @@ -195993,7 +195993,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Cloud non è stato abilitato. Puoi ripetere la configurazione quando vuoi." + "value": "Cloud non è stato abilitato. Puoi ripetere la configurazione quando vuoi." } }, "es": { @@ -196011,7 +196011,7 @@ "it": { "stringUnit": { "state": "translated", - "value": "Cloud មិនត្រូវបានបើកទេ។ អ្នកអាចចាប់ផ្តើមការរៀបចំម្តងទៀតនៅពេលរួចរាល់។" + "value": "Cloud មិនត្រូវបានបើកទេ។ អ្នកអាចចាប់ផ្តើមការរៀបចំម្តងទៀតនៅពេលរួចរាល់។" } }, "ja": { @@ -196023,7 +196023,7 @@ "km": { "stringUnit": { "state": "translated", - "value": "Cloud ble ikke aktivert. Du kan starte oppsettet på nytt når du er klar." + "value": "Cloud ble ikke aktivert. Du kan starte oppsettet på nytt når du er klar." } }, "ko": { @@ -196035,37 +196035,37 @@ "nb": { "stringUnit": { "state": "translated", - "value": "Cloud nie zostało włączone. Możesz ponownie rozpocząć konfigurację, gdy będziesz gotowy." + "value": "Cloud nie zostało włączone. Możesz ponownie rozpocząć konfigurację, gdy będziesz gotowy." } }, "pl": { "stringUnit": { "state": "translated", - "value": "O Cloud não foi ativado. Você pode iniciar a configuração novamente quando quiser." + "value": "O Cloud não foi ativado. Você pode iniciar a configuração novamente quando quiser." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Cloud не ввімкнено. Ви можете знову розпочати налаштування, коли будете готові." + "value": "Cloud не ввімкнено. Ви можете знову розпочати налаштування, коли будете готові." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Cloud ยังไม่ได้เปิดใช้ คุณสามารถเริ่มการตั้งค่าใหม่ได้เมื่อพร้อม" + "value": "Cloud ยังไม่ได้เปิดใช้ คุณสามารถเริ่มการตั้งค่าใหม่ได้เมื่อพร้อม" } }, "th": { "stringUnit": { "state": "translated", - "value": "Cloud etkinleştirilmedi. Hazır olduğunuzda kurulumu yeniden başlatabilirsiniz." + "value": "Cloud etkinleştirilmedi. Hazır olduğunuzda kurulumu yeniden başlatabilirsiniz." } }, "tr": { "stringUnit": { "state": "translated", - "value": "Cloud не включён. Вы можете снова начать настройку, когда будете готовы." + "value": "Cloud не включён. Вы можете снова начать настройку, когда будете готовы." } }, "uk": { @@ -493683,7 +493683,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "No reachable ports" + "value": "No ports yet" } }, "ja": { @@ -610512,70 +610512,70 @@ "value": "Cloud Machines 需要 cmux Pro" } }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Cloud Machines zahtijevaju cmux Pro" + "bs": { + "stringUnit": { + "state": "translated", + "value": "Cloud Machines zahtijevaju cmux Pro" } }, "da": { "stringUnit": { "state": "translated", - "value": "Cloud Machines kræver cmux Pro" + "value": "Cloud Machines kræver cmux Pro" } }, "it": { "stringUnit": { "state": "translated", - "value": "Cloud Machines richiedono cmux Pro" + "value": "Cloud Machines richiedono cmux Pro" } }, "km": { "stringUnit": { "state": "translated", - "value": "Cloud Machines ត្រូវការ cmux Pro" + "value": "Cloud Machines ត្រូវការ cmux Pro" } }, "nb": { "stringUnit": { "state": "translated", - "value": "Cloud Machines krever cmux Pro" + "value": "Cloud Machines krever cmux Pro" } }, "pl": { "stringUnit": { "state": "translated", - "value": "Cloud Machines wymagają cmux Pro" + "value": "Cloud Machines wymagają cmux Pro" } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "O Cloud Machines requer cmux Pro" + "value": "O Cloud Machines requer cmux Pro" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Для Cloud Machines требуется cmux Pro" + "value": "Для Cloud Machines требуется cmux Pro" } }, "th": { "stringUnit": { "state": "translated", - "value": "Cloud Machines ต้องใช้ cmux Pro" + "value": "Cloud Machines ต้องใช้ cmux Pro" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Cloud Machines için cmux Pro gerekir" + "value": "Cloud Machines için cmux Pro gerekir" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Для Cloud Machines потрібен cmux Pro" + "value": "Для Cloud Machines потрібен cmux Pro" } } } @@ -620716,380 +620716,380 @@ } }, "machines.new.plan.loading": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Loading your Cloud machine plan…" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "جارٍ تحميل خطة جهاز Cloud…" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Učitavanje plana Cloud mašine…" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Indlæser din Cloud-maskineplan…" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Dein Cloud-Maschinentarif wird geladen…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cargando el plan de tu máquina Cloud…" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Chargement du forfait de votre machine Cloud…" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Caricamento del piano della macchina Cloud…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud マシンのプランを読み込んでいます…" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "កំពុងផ្ទុកគម្រោងម៉ាស៊ីន Cloud របស់អ្នក…" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 머신 요금제를 불러오는 중…" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Laster inn Cloud-maskinplanen din…" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Wczytywanie planu maszyny Cloud…" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Carregando o plano da sua máquina Cloud…" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Загрузка тарифа машины Cloud…" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "กำลังโหลดแผนเครื่อง Cloud ของคุณ…" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Cloud makine planınız yükleniyor…" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Завантаження тарифу машини Cloud…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在加载你的 Cloud 机器方案…" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在載入您的 Cloud 機器方案…" + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading your Cloud machine plan…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحميل خطة جهاز Cloud…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Učitavanje plana Cloud mašine…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indlæser din Cloud-maskineplan…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dein Cloud-Maschinentarif wird geladen…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando el plan de tu máquina Cloud…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargement du forfait de votre machine Cloud…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Caricamento del piano della macchina Cloud…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud マシンのプランを読み込んでいます…" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងផ្ទុកគម្រោងម៉ាស៊ីន Cloud របស់អ្នក…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 머신 요금제를 불러오는 중…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Laster inn Cloud-maskinplanen din…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wczytywanie planu maszyny Cloud…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Carregando o plano da sua máquina Cloud…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка тарифа машины Cloud…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังโหลดแผนเครื่อง Cloud ของคุณ…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cloud makine planınız yükleniyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Завантаження тарифу машини Cloud…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在加载你的 Cloud 机器方案…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在載入您的 Cloud 機器方案…" + } } } - } - }, + }, "machines.new.plan.retry": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "إعادة المحاولة" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Pokušaj ponovo" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Prøv igen" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Erneut versuchen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reintentar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Réessayer" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Riprova" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "再試行" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ព្យាយាមម្ដងទៀត" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "재시도" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Prøv igjen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Ponów" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Tentar Novamente" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Повторить" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ลองใหม่" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Tekrar Dene" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Повторити" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重试" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重試" + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة المحاولة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokušaj ponovo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Prøv igen" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut versuchen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reintentar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réessayer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riprova" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再試行" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ព្យាយាមម្ដងទៀត" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재시도" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Prøv igjen" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ponów" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tentar Novamente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повторить" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลองใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tekrar Dene" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Повторити" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重試" + } } } - } - }, + }, "machines.new.plan.error": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cloud plan details are still loading. Retry in a moment." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لا تزال تفاصيل خطة Cloud قيد التحميل. أعد المحاولة بعد قليل." - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Detalji Cloud plana se još učitavaju. Pokušajte ponovo za trenutak." - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Cloud-planens detaljer indlæses stadig. Prøv igen om et øjeblik." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Details des Cloud-Tarifs werden noch geladen. Versuche es gleich noch einmal." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Los detalles del plan de Cloud aún se están cargando. Vuelve a intentarlo en un momento." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Les détails du forfait Cloud sont encore en cours de chargement. Réessayez dans un instant." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "I dettagli del piano Cloud sono ancora in caricamento. Riprova tra un momento." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud プランの詳細を読み込み中です。しばらくしてから再試行してください。" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "ព័ត៌មានលម្អិតនៃគម្រោង Cloud នៅតែកំពុងផ្ទុក។ សូមព្យាយាមម្ដងទៀតបន្តិចទៀត។" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 요금제 정보를 아직 불러오는 중입니다. 잠시 후 다시 시도하세요." - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Detaljene for Cloud-planen lastes fortsatt inn. Prøv igjen om et øyeblikk." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Szczegóły planu Cloud wciąż się wczytują. Spróbuj ponownie za chwilę." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Os detalhes do plano Cloud ainda estão carregando. Tente novamente em instantes." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Сведения о тарифе Cloud ещё загружаются. Повторите попытку через мгновение." - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "รายละเอียดแผน Cloud ยังโหลดอยู่ โปรดลองใหม่อีกครั้งในอีกสักครู่" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Cloud planı ayrıntıları hâlâ yükleniyor. Birazdan tekrar deneyin." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Відомості про тариф Cloud ще завантажуються. Повторіть спробу через мить." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Cloud 方案详情仍在加载。请稍后重试。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Cloud 方案詳細資料仍在載入。請稍後重試。" + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud plan details are still loading. Retry in a moment." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا تزال تفاصيل خطة Cloud قيد التحميل. أعد المحاولة بعد قليل." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Detalji Cloud plana se još učitavaju. Pokušajte ponovo za trenutak." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Cloud-planens detaljer indlæses stadig. Prøv igen om et øjeblik." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Details des Cloud-Tarifs werden noch geladen. Versuche es gleich noch einmal." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los detalles del plan de Cloud aún se están cargando. Vuelve a intentarlo en un momento." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les détails du forfait Cloud sont encore en cours de chargement. Réessayez dans un instant." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "I dettagli del piano Cloud sono ancora in caricamento. Riprova tra un momento." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud プランの詳細を読み込み中です。しばらくしてから再試行してください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ព័ត៌មានលម្អិតនៃគម្រោង Cloud នៅតែកំពុងផ្ទុក។ សូមព្យាយាមម្ដងទៀតបន្តិចទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 요금제 정보를 아직 불러오는 중입니다. 잠시 후 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Detaljene for Cloud-planen lastes fortsatt inn. Prøv igjen om et øyeblikk." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szczegóły planu Cloud wciąż się wczytują. Spróbuj ponownie za chwilę." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Os detalhes do plano Cloud ainda estão carregando. Tente novamente em instantes." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сведения о тарифе Cloud ещё загружаются. Повторите попытку через мгновение." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รายละเอียดแผน Cloud ยังโหลดอยู่ โปรดลองใหม่อีกครั้งในอีกสักครู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cloud planı ayrıntıları hâlâ yükleniyor. Birazdan tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відомості про тариф Cloud ще завантажуються. Повторіть спробу через мить." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud 方案详情仍在加载。请稍后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 方案詳細資料仍在載入。請稍後重試。" + } } } - } - }, + }, "settings.search.alias.setting.terminal.copy-confirmation": { "extractionState": "manual", "localizations": { diff --git a/Sources/Cloud/CloudPortsDiscoveryDemand.swift b/Sources/Cloud/CloudPortsDiscoveryDemand.swift index d171efb01a1a..cc0c67f72124 100644 --- a/Sources/Cloud/CloudPortsDiscoveryDemand.swift +++ b/Sources/Cloud/CloudPortsDiscoveryDemand.swift @@ -1,7 +1,8 @@ import AppKit import CmuxSurfaceCatalogModel -/// Starts a scan only for an expanded, visible Ports group, including restored/default expansion. +/// Starts a scan for visible Cloud machines so the Ports tab count stays current +/// before the person opens the tab. @MainActor final class CloudPortsDiscoveryDemand { private var scheduled: Task? @@ -27,22 +28,19 @@ final class CloudPortsDiscoveryDemand { func reconcile(coordinator: CloudTreeOutlineView.Coordinator) { guard let outline = coordinator.outlineView, outline.window != nil else { return } + // A snapshot can be applied before AppKit has performed the containing + // view's first layout pass. Materialize row geometry before checking + // the visibility boundary so an already visible machine is not missed. + outline.layoutSubtreeIfNeeded() for root in candidates where !requested.contains(root.machine) { - guard outline.row(forItem: root) >= 0, outline.isItemExpanded(root), - root.children.contains(where: { Self.showsPorts($0, in: outline) }) else { continue } + // The machine row is the visibility boundary. Port discovery is a + // lightweight cached scan, and waiting for a detail tab to open + // leaves its count stale while a dev server starts in a terminal. + guard outline.row(forItem: root) >= 0 else { continue } requested.insert(root.machine) coordinator.nodeActions.discoverPorts(root.machine) } } - /// An expanded Ports group, or a machine tab row with Ports open. - private static func showsPorts(_ node: CloudTreeNode, in outline: NSOutlineView) -> Bool { - switch node.kind { - case .portsGroup: return outline.isItemExpanded(node) - case .machineDetailTabs(let tabs): return tabs.selected == .ports - default: return false - } - } - deinit { scheduled?.cancel() } } diff --git a/Sources/Cloud/CloudPortsStatusContent.swift b/Sources/Cloud/CloudPortsStatusContent.swift index 111f1be5ec2e..97f15fb2d01b 100644 --- a/Sources/Cloud/CloudPortsStatusContent.swift +++ b/Sources/Cloud/CloudPortsStatusContent.swift @@ -8,7 +8,7 @@ import CmuxSurfaceCatalogModel final class CloudPortsStatusContent: NSView { private let titleLabel = NSTextField(wrappingLabelWithString: "") private let messageLabel = NSTextField(wrappingLabelWithString: "") - private let actionButton = NSButton(title: "", target: nil, action: nil) + private let actionButton = CloudPortsStatusActionButton() private var presentation: CloudPortsStatusPresentation? private var style = CloudTreeStyle.defaultStyle private var actionHandler: (() -> Void)? @@ -29,7 +29,6 @@ final class CloudPortsStatusContent: NSView { messageLabel.maximumNumberOfLines = 0 messageLabel.lineBreakMode = .byWordWrapping messageLabel.textColor = .secondaryLabelColor - actionButton.bezelStyle = .inline actionButton.target = self actionButton.action = #selector(performAction) actionButton.setAccessibilityRole(.button) @@ -54,7 +53,7 @@ final class CloudPortsStatusContent: NSView { titleLabel.stringValue = presentation.title messageLabel.stringValue = presentation.message messageLabel.isHidden = presentation.message.isEmpty - actionButton.title = presentation.actionTitle ?? "" + actionButton.setTitle(presentation.actionTitle ?? "", fontSize: GlobalFontMagnification.scaledSize(max(10, style.detailSize))) actionButton.isHidden = presentation.action == .none || presentation.actionTitle == nil actionButton.setAccessibilityLabel(presentation.actionTitle ?? presentation.title) let fontSize = GlobalFontMagnification.scaledSize(max(10, style.detailSize)) @@ -79,13 +78,13 @@ final class CloudPortsStatusContent: NSView { if actionButton.isHidden { actionButton.frame = .zero } else { - // The inline bezel hugs its title; give it room on both sides. + // The button hugs its title; give it room on both sides. let padding = GlobalFontMagnification.scaledSize(Self.actionHorizontalPadding) * 2 actionButton.frame = NSRect(x: inset, y: messageLabel.frame.maxY + 4, width: min(width - inset * 2, actionButton.fittingSize.width + padding), height: 22) } } - private static let actionHorizontalPadding: CGFloat = 8 + private static let actionHorizontalPadding: CGFloat = 10 override var intrinsicContentSize: NSSize { guard let presentation else { return NSSize(width: NSView.noIntrinsicMetric, height: 0) } @@ -124,3 +123,74 @@ final class CloudPortsStatusContent: NSView { @objc private func performAction() { actionHandler?() } } + +/// The Ports status action ("Refresh", "Set Up VPN…"): a quiet rounded chip +/// in the Cloud tree's shape and fills (`CloudTreeHoverStyle`). Its title +/// brightens and its fill deepens under the pointer, and again while pressed. +@MainActor +final class CloudPortsStatusActionButton: NSButton { + private var isHovered = false { didSet { if isHovered != oldValue { refreshAppearance() } } } + private var hoverTracking: NSTrackingArea? + private var chipTitle = "" + private static let restFill = CloudTreeHoverStyle.selectedOpacity + private static let hoverFill = CloudTreeHoverStyle.selectedOpacity + CloudTreeHoverStyle.hoverOpacity + private static let pressedFill = CloudTreeHoverStyle.selectedOpacity + CloudTreeHoverStyle.pressedOpacity + private var fontSize: CGFloat = 11 + + init() { + super.init(frame: .zero) + isBordered = false + wantsLayer = true + layer?.cornerRadius = CloudTreeHoverStyle.cornerRadius + layer?.cornerCurve = .continuous + setButtonType(.momentaryChange) + refreshAppearance() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + deinit {} + + func setTitle(_ title: String, fontSize: CGFloat) { + chipTitle = title + self.fontSize = fontSize + refreshAppearance() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let hoverTracking { removeTrackingArea(hoverTracking) } + let area = NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], owner: self) + addTrackingArea(area) + hoverTracking = area + } + + override func mouseEntered(with event: NSEvent) { isHovered = true } + override func mouseExited(with event: NSEvent) { isHovered = false } + + override var isHighlighted: Bool { + didSet { refreshAppearance() } + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + refreshAppearance() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { isHovered = false } + } + + private func refreshAppearance() { + let opacity = isHighlighted ? Self.pressedFill : (isHovered ? Self.hoverFill : Self.restFill) + effectiveAppearance.performAsCurrentDrawingAppearance { + layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(opacity).cgColor + } + attributedTitle = NSAttributedString(string: chipTitle, attributes: [ + .font: NSFont.systemFont(ofSize: fontSize, weight: .medium), + .foregroundColor: isHovered || isHighlighted ? NSColor.labelColor : NSColor.secondaryLabelColor, + ]) + } +} diff --git a/Sources/Cloud/CloudRefreshMachinesButton.swift b/Sources/Cloud/CloudRefreshMachinesButton.swift deleted file mode 100644 index 51c93eade589..000000000000 --- a/Sources/Cloud/CloudRefreshMachinesButton.swift +++ /dev/null @@ -1,56 +0,0 @@ -import CmuxFoundation -import SwiftUI - -/// Refresh Cloud Machines, at the bottom right of the Cloud panel. As wide as -/// its label, with the tree's hover fill, so it reads like a quiet row action. -/// While a refresh runs it shows a spinner in place of its icon and ignores -/// clicks. -struct CloudRefreshMachinesButton: View { - let isRefreshing: Bool - let action: () -> Void - @State private var isHovered = false - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - private var title: String { - String(localized: "cloudTree.action.refreshCloudMachines", defaultValue: "Refresh Cloud Machines") - } - - var body: some View { - HStack(spacing: 0) { - Spacer(minLength: 0) - Button(action: action) { - HStack(spacing: 5) { - Group { - if isRefreshing { - ProgressView().controlSize(.mini) - } else { - Image(systemName: "arrow.clockwise") - .font(.system(size: 10, weight: .medium)) - } - } - .frame(width: 12, height: 12) - Text(title) - .cmuxFont(size: 11.5) - .lineLimit(1) - } - .foregroundStyle(isHovered && !isRefreshing ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) - .padding(.horizontal, 8) - .frame(height: 22) - .background( - RoundedRectangle(cornerRadius: CloudTreeHoverStyle.cornerRadius, style: .continuous) - .fill(Color.primary.opacity(isHovered && !isRefreshing ? CloudTreeHoverStyle.hoverOpacity : 0)) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isRefreshing) - .onHover { isHovered = $0 } - .animation(reduceMotion ? nil : .easeOut(duration: isHovered ? CloudTreeHoverStyle.fadeIn : CloudTreeHoverStyle.fadeOut), value: isHovered) - .help(title) - .accessibilityLabel(title) - .accessibilityIdentifier("CloudRefreshMachinesButton") - } - .padding(.horizontal, RightSidebarChromeMetrics.barHorizontalPadding - 2) - .padding(.vertical, 6) - } -} diff --git a/Sources/Cloud/CloudTreeBuildInputs.swift b/Sources/Cloud/CloudTreeBuildInputs.swift index cecddba2b545..6bd40f3928f6 100644 --- a/Sources/Cloud/CloudTreeBuildInputs.swift +++ b/Sources/Cloud/CloudTreeBuildInputs.swift @@ -17,6 +17,7 @@ struct CloudTreeBuildInputs: Equatable { var showsCloudVPNWarning = false var canCreateCloudMachine = false var cloudMachinesUsage: CloudMachinesUsage? = nil + var cloudMachinesRefresh: CloudTreeSectionRefresh? = nil var localeIdentifier: String = Locale.current.identifier func nodes(now: Date = .now, resourceNodeBuilder: CloudTreeMachineResourceNodeBuilder = .init()) -> [CloudTreeNode] { @@ -31,6 +32,7 @@ struct CloudTreeBuildInputs: Equatable { showsCloudVPNWarning: showsCloudVPNWarning, canCreateCloudMachine: canCreateCloudMachine, cloudMachinesUsage: cloudMachinesUsage, + cloudMachinesRefresh: cloudMachinesRefresh, now: now, resourceNodeBuilder: resourceNodeBuilder ) } diff --git a/Sources/Cloud/CloudTreeCellView.swift b/Sources/Cloud/CloudTreeCellView.swift index 7d880c81e4e1..40a7d0238a76 100644 --- a/Sources/Cloud/CloudTreeCellView.swift +++ b/Sources/Cloud/CloudTreeCellView.swift @@ -288,13 +288,33 @@ final class CloudTreeCellView: NSTableCellView { let description = CloudTreeRowToolTip.describe(node: node, style: style, presenceHeads: presenceHeads) toolTip = description.toolTip setAccessibilityLabel(description.accessibilityLabel) + // A header keeps its icon's clickable spot across reloads: SwiftUI + // reports the icon's frame only when it moves, and a reload that + // leaves it in place would otherwise leave the header with no + // clickable spot, so a click on the icon would toggle the section. + if CloudTreeRowContentView.sectionRefresh(for: node.kind) == nil { displayHost.interactiveRects = [] } displayHost.rootView = AnyView( - CloudTreeRowContentView(kind: node.kind, presenceHeads: presenceHeads, style: style, resources: node.resourceSection) + CloudTreeRowContentView( + kind: node.kind, presenceHeads: presenceHeads, style: style, resources: node.resourceSection, + onRefresh: Self.sectionRefreshAction(node.kind, configuredNodeActions), + onInteractiveFrame: { [weak displayHost] frame in displayHost?.interactiveRects = frame.map { [$0] } ?? [] } + ) .modifier(CloudSidebarRowDecoration(isPinned: node.isPinned, showsAttentionSlot: node.showsAttentionSlot, hasUnreadNotification: node.hasUnreadAttention, attentionSlot: style.rowGrid.attentionSlot)) .frame(maxWidth: .infinity, alignment: .leading) + .coordinateSpace(.named(CloudTreePassthroughHostingView.coordinateSpace)) ) } + /// The refresh a section header's icon runs; nil for every other row. + private static func sectionRefreshAction(_ kind: CloudTreeNode.Kind, _ actions: CloudTreeNodeActions?) -> (() -> Void)? { + guard let actions else { return nil } + switch kind { + // The panel's one refresh: the fleet and the account's other devices. + case .cloudMachinesSection, .devicesSection: return { actions.refresh() } + default: return nil + } + } + /// Rows the pointer can act on take the shared hover fill. Create rows and /// tabs draw their own; readings, notes and the closing gap take none. static func showsRowHover(_ kind: CloudTreeNode.Kind) -> Bool { @@ -388,14 +408,29 @@ final class CloudTreeCellView: NSTableCellView { /// A hosting view that is invisible to hit testing, so the outline row beneath /// it owns selection, drag, double-click, and the context menu. final class CloudTreePassthroughHostingView: NSHostingView { + /// The coordinate space a row's content reports its clickable spots in. + static let coordinateSpace = "CloudTreeRowContent" var passesThrough = true + /// Spots that take clicks even while the rest passes through, like a + /// section header's refresh icon, in this view's (flipped) coordinates. + var interactiveRects: [CGRect] = [] override func hitTest(_ point: NSPoint) -> NSView? { guard passesThrough else { return super.hitTest(point) } + let local = convert(point, from: superview) + if interactiveRects.contains(where: { $0.contains(local) }) { return super.hitTest(point) } // The outline owns all ordinary row interaction. Returning nil here is // what keeps a header click from being swallowed by the SwiftUI host. return nil } + + /// Whether a click (or this view's whole content) goes to SwiftUI. + func acceptsClick(_ event: NSEvent?) -> Bool { + guard passesThrough else { return true } + guard let event else { return false } + let local = convert(event.locationInWindow, from: nil) + return interactiveRects.contains { $0.contains(local) } + } } /// The hit-testable host for a row's hover buttons. `CloudTreeNSOutlineView` diff --git a/Sources/Cloud/CloudTreeCreateActionBuilder.swift b/Sources/Cloud/CloudTreeCreateActionBuilder.swift index cec2f8618114..c88b40ea6783 100644 --- a/Sources/Cloud/CloudTreeCreateActionBuilder.swift +++ b/Sources/Cloud/CloudTreeCreateActionBuilder.swift @@ -8,12 +8,11 @@ enum CloudTreeCreateActionBuilder { for node in nodes { node.children = add(to: node.children) switch node.kind { - case .cloudMachinesSection(let canCreateMachine, _): + case .cloudMachinesSection: // New Cloud Machine is the button above the section - // (`CloudNewMachineButton`), so the empty fleet's - // double-click-only "New Machine" placeholder goes. - node.children.removeAll { $0.id == "cloud-machines-section/empty" } - guard canCreateMachine else { break } + // (`CloudNewMachineButton`); an empty fleet keeps its + // "No cloud machines yet" line so the section still opens. + break case .workspacesGroup(let machine) where (machine.cloudMachineID != nil || machine.isDevice) && !node.children.contains(where: { $0.structureTag == "createAction" }): node.children.insert(CloudTreeNode( diff --git a/Sources/Cloud/CloudTreeDevicesEmptyView.swift b/Sources/Cloud/CloudTreeDevicesEmptyView.swift index fad6d2aa6a82..48d9759d0257 100644 --- a/Sources/Cloud/CloudTreeDevicesEmptyView.swift +++ b/Sources/Cloud/CloudTreeDevicesEmptyView.swift @@ -20,7 +20,7 @@ struct CloudTreeDevicesEmptyView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { if section.count == 0 { - Text(String(localized: "devices.empty.title", defaultValue: "No other Macs yet")) + Text(String(localized: "devices.empty.title", defaultValue: "No other devices yet")) .cmuxFont(size: style.detailSize, design: style.fontDesign) .foregroundStyle(.secondary) .padding(.leading, scaled(textInset)) diff --git a/Sources/Cloud/CloudTreeNSOutlineView.swift b/Sources/Cloud/CloudTreeNSOutlineView.swift index 9463866b581f..247adc65829a 100644 --- a/Sources/Cloud/CloudTreeNSOutlineView.swift +++ b/Sources/Cloud/CloudTreeNSOutlineView.swift @@ -272,7 +272,7 @@ final class CloudTreeNSOutlineView: NSOutlineView { return !controls.isHiddenOrHasHiddenAncestor } if let actionHost = candidate as? CloudTreePassthroughHostingView, - !actionHost.passesThrough { + actionHost.acceptsClick(event) { return !actionHost.isHiddenOrHasHiddenAncestor } view = candidate.superview @@ -454,12 +454,11 @@ final class CloudTreeNSOutlineView: NSOutlineView { onDocumentContentChanged?() } - private func disclosureLeading(atRow row: Int) -> CGFloat { + func disclosureLeading(atRow row: Int) -> CGFloat { GlobalFontMagnification.scaledSize( Self.leadingMargin + CGFloat(max(0, level(forRow: row))) * treeStyle.indentPerLevel ) } - override func frameOfOutlineCell(atRow row: Int) -> NSRect { // A machine's tab row opens and closes through its tabs, not a disclosure. if let node = item(atRow: row) as? CloudTreeNode, case .machineDetailTabs = node.kind { return .zero } @@ -496,6 +495,7 @@ final class CloudTreeNSOutlineView: NSOutlineView { } let trailing = frame.maxX frame.origin.x = panelContentLeading(atRow: row) + ?? connectingLeading(atRow: row) ?? CloudTreeLayoutMetrics().contentLeading(level: level(forRow: row), style: treeStyle) frame.size.width = max(0, trailing - frame.minX) return frame diff --git a/Sources/Cloud/CloudTreeNode.swift b/Sources/Cloud/CloudTreeNode.swift index 04f3aedb2b0c..a87c52ece5b8 100644 --- a/Sources/Cloud/CloudTreeNode.swift +++ b/Sources/Cloud/CloudTreeNode.swift @@ -68,8 +68,8 @@ final class CloudTreeNode: NSObject { case devicesSection(CloudTreeDevicesSection) /// The collapsible Cloud Machines section header. `canCreateMachine` shows its /// hover "+" (New Machine, Cmd-Y), false while Cloud Machines is off and the - /// header stands alone; `usage` is the plan's machine count, nil until it loads. - case cloudMachinesSection(canCreateMachine: Bool, usage: CloudMachinesUsage? = nil) + /// header stands alone; `usage` is the plan's count (nil until it loads), `refresh` its refresh icon. + case cloudMachinesSection(canCreateMachine: Bool, usage: CloudMachinesUsage? = nil, refresh: CloudTreeSectionRefresh? = nil) case createAction(CloudTreeCreateAction) /// My Devices guidance and independent discovery actions, also shown with peers. case devicesEmpty(CloudTreeDevicesSection) @@ -238,7 +238,7 @@ final class CloudTreeNode: NSObject { case .machineDetailTabs, .machineEndSpacer: return "" case .devicesEmpty(let section): return section.count == 0 - ? String(localized: "devices.empty.title", defaultValue: "No other Macs yet") + ? String(localized: "devices.empty.title", defaultValue: "No other devices yet") : String(localized: "devices.manage", defaultValue: "Manage My Devices") } } @@ -904,6 +904,13 @@ enum CloudTreeNodeBuilder { // snapshot, so device rows carry no Resources group. if let machineSnapshot { children.append(resourceNodeBuilder.groupNode(machine: machine, snapshot: machineSnapshot, now: now)) + } else if info?.linkState == .connecting { + // Keep the stable Resources control while a machine reconnects. + // Telemetry is unavailable until the VM snapshot arrives, so the + // group is intentionally empty and does not imply stale readings. + let placeholder = MachineSnapshot( + id: machine.rawValue, provider: "", image: "", isDesktop: false, activity: .pending) + children.append(resourceNodeBuilder.groupNode(machine: machine, snapshot: placeholder, now: now)) } return children } diff --git a/Sources/Cloud/CloudTreeNodeActions.swift b/Sources/Cloud/CloudTreeNodeActions.swift index c8d8e8a7c5f0..b46bd65a6f0e 100644 --- a/Sources/Cloud/CloudTreeNodeActions.swift +++ b/Sources/Cloud/CloudTreeNodeActions.swift @@ -76,12 +76,10 @@ struct CloudTreeNodeActions { var newMachine: @MainActor () -> Void = {} /// Creates a workspace on the remembered or selected Cloud machine. var newWorkspaceOnResolvedMachine: @MainActor () -> Void = {} - /// Pops up a row's context menu from its trailing "⋯" button. The outline - /// coordinator binds it per cell, so the button and a right-click show the - /// same menu. + /// Pops up a row's context menu from its trailing "⋯" button. Bound per + /// cell, so the button and a right-click show the same menu. var showRowMenu: @MainActor (_ nodeID: String) -> Void = { _ in } - /// Opens a machine's detail tab, or closes it when it is open. Bound per - /// cell by the outline coordinator. + /// Opens a machine's detail tab, or closes it when open. Bound per cell. var selectMachineDetailTab: @MainActor (_ machine: SurfaceMachineID, _ tab: CloudTreeMachineDetailTab) -> Void = { _, _ in } var organize: @MainActor (CloudSidebarOrganizationAction, String, [CloudTreeNode]) -> Bool = { _, _, _ in false } /// Navigates a nested terminal through its owning Cloud workspace. diff --git a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift index 1c4ac61ee6a3..2c5556447aa3 100644 --- a/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift +++ b/Sources/Cloud/CloudTreeNodeBuilder+Machines.swift @@ -19,6 +19,7 @@ extension CloudTreeNodeBuilder { showsCloudVPNWarning: Bool = false, canCreateCloudMachine: Bool = false, cloudMachinesUsage: CloudMachinesUsage? = nil, + cloudMachinesRefresh: CloudTreeSectionRefresh? = nil, now: Date = .now, resourceNodeBuilder: CloudTreeMachineResourceNodeBuilder = .init() ) -> [CloudTreeNode] { @@ -77,6 +78,7 @@ extension CloudTreeNodeBuilder { // Device machines have no cloud id and are never fleet rows. for info in snapshot.machines where !info.id.isLocal { guard let id = info.id.cloudMachineID, !seen.contains(id) else { continue } + let catalogOnlyConnecting = machines.isEmpty && info.linkState == .connecting let placeholderSnapshot = MachineSnapshot( id: id, provider: "", @@ -93,7 +95,7 @@ extension CloudTreeNodeBuilder { kind: .machine(placeholderSnapshot, info), children: cloudChildren( machine: info.id, - machineSnapshot: placeholderSnapshot, + machineSnapshot: catalogOnlyConnecting ? nil : placeholderSnapshot, info: info, snapshot: snapshot, projectionIndex: projectionIndex, @@ -103,7 +105,9 @@ extension CloudTreeNodeBuilder { ), isPinned: pinnedMachineIDs.contains(id) )) - nodes.last?.resourceSection = section + if !catalogOnlyConnecting { + nodes.last?.resourceSection = section + } } if source.groupsDevicesUnderSection { let cloudChildren = nodes.isEmpty @@ -112,15 +116,17 @@ extension CloudTreeNodeBuilder { kind: .placeholder( machine: .cloud("cloud-machines-section"), CloudTreePlaceholder( - text: String(localized: "machines.empty.create", defaultValue: "New Machine"), - style: .createMachine + text: String(localized: "machines.empty.none", defaultValue: "No cloud machines yet"), + style: .empty ) ) )] : nodes nodes = [CloudTreeNode( id: "cloud-machines-section", - kind: .cloudMachinesSection(canCreateMachine: canCreateCloudMachine, usage: cloudMachinesUsage), + kind: .cloudMachinesSection( + canCreateMachine: canCreateCloudMachine, usage: cloudMachinesUsage, refresh: cloudMachinesRefresh + ), children: cloudChildren )] } diff --git a/Sources/Cloud/CloudTreeOutlineView+MachineDetailTabs.swift b/Sources/Cloud/CloudTreeOutlineView+MachineDetailTabs.swift index 45d51a4dc227..c6361ccbe42a 100644 --- a/Sources/Cloud/CloudTreeOutlineView+MachineDetailTabs.swift +++ b/Sources/Cloud/CloudTreeOutlineView+MachineDetailTabs.swift @@ -19,6 +19,18 @@ extension CloudTreeOutlineView.Coordinator { } extension CloudTreeNSOutlineView { + /// A machine's "Connecting…" row stands where its New Workspace will be, + /// so its spinner takes the same chevron column as New Workspace's "+" + /// (`CloudTreeCellView.createRowContentInset`); nil for every other row. + func connectingLeading(atRow row: Int) -> CGFloat? { + guard let node = item(atRow: row) as? CloudTreeNode, + case .placeholder(_, let placeholder) = node.kind, placeholder.style == .connecting, + let parent = parent(forItem: node) as? CloudTreeNode, parent.isMachineRow else { return nil } + let slot = max(treeStyle.iconSlot, 12) + return disclosureLeading(atRow: row) + + GlobalFontMagnification.scaledSize(treeStyle.rowGrid.disclosureSlot / 2 - slot / 2) + } + /// An open machine tab's rows start under the first tab, not one indent /// deeper than the tab row; nil for every other row. func panelContentLeading(atRow row: Int) -> CGFloat? { diff --git a/Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift b/Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift new file mode 100644 index 000000000000..7d27e9a81843 --- /dev/null +++ b/Sources/Cloud/CloudTreeOutlineView+MachineLookup.swift @@ -0,0 +1,12 @@ +import CmuxCloud +import CmuxSurfaceCatalogModel + +extension CloudTreeOutlineView.Coordinator { + /// Finds a Cloud machine anywhere in the displayed tree, including under its section row. + func machine(id: SurfaceMachineID) -> MachineSnapshot? { + for node in CloudTreeNodeBuilder.flattened(nodes) { + if case .machine(let machine, _) = node.kind, .cloud(machine.id) == id { return machine } + } + return nil + } +} diff --git a/Sources/Cloud/CloudTreeOutlineView+Ports.swift b/Sources/Cloud/CloudTreeOutlineView+Ports.swift index 8bf3e66c5ffd..4f61421623f7 100644 --- a/Sources/Cloud/CloudTreeOutlineView+Ports.swift +++ b/Sources/Cloud/CloudTreeOutlineView+Ports.swift @@ -3,7 +3,7 @@ import CmuxCloud import CmuxSurfaceCatalogModel extension CloudTreeOutlineView.Coordinator { - /// Mouse, keyboard and status-button activation share the machine's current plan gate. + /// The Ports status button's action. It shares the machine's current plan gate. func performPortAction(_ action: CloudPortsStatusAction, machineID: SurfaceMachineID) { switch action { case .none: break diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index 02a57e6a092e..a11a2c0d1f71 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -36,7 +36,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { var showsCloudVPNWarning = false /// The Cloud Machines header's New Machine "+" and its plan count (nil until the plan loads). var canCreateCloudMachine: Bool = false - var cloudMachinesUsage: CloudMachinesUsage? = nil + var cloudMachinesUsage: CloudMachinesUsage? = nil, cloudMachinesRefresh: CloudTreeSectionRefresh? = nil var reveal: CloudTreeRevealRequest? = nil var creationReveal: CloudWorkspaceCreationReveal? = nil var nodeBuilder: ((CloudTreeBuildInputs) -> [CloudTreeNode])? = nil @@ -76,7 +76,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { devicesSection: devicesSection, showsCloudVPNWarning: showsCloudVPNWarning, canCreateCloudMachine: canCreateCloudMachine, - cloudMachinesUsage: cloudMachinesUsage + cloudMachinesUsage: cloudMachinesUsage, cloudMachinesRefresh: cloudMachinesRefresh )) context.coordinator.reveal(reveal) context.coordinator.reveal(creation: creationReveal) @@ -110,12 +110,12 @@ struct CloudTreeOutlineView: NSViewRepresentable { var machineDetailLayout = CloudTreeMachineDetailLayout() private(set) var isUpdatingProgrammatically = false private var activeDrag: ActiveDrag? - private var machineLiftMouseUpMonitor: Any? + var machineLiftMouseUpMonitor: Any? // NSDraggingItem retains the writer for the live native session. A weak // coordinator edge prevents a retained writer/container cycle. private weak var activeDragWriter: CloudTreeSurfaceDragPasteboardWriter? - private var activeDragSequenceNumber: Int? - private var activeDragSession: NSDraggingSession? + var activeDragSequenceNumber: Int? + var activeDragSession: NSDraggingSession? private weak var activeDragSourceView: CloudTreeNSOutlineView? private var supersededDragSession: NSDraggingSession? private var supersededDragSequenceNumber: Int? @@ -164,25 +164,6 @@ struct CloudTreeOutlineView: NSViewRepresentable { if let monitor = machineLiftMouseUpMonitor { NSEvent.removeMonitor(monitor) } } - /// Removes the fallback monitor used when AppKit omits a drag-end callback. - private func removeMachineLiftMouseUpMonitor() { - if let monitor = machineLiftMouseUpMonitor { NSEvent.removeMonitor(monitor) } - machineLiftMouseUpMonitor = nil - } - - /// Finishes the native drag through the same coordinator path as `endedAt`. - func installMachineLiftMouseUpMonitor(for session: NSDraggingSession, in outline: CloudTreeNSOutlineView) { - removeMachineLiftMouseUpMonitor() - machineLiftMouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseUp]) { [weak self] event in - MainActor.assumeIsolated { - guard let self, - self.activeDragSession === session, - self.activeDragSequenceNumber == session.draggingSequenceNumber else { return } - self.outlineView(outline, draggingSession: session, endedAt: event.locationInWindow, operation: []) - } - return event - } - } private func discardPendingDrag(_ pending: PendingDrag) { pending.registration.end() } @@ -512,10 +493,6 @@ struct CloudTreeOutlineView: NSViewRepresentable { machineActions.promptRename(machine) case .workspace(let machine, let workspace, _, _, _): nodeActions.renameWorkspace(machine, workspace) - case .display: - // The double-click's first click already opened the display or - // explained the ownership boundary; a second hint would repeat it. - break default: break } @@ -619,9 +596,10 @@ struct CloudTreeOutlineView: NSViewRepresentable { nodeActions.newMachine() return } + // A Ports status row's text never acts; only its own button refreshes or wakes. + guard placeholder.portStatus == nil else { return } // "Asleep — open to wake": a fresh terminal on the machine is what wakes it. - if let status = placeholder.portStatus { performPortAction(status.action, machineID: machineID) } - else if placeholder.opensMachine, let machine = machine(id: machineID) { openMachine(machine) } + if placeholder.opensMachine, let machine = machine(id: machineID) { openMachine(machine) } } } private func toggle(_ node: CloudTreeNode) { @@ -636,14 +614,6 @@ struct CloudTreeOutlineView: NSViewRepresentable { } } - /// Machines can sit under a section row (`.cloudMachinesSection`), so search the whole tree. - func machine(id: SurfaceMachineID) -> MachineSnapshot? { - for node in CloudTreeNodeBuilder.flattened(nodes) { - if case .machine(let machine, _) = node.kind, .cloud(machine.id) == id { return machine } - } - return nil - } - // MARK: Keyboard func moveSelection(by delta: Int) { diff --git a/Sources/Cloud/CloudTreePendingMachineRowContent.swift b/Sources/Cloud/CloudTreePendingMachineRowContent.swift index a2ce0c5d7bae..1f7cac62f8e3 100644 --- a/Sources/Cloud/CloudTreePendingMachineRowContent.swift +++ b/Sources/Cloud/CloudTreePendingMachineRowContent.swift @@ -13,13 +13,27 @@ struct CloudTreePendingMachineRowContent: View { @Environment(\.cmuxGlobalFontMagnificationPercent) private var magnification var body: some View { + if operation.failureOutput != nil { + failureRow + .accessibilityElement(children: .combine) + .accessibilityLabel(operation.summaryLine) + } else { + pendingRow + } + } + + @ViewBuilder + private var pendingRow: some View { switch style.machineRowLayout { case .singleLine: CloudTreeMachineBand(style: style) { - HStack(alignment: .firstTextBaseline, spacing: style.rowGrid.dotGap) { + // Centered, not baseline-aligned: the glyph has no text + // baseline, so on a baseline it drops below the name. + HStack(alignment: .center, spacing: style.rowGrid.dotGap) { name statusGlyph status + .layoutPriority(1) Spacer(minLength: style.rowGrid.trailingGap) } } @@ -28,7 +42,7 @@ struct CloudTreePendingMachineRowContent: View { case .twoLine: HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: scaled(style.rowGrid.machineLineSpacing)) { - HStack(alignment: .firstTextBaseline, spacing: style.rowGrid.dotGap) { + HStack(alignment: .center, spacing: style.rowGrid.dotGap) { name statusGlyph } @@ -45,6 +59,21 @@ struct CloudTreePendingMachineRowContent: View { } } + /// A failed create has no machine identity to show. Rendering the request + /// placeholder beside the failure duplicates the same row's meaning and + /// leaves a truncated `New…` label before the useful error text. + private var failureRow: some View { + CloudTreeMachineBand(style: style) { + HStack(alignment: .center, spacing: style.rowGrid.dotGap) { + statusGlyph + status + .layoutPriority(1) + Spacer(minLength: style.rowGrid.trailingGap) + } + .frame(height: scaled(style.machineNameLineHeight)) + } + } + /// Progress or failure, drawn after the name rather than in a leading slot: /// the name sits on the column the created machine's row will use, so it /// does not jump when the fleet list returns the real machine. diff --git a/Sources/Cloud/CloudTreePlaceholderContent.swift b/Sources/Cloud/CloudTreePlaceholderContent.swift index c28cf4de8af3..f3524d6e8adf 100644 --- a/Sources/Cloud/CloudTreePlaceholderContent.swift +++ b/Sources/Cloud/CloudTreePlaceholderContent.swift @@ -28,6 +28,10 @@ struct CloudTreePlaceholderContent: View { weight: .medium, tint: Color(nsColor: .secondaryLabelColor) ) + case .empty: + // Keeps the icon column so the text lines up with the + // rows it stands in for, like "No other devices yet". + Color.clear case .dimmed: CmuxSystemSymbolImage( systemName: "moon.zzz", @@ -39,7 +43,7 @@ struct CloudTreePlaceholderContent: View { } .frame(width: max(style.iconSlot, 12)) Text(placeholder.text) - .cmuxFont(size: style.detailSize + 1, design: style.fontDesign) + .cmuxFont(size: placeholder.style == .empty ? style.detailSize : style.detailSize + 1, design: style.fontDesign) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 8af48284ff90..a9a07724dcf6 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -15,6 +15,9 @@ struct CloudTreeRowContentView: View { var presenceHeads: [WorkspacePresenceParticipant] = [] var style: CloudTreeStyle = CloudTreeStyleStore.current var resources: CloudTreeMachineResourceSection? = nil + /// A section header's refresh, and where its icon sits (the header's one clickable spot). + var onRefresh: (() -> Void)? = nil + var onInteractiveFrame: (CGRect?) -> Void = { _ in } var body: some View { row @@ -146,19 +149,46 @@ struct CloudTreeRowContentView: View { /// leads with its identity glyph in the shared icon slot. @ViewBuilder private func groupRow(title: String) -> some View { - let label = CloudTreeGroupRowContent(title: title, count: Self.groupCount(for: kind), style: style) if let symbol = kind.sectionHeaderSymbol { - CloudTreeSectionHeaderRow(style: style, symbol: symbol) { label } + CloudTreeSectionHeaderRow(style: style, symbol: symbol) { groupLabel(title: title) } } else { - label + groupLabel(title: title) } } + @ViewBuilder + private func groupLabel(title: String) -> some View { + if let refresh = Self.sectionRefresh(for: kind), let onRefresh { + CloudTreeSectionRefreshHeader( + title: title, count: Self.groupCount(for: kind), style: style, refresh: refresh, + label: Self.refreshLabel(for: kind), action: onRefresh, onInteractiveFrame: onInteractiveFrame + ) + } else { + CloudTreeGroupRowContent(title: title, count: Self.groupCount(for: kind), style: style) + } + } + + /// The refresh icon a section header carries after its count; nil shows none. + static func sectionRefresh(for kind: CloudTreeNode.Kind) -> CloudTreeSectionRefresh? { + switch kind { + case .cloudMachinesSection(_, _, let refresh): refresh + case .devicesSection(let section): CloudTreeSectionRefresh(isRefreshing: section.isRefreshing) + default: nil + } + } + + private static func refreshLabel(for kind: CloudTreeNode.Kind) -> String { + if case .cloudMachinesSection = kind { + return String(localized: "cloudTree.action.refreshCloudMachines", defaultValue: "Refresh Cloud Machines") + } + return String(localized: "cloudTree.menu.refresh", defaultValue: "Refresh") + } + /// The count a group header shows after its title ("My Devices 2"); nil shows none. static func groupCount(for kind: CloudTreeNode.Kind) -> CloudTreeGroupCount? { switch kind { case .devicesSection(let section): CloudTreeGroupCount(section.count) - case .cloudMachinesSection(_, let usage?): CloudTreeGroupCount(usage: usage) + case .cloudMachinesSection(_, let usage?, _): CloudTreeGroupCount(usage: usage) case .terminalsPool(_, let count), .displaysPool(_, let count, _): CloudTreeGroupCount(count) default: nil } diff --git a/Sources/Cloud/CloudTreeRowHoverButtons.swift b/Sources/Cloud/CloudTreeRowHoverButtons.swift index 6adf0c852682..afb95d926615 100644 --- a/Sources/Cloud/CloudTreeRowHoverButtons.swift +++ b/Sources/Cloud/CloudTreeRowHoverButtons.swift @@ -12,9 +12,11 @@ struct CloudTreeRowHoverButtons: View { var body: some View { switch kind { + // The section headers' refresh icons sit after their counts + // (`CloudTreeSectionRefreshHeader`), not with these buttons. case .devicesSection(let section): CloudTreeDevicesMenuButton(section: section, nodeActions: nodeActions) - case .cloudMachinesSection(let canCreateMachine, _): + case .cloudMachinesSection(let canCreateMachine, _, _): if canCreateMachine { plus(String(localized: "machines.new", defaultValue: "New Machine")) { nodeActions.newMachine() @@ -124,7 +126,7 @@ struct CloudTreeRowHoverButtons: View { switch kind { case .machine, .localMachine, .terminalsPool, .displaysPool, .workspacesGroup, .workspace, .devicesSection: return true - case .cloudMachinesSection(let canCreateMachine, _): + case .cloudMachinesSection(let canCreateMachine, _, _): return canCreateMachine case .pendingMachine: return true diff --git a/Sources/Cloud/CloudTreeRowToolTip.swift b/Sources/Cloud/CloudTreeRowToolTip.swift index 704c4744d2a1..52a4b52ce817 100644 --- a/Sources/Cloud/CloudTreeRowToolTip.swift +++ b/Sources/Cloud/CloudTreeRowToolTip.swift @@ -83,7 +83,7 @@ enum CloudTreeRowToolTip { toolTip: joined([placeholder.text], beyond: node.searchableTitle), accessibilityLabel: node.searchableTitle ) - case .cloudMachinesSection(_, let usage?): + case .cloudMachinesSection(_, let usage?, _): // The count's display host never hit-tests, so the plan's help rides // on the row, and the row's label keeps VoiceOver from reading the // visible "1/50" as "1 slash 50". diff --git a/Sources/Cloud/CloudTreeRowView.swift b/Sources/Cloud/CloudTreeRowView.swift index 0712bb2e0faf..c5df594e0979 100644 --- a/Sources/Cloud/CloudTreeRowView.swift +++ b/Sources/Cloud/CloudTreeRowView.swift @@ -16,6 +16,9 @@ final class CloudTreeRowView: NSTableRowView { } private let hoverLayer = CALayer() + /// The selection fill is a layer too, not drawn: a drawn fill is + /// stretched and redrawn as the sidebar resizes, which flickers. + private let selectionLayer = CALayer() /// Where the highlight starts, set by the outline from the row's level. var highlightLeading: CGFloat = CloudTreeHoverStyle.horizontalInset { @@ -34,7 +37,12 @@ final class CloudTreeRowView: NSTableRowView { } override var isSelected: Bool { - didSet { updateHoverLayer(animated: false) } + didSet { updateHoverLayer(animated: false); updateSelectionLayer() } + } + + /// The outline marks rows emphasized while it has keyboard focus. + override var isEmphasized: Bool { + didSet { updateSelectionLayer() } } override init(frame frameRect: NSRect) { @@ -43,6 +51,9 @@ final class CloudTreeRowView: NSTableRowView { hoverLayer.opacity = 0 hoverLayer.cornerRadius = CloudTreeHoverStyle.cornerRadius hoverLayer.cornerCurve = .continuous + selectionLayer.opacity = 0 + selectionLayer.cornerRadius = CloudTreeHoverStyle.cornerRadius + selectionLayer.cornerCurve = .continuous } @available(*, unavailable) @@ -54,17 +65,28 @@ final class CloudTreeRowView: NSTableRowView { super.layout() if hoverLayer.superlayer == nil, let layer { layer.insertSublayer(hoverLayer, at: 0) + layer.insertSublayer(selectionLayer, at: 0) updateHoverColor() } CATransaction.begin() CATransaction.setDisableActions(true) hoverLayer.frame = highlightRect + selectionLayer.frame = highlightRect CATransaction.commit() + updateSelectionLayer() } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() updateHoverColor() + updateSelectionLayer() + } + + /// The outline asks every visible row to redraw when its focus changes; + /// the selection shade follows it here. + override func viewWillDraw() { + super.viewWillDraw() + updateSelectionLayer() } override func prepareForReuse() { @@ -72,17 +94,22 @@ final class CloudTreeRowView: NSTableRowView { setHoverHighlighted(false, animated: false) } - override func drawSelection(in dirtyRect: NSRect) { - guard isSelected else { return } - let rect = highlightRect - let radius = CloudTreeHoverStyle.cornerRadius - let path = NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius) - // Gray in both focus states (no accent blue); keyboard focus reads as a - // slightly stronger shade. - NSColor.labelColor.withAlphaComponent( - isKeyboardFocusActive ? CloudTreeHoverStyle.focusedSelectedOpacity : CloudTreeHoverStyle.selectedOpacity - ).setFill() - path.fill() + /// The selection layer draws the highlight; AppKit's own fill stays off. + override func drawSelection(in dirtyRect: NSRect) {} + + private func updateSelectionLayer() { + CATransaction.begin() + CATransaction.setDisableActions(true) + selectionLayer.opacity = isSelected ? 1 : 0 + if isSelected { + // Gray in both focus states (no accent blue); keyboard focus reads + // as a slightly stronger shade. + let opacity = isKeyboardFocusActive ? CloudTreeHoverStyle.focusedSelectedOpacity : CloudTreeHoverStyle.selectedOpacity + effectiveAppearance.performAsCurrentDrawingAppearance { + selectionLayer.backgroundColor = NSColor.labelColor.withAlphaComponent(opacity).cgColor + } + } + CATransaction.commit() } private var highlightRect: NSRect { diff --git a/Sources/Cloud/CloudTreeSectionRefreshHeader.swift b/Sources/Cloud/CloudTreeSectionRefreshHeader.swift new file mode 100644 index 000000000000..a2e27e3032f8 --- /dev/null +++ b/Sources/Cloud/CloudTreeSectionRefreshHeader.swift @@ -0,0 +1,71 @@ +import CmuxCloud +import CmuxFoundation +import SwiftUI + +/// A section header ("Cloud Machines 3/5", "My Devices") with its refresh +/// icon right after the count, small and in the count's color. +struct CloudTreeSectionRefreshHeader: View { + let title: String + let count: CloudTreeGroupCount? + let style: CloudTreeStyle + let refresh: CloudTreeSectionRefresh + /// The icon's tooltip and accessibility label. + let label: String + let action: () -> Void + /// Reports the icon's frame in `CloudTreePassthroughHostingView.coordinateSpace`, + /// the only spot of the header that takes a click instead of the row. + let onInteractiveFrame: (CGRect?) -> Void + + @Environment(\.cmuxGlobalFontMagnificationPercent) private var magnification + @State private var isHovered = false + + var body: some View { + HStack(alignment: .center, spacing: scaled(4)) { + // The shared header, hugging its title and count (its own spacer + // collapses) so the icon sits right after the count. + CloudTreeGroupRowContent(title: title, count: count, style: style) + .fixedSize(horizontal: true, vertical: false) + .padding(.trailing, -style.rowGrid.trailingPadding) + refreshButton + Spacer(minLength: 0) + } + .padding(.trailing, style.rowGrid.trailingPadding) + .onDisappear { onInteractiveFrame(nil) } + } + + private var refreshButton: some View { + Button(action: action) { + Group { + if refresh.isRefreshing { + ProgressView() + .controlSize(.mini) + .scaleEffect(0.7) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: scaled(max(8, style.detailSize - 2)), weight: .semibold)) + .foregroundStyle(isHovered ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tertiary)) + } + } + // The same hit area and hover fill as the headers' + and ⋯ + // (`MachinesChromeIconButton`); the glyph keeps its own size. + .frame(width: 22, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: RightSidebarChromeMetrics.buttonCornerRadius, style: .continuous) + .fill(isHovered && !refresh.isRefreshing ? Color.primary.opacity(0.06) : Color.clear) + ) + .disabled(refresh.isRefreshing) + .onHover { isHovered = $0 } + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .named(CloudTreePassthroughHostingView.coordinateSpace)) + } action: { onInteractiveFrame($0) } + .help(label) + .accessibilityLabel(label) + } + + private func scaled(_ value: CGFloat) -> CGFloat { + GlobalFontMagnification.scaledSize(value, percent: magnification) + } +} diff --git a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift index af0f693d3022..d7d0debddb83 100644 --- a/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift +++ b/Sources/Cloud/CloudWorkspaceSidebarPresentation.swift @@ -37,12 +37,24 @@ struct CloudWorkspaceSidebarPresentation { static func deviceLabel(workspace: Workspace, catalog: SurfaceCatalog? = nil) -> String? { let catalog = catalog ?? SurfaceCatalog.shared return deviceLabel(workspace: workspace, machines: deviceMachines(for: workspace, catalog: catalog), catalog: catalog) + } static var unavailableDirectory: String { String(localized: "sidebar.cloudWorkspace.directoryUnavailable", defaultValue: "Directory unavailable") } + /// Builds the presentation from the app's shared catalog. + @MainActor + init?(workspace: Workspace, orderedPanelIDs: [UUID], usesLastSegmentPath: Bool) { + self.init( + workspace: workspace, + orderedPanelIDs: orderedPanelIDs, + usesLastSegmentPath: usesLastSegmentPath, + catalog: SurfaceCatalog.shared + ) + } + @MainActor /// Builds the immutable remote sidebar identity and directory presentation. init?(workspace: Workspace, orderedPanelIDs: [UUID], usesLastSegmentPath: Bool, catalog: SurfaceCatalog? = nil) { diff --git a/Sources/Cloud/MachineCreateOperation.swift b/Sources/Cloud/MachineCreateOperation.swift index 261b27d550b7..57aab62f380c 100644 --- a/Sources/Cloud/MachineCreateOperation.swift +++ b/Sources/Cloud/MachineCreateOperation.swift @@ -47,6 +47,12 @@ struct MachineCreateOperation: Identifiable, Equatable { return nil } + /// The create failed because the plan's active-machine limit is reached + /// (the service's `vm_active_limit_exceeded`), so upgrading would let it through. + var hitMachineLimit: Bool { + failureOutput?.contains("vm_active_limit_exceeded") == true + } + /// The one-line status beside the name: the sheet's progress wording /// while running, the failure headline once it failed. var statusLabel: String { diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index 4af0400886f2..53645dbe3422 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -453,11 +453,11 @@ struct MachinesPanelView: View { discoveryEnabled: includesDevices, incomingAccessEnabled: devicesModel.preferences?.incomingAccessEnabled ?? false, discoveryManaged: discoveryManaged, - incomingAccessManaged: incomingAccessManaged, available: DevicesFeature.isAvailable() + incomingAccessManaged: incomingAccessManaged, available: DevicesFeature.isAvailable(), isRefreshing: devicesModel.isRefreshing ), showsCloudVPNWarning: tunnelStatus.status?.state == .off, canCreateCloudMachine: includesCloud, - cloudMachinesUsage: includesCloud ? viewModel.visibleUsage : nil, + cloudMachinesUsage: includesCloud ? viewModel.visibleUsage : nil, cloudMachinesRefresh: includesCloud ? .init(isRefreshing: viewModel.isRefreshingOnRequest) : nil, reveal: devicesModel.revealRequest, creationReveal: SurfaceCatalog.shared.cloudWorkspaceCreationCoordinator.reveals.reveal(for: tabManager) ) @@ -472,7 +472,7 @@ struct MachinesPanelView: View { Image(systemName: "desktopcomputer") .font(.system(size: 30, weight: .light)) .foregroundStyle(.secondary) - Text(String(localized: "devices.empty.title", defaultValue: "No other Macs yet")) + Text(String(localized: "devices.empty.title", defaultValue: "No other devices yet")) .font(.callout.weight(.medium)) Text(String(localized: "devices.empty.help", defaultValue: "Sign in to cmux on another Mac and make it discoverable in Settings › Devices.")) .font(.callout) diff --git a/Sources/Cloud/MachinesPanelViewModel+MachineDeletion.swift b/Sources/Cloud/MachinesPanelViewModel+MachineDeletion.swift index bae4febbbb33..6e816208caa0 100644 --- a/Sources/Cloud/MachinesPanelViewModel+MachineDeletion.swift +++ b/Sources/Cloud/MachinesPanelViewModel+MachineDeletion.swift @@ -17,7 +17,24 @@ extension MachinesPanelViewModel { /// The plan's usage without machines being deleted, so the Cloud Machines /// header count leaves with the row instead of at the next list read. var visibleUsage: CloudMachinesUsage? { - Self.usage(plan?.usage, machines: machines, hiding: MachineDeleteCoordinator.shared.hiddenMachineIDs) + Self.usage( + plan?.usage ?? NewMachineSheetDataCache.shared?.currentData?.plan?.usage, + machines: machines, + hiding: MachineDeleteCoordinator.shared.hiddenMachineIDs + ) + } + + /// Uses scope-checked sheet-cache usage while the panel's next list read is pending. + /// - Parameters: + /// - usage: Usage from the panel's current list read. + /// - fallback: Usage from the current scope's warmed sheet cache. + /// - machines: The fleet list that supplied the usage count. + /// - machineIDs: Provider machine identifiers to leave out. + /// - Returns: Usage from the newest available current-scope source, adjusted for hidden rows. + static func usage( + _ usage: CloudMachinesUsage?, fallback: CloudMachinesUsage?, machines: [MachineSnapshot], hiding machineIDs: Set + ) -> CloudMachinesUsage? { + Self.usage(usage ?? fallback, machines: machines, hiding: machineIDs) } /// Takes hidden machines out of a usage counted from `machines`. diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index e1c9f1146801..8affd9d43468 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -10,7 +10,9 @@ import SwiftUI final class MachinesPanelViewModel: ObservableObject { @Published private(set) var machines: [MachineSnapshot] = [] @Published private(set) var plan: MachinePlanSnapshot? - @Published private(set) var isLoading = false + @Published private(set) var isLoading = false { didSet { if !isLoading { isRefreshingOnRequest = false } } } + /// A refresh someone asked for (`refresh(tree:)`) is loading, as opposed to the poll. + @Published private(set) var isRefreshingOnRequest = false @Published private(set) var hasLoadedOnce = false @Published private(set) var lastErrorDescription: String? /// Classified list failure for the matching sign-in, plan, or retry presentation. @@ -378,6 +380,7 @@ final class MachinesPanelViewModel: ObservableObject { func refresh(tree forceTree: Bool) { recoverList() refreshTree(force: forceTree) + isRefreshingOnRequest = isLoading } func refreshMachine(_ machine: SurfaceMachineID) { machineRefreshes.refresh(machine) } nonisolated static func usageBackoffDelay(failureCount: Int) -> TimeInterval { @@ -390,16 +393,13 @@ final class MachinesPanelViewModel: ObservableObject { } static let pollInterval: Duration = .seconds(45) static let initialTransientFailureLimit = 3 - /// A refresh asked for while one is in flight runs again afterwards: a - /// create that lands mid-poll must still replace its pending row with the - /// real machine now, not on the next 45 s sweep. + /// A refresh asked for while one is in flight runs again afterwards: a create that lands + /// mid-poll must still replace its pending row with the real machine now, not on the next 45 s sweep. var refreshRequestedWhileLoading = false - /// A queued automatic refresh promotes the current request to recovery - /// presentation and keeps that intent for the follow-up read. + /// A queued automatic refresh promotes the current request to recovery presentation and keeps that intent for the follow-up read. var refreshRequestedWhileLoadingIsRecovery = false - /// Invalidates refresh completions when the Cloud gate closes. A cancelled - /// URLSession task may still resume on the main actor, so cancellation - /// alone is not enough to prevent stale rows or follow-up work. + /// Invalidates refresh completions when the Cloud gate closes. A cancelled URLSession task may + /// still resume on the main actor, so cancellation alone is not enough to prevent stale rows or follow-up work. var refreshGeneration: UInt64 = 0 /// Sleeps until the earliest upcoming transition across the fleet, then /// recomputes the free-access facet locally and re-arms for the next one. diff --git a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift index d593c871f1a7..7fb960bb58e9 100644 --- a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift +++ b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift @@ -3,6 +3,26 @@ import AppKit /// Coordinator side of the continuous machine and workspace drag /// (`CloudTreeMachineReorderLift`). extension CloudTreeOutlineView.Coordinator { + /// Removes the fallback monitor used when AppKit omits a drag-end callback. + func removeMachineLiftMouseUpMonitor() { + if let monitor = machineLiftMouseUpMonitor { NSEvent.removeMonitor(monitor) } + machineLiftMouseUpMonitor = nil + } + + /// Finishes the native drag through the same coordinator path as `endedAt`. + func installMachineLiftMouseUpMonitor(for session: NSDraggingSession, in outline: CloudTreeNSOutlineView) { + removeMachineLiftMouseUpMonitor() + machineLiftMouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseUp]) { [weak self] event in + MainActor.assumeIsolated { + guard let self, + self.activeDragSession === session, + self.activeDragSequenceNumber == session.draggingSequenceNumber else { return } + self.outlineView(outline, draggingSession: session, endedAt: event.locationInWindow, operation: []) + } + return event + } + } + /// 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) { diff --git a/Sources/ContentView+RightSidebarWidth.swift b/Sources/ContentView+RightSidebarWidth.swift new file mode 100644 index 000000000000..0e7a92b68f79 --- /dev/null +++ b/Sources/ContentView+RightSidebarWidth.swift @@ -0,0 +1,37 @@ +import AppKit + +extension ContentView { + /// Clamps the right sidebar to its available and configured width caps. + /// + /// The content minimum can exceed either cap in a narrow window, so it is + /// bounded by the effective maximum before it becomes the lower bound. + static func clampedRightSidebarWidth( + _ candidate: CGFloat, + availableWidth: CGFloat, + configuredMaximumWidth: CGFloat? = nil, + contentMinimumWidth: CGFloat = 0 + ) -> CGFloat { + let sanitizedCandidate = candidate.isFinite ? candidate : 220 + let sanitizedAvailableWidth = availableWidth.isFinite && availableWidth > 0 ? availableWidth : 1920 + let availableWidthCap = max( + minimumRightSidebarWidth, + sanitizedAvailableWidth - minimumTerminalWidthWithRightSidebar + ) + let configuredOrDefaultCap: CGFloat + if let configuredMaximumWidth, configuredMaximumWidth.isFinite { + configuredOrDefaultCap = configuredMaximumWidth + } else { + configuredOrDefaultCap = maximumRightSidebarWidth + } + let maximumWidth = max( + minimumRightSidebarWidth, + min(configuredOrDefaultCap, availableWidthCap) + ) + let contentMinimum = contentMinimumWidth.isFinite ? contentMinimumWidth : minimumRightSidebarWidth + let minimumWidth = min( + max(minimumRightSidebarWidth, contentMinimum), + maximumWidth + ) + return max(minimumWidth, min(maximumWidth, sanitizedCandidate)) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 88d453520e75..35ad1fd18b59 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1133,9 +1133,9 @@ struct ContentView: View { private static let commandPaletteVisiblePreviewResultLimit = 48 private static let commandPaletteVisiblePreviewCandidateLimit = 128 private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 - private static let minimumRightSidebarWidth: CGFloat = CGFloat(RightSidebarWidthSettings.minimumWidth) - private static let maximumRightSidebarWidth: CGFloat = CGFloat(RightSidebarWidthSettings.builtInMaximumWidth) - private static let minimumTerminalWidthWithRightSidebar: CGFloat = 360 + static let minimumRightSidebarWidth: CGFloat = CGFloat(RightSidebarWidthSettings.minimumWidth) + static let maximumRightSidebarWidth: CGFloat = CGFloat(RightSidebarWidthSettings.builtInMaximumWidth) + static let minimumTerminalWidthWithRightSidebar: CGFloat = 360 private var minimumSidebarWidth: CGFloat { CGFloat(SessionPersistencePolicy.sanitizedMinimumSidebarWidth(sidebarMinimumWidthSetting)) @@ -1177,11 +1177,7 @@ struct ContentView: View { captureStart: { fileExplorerDragStartWidth = fileExplorerWidth }, updateWidth: { translation in let startWidth = fileExplorerDragStartWidth ?? fileExplorerWidth - let nextWidth = Self.clampedRightSidebarWidth( - startWidth - translation, - availableWidth: resolvedRightSidebarAvailableWidth(availableWidth), - configuredMaximumWidth: rightSidebarConfiguredMaximumWidth - ) + let nextWidth = normalizedRightSidebarWidth(startWidth - translation, availableWidth: availableWidth) withTransaction(Transaction(animation: nil)) { fileExplorerWidth = nextWidth } @@ -1231,28 +1227,6 @@ struct ContentView: View { return max(minimumWidth, min(sanitizedMaximumWidth, candidate)) } - static func clampedRightSidebarWidth( - _ candidate: CGFloat, - availableWidth: CGFloat, - configuredMaximumWidth: CGFloat? = nil - ) -> CGFloat { - let minimumWidth = Self.minimumRightSidebarWidth - let sanitizedCandidate = candidate.isFinite ? candidate : 220 - let sanitizedAvailableWidth = availableWidth.isFinite && availableWidth > 0 ? availableWidth : 1920 - let availableWidthCap = max( - minimumWidth, - sanitizedAvailableWidth - Self.minimumTerminalWidthWithRightSidebar - ) - let configuredOrDefaultCap: CGFloat - if let configuredMaximumWidth, configuredMaximumWidth.isFinite { - configuredOrDefaultCap = max(minimumWidth, configuredMaximumWidth) - } else { - configuredOrDefaultCap = Self.maximumRightSidebarWidth - } - let maximumWidth = min(configuredOrDefaultCap, availableWidthCap) - return max(minimumWidth, min(maximumWidth, sanitizedCandidate)) - } - private func clampSidebarWidthIfNeeded(availableWidth: CGFloat? = nil) { let nextWidth = Self.clampedSidebarWidth( sidebarWidth, @@ -1317,7 +1291,8 @@ struct ContentView: View { Self.clampedRightSidebarWidth( candidate, availableWidth: resolvedRightSidebarAvailableWidth(availableWidth), - configuredMaximumWidth: rightSidebarConfiguredMaximumWidth + configuredMaximumWidth: rightSidebarConfiguredMaximumWidth, + contentMinimumWidth: fileExplorerState.modeBarMinimumWidth ) } @@ -1979,6 +1954,7 @@ struct ContentView: View { } } } + .onChange(of: fileExplorerState.modeBarMinimumWidth) { _ in clampRightSidebarWidthIfNeeded() } .onChange(of: fileExplorerState.width) { newValue in if fileExplorerDragStartWidth == nil { let sanitized = normalizedRightSidebarWidth(newValue) diff --git a/Sources/FileExplorerState.swift b/Sources/FileExplorerState.swift index f84f31dc2eb8..aaf0ebd34ac4 100644 --- a/Sources/FileExplorerState.swift +++ b/Sources/FileExplorerState.swift @@ -24,6 +24,10 @@ final class FileExplorerState: ObservableObject { @Published var width: CGFloat { didSet { defaults.set(Double(width), forKey: "fileExplorer.width") } } + /// The width the mode bar needs for the selected tab's full name, the other + /// tabs as icons, and its trailing controls; the right sidebar never gets + /// narrower. Not persisted. + @Published var modeBarMinimumWidth: CGFloat = 0 /// Proportion of sidebar height allocated to the tab list (0.0-1.0). /// The file explorer gets the remaining space below. diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index 2b5176a5bd63..5756f87cea6b 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -307,7 +307,7 @@ final class HiveComputersService { if ManagedDevicePolicy().isDeviceDiscoveryDisabled { return String(localized: "devices.managed", defaultValue: "Disabled by your administrator.") } - return String(localized: "devices.discovery.settingsDisabled", defaultValue: "Turn on Discover other Macs to see your devices.") + return String(localized: "devices.discovery.settingsDisabled", defaultValue: "Turn on Discover other devices to see your devices.") } /// Why the account's Macs cannot be listed at all, or `nil` while diff --git a/Sources/ModeBarLabelEdgeFade.swift b/Sources/ModeBarLabelEdgeFade.swift new file mode 100644 index 000000000000..e93333083b7e --- /dev/null +++ b/Sources/ModeBarLabelEdgeFade.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// The mask over a mode tab's label: opaque, with a short fade at the +/// trailing edge while the label is wider than its slot. +struct ModeBarLabelEdgeFade: View { + let naturalWidth: CGFloat + + var body: some View { + GeometryReader { proxy in + let width = max(proxy.size.width, 1) + let fade = min(10, max(0, naturalWidth - proxy.size.width)) + LinearGradient( + stops: [ + .init(color: .black, location: 0), + .init(color: .black, location: max(0, (width - fade) / width)), + .init(color: .black.opacity(fade > 0 ? 0 : 1), location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + } +} diff --git a/Sources/Panels/MachineCreateLoadingContent.swift b/Sources/Panels/MachineCreateLoadingContent.swift index b3302f4ded5a..a424aef54a78 100644 --- a/Sources/Panels/MachineCreateLoadingContent.swift +++ b/Sources/Panels/MachineCreateLoadingContent.swift @@ -1,3 +1,4 @@ +import CmuxCloud import SwiftUI /// A truthful reservation surface using the same operation and actions as the machine row. @@ -33,8 +34,22 @@ struct MachineCreateLoadingContent: View { .foregroundStyle(.secondary) .textSelection(.enabled) HStack(spacing: 8) { - Button(String(localized: "machines.pending.retry", defaultValue: "Retry")) { actions.retry(operation.id) } + if offersUpgrade { + Button(String(localized: "machines.pending.upgrade", defaultValue: "Upgrade Plan")) { + ProUpgradePresenter.present(source: .createFailedAtLimit) + } .buttonStyle(.borderedProminent) + .accessibilityIdentifier("MachineCreateUpgradeButton") + } + // Upgrading is the way forward at the limit, so it takes + // the prominent style and Retry steps back. + if offersUpgrade { + Button(String(localized: "machines.pending.retry", defaultValue: "Retry")) { actions.retry(operation.id) } + .buttonStyle(.bordered) + } else { + Button(String(localized: "machines.pending.retry", defaultValue: "Retry")) { actions.retry(operation.id) } + .buttonStyle(.borderedProminent) + } Button(String(localized: "machines.pending.dismiss", defaultValue: "Dismiss")) { actions.dismiss(operation.id) } .buttonStyle(.bordered) } @@ -50,4 +65,10 @@ struct MachineCreateLoadingContent: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: GhosttyApp.shared.defaultBackgroundColor)) } + + /// The create hit the plan's machine limit and a plan with more machines + /// exists. An unloaded plan still offers it; the plans page shows the rest. + private var offersUpgrade: Bool { + operation.hitMachineLimit && (NewMachineSheetDataCache.shared?.currentData?.plan?.hasHigherPlan ?? true) + } } diff --git a/Sources/RightSidebarChromeStyle.swift b/Sources/RightSidebarChromeStyle.swift index 0b26dd993d75..2a29f64d9d86 100644 --- a/Sources/RightSidebarChromeStyle.swift +++ b/Sources/RightSidebarChromeStyle.swift @@ -367,14 +367,29 @@ struct ModeBarButton: View { let action: () -> Void @State private var isHovered: Bool = false - /// False once the label is truncated to less than a letter and an - /// ellipsis; the tab then shows only its icon. The label keeps its slot, - /// so hiding it never changes the tab's width. + /// False once the label's slot is narrower than about a letter; the tab + /// then shows only its icon. The label keeps its slot, so hiding it + /// never changes the tab's width. @State private var labelFits = true + @State private var labelWidth: CGFloat = 0 + /// The label's full width, which its slot may be narrower than. + @State private var naturalLabelWidth: CGFloat = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// The tab switch's one curve: smooth, short and without overshoot, so + /// tabs settle into their new widths instead of bouncing like a reorder. + static let switchAnimation = Animation.smooth(duration: 0.26) + + /// With its label hidden, the icon (and badge) moves into the middle of + /// the label's empty slot, so it sits centered in the tab's highlight. + private var hiddenLabelShift: CGFloat { + labelFits ? 0 : (labelWidth + Self.contentSpacing) / 2 + } + private static let contentSpacing: CGFloat = 4 var body: some View { Button(action: action) { - HStack(spacing: 4) { + HStack(spacing: Self.contentSpacing) { CmuxSystemSymbolImage( systemName: item.symbolName, pointSize: RightSidebarChromeControlStyle.modeIconSize, @@ -386,17 +401,29 @@ struct ModeBarButton: View { keyPrefix: "rightSidebarModeIcon_\(item.id)", isVisible: true ) + .offset(x: badgeCount > 0 ? 0 : hiddenLabelShift) + // The label keeps its natural width and its slot uncovers it: + // a slot that narrows clips with a soft edge rather than + // re-truncating ("Files", "Fil…", "F…") on every frame of a + // width change. Text(item.label) .cmuxFont( size: RightSidebarChromeControlStyle.labelSize, weight: RightSidebarChromeControlStyle.labelWeight ) .lineLimit(1) - .truncationMode(.tail) + .fixedSize() + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { naturalLabelWidth = $0 } + .frame(minWidth: 0, alignment: .leading) + .clipped() + .mask { ModeBarLabelEdgeFade(naturalWidth: naturalLabelWidth) } .opacity(labelFits ? 1 : 0) - .onGeometryChange(for: Bool.self) { proxy in - proxy.size.width >= GlobalFontMagnification.scaledSize(Self.minimumVisibleLabelWidth) - } action: { labelFits = $0 } + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.width + } action: { width in + labelWidth = width + labelFits = width >= GlobalFontMagnification.scaledSize(Self.minimumVisibleLabelWidth) + } if badgeCount > 0 { pendingChip } @@ -417,6 +444,10 @@ struct ModeBarButton: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + // The label's fade and the icon's glide to or from the middle follow + // the width on the same curve. They change a layout pass after the + // width, so they carry their own animation rather than the switch's. + .animation(reduceMotion ? nil : Self.switchAnimation, value: labelFits) .titlebarInteractiveControl() .onHover { isHovered = $0 } .help(helpText) diff --git a/Sources/RightSidebarModeBarDrag.swift b/Sources/RightSidebarModeBarDrag.swift index 5c7ac0b30d40..424830878961 100644 --- a/Sources/RightSidebarModeBarDrag.swift +++ b/Sources/RightSidebarModeBarDrag.swift @@ -125,18 +125,11 @@ final class RightSidebarModeBarDragController { removeMouseUpMonitor() endedStartLocation = current.startLocation guard !current.isCarriedOut else { return } - let authoritative = RightSidebarTabPreferences.orderedModes().filter { $0.isAvailable() && ($0 == current.mode || !RightSidebarTabPreferences.isHidden($0)) } - let source = authoritative.firstIndex(of: current.mode) - var order = authoritative - if let source, !order.isEmpty { - let item = order.remove(at: source) - let target = min(max(current.slot, 0), order.count) - order.insert(item, at: target) - } + let order = current.layout.reordered(current.modes, slot: current.slot) withAnimation(animation) { // The bar reads the saved order in the same update that clears // the offsets, so position and offset animate together. - if let source, source != min(max(current.slot, 0), max(0, authoritative.count - 1)) { + if order != current.modes { RightSidebarTabPreferences.setDisplayedOrder(order) } session = nil diff --git a/Sources/RightSidebarModeBarTabWidths.swift b/Sources/RightSidebarModeBarTabWidths.swift index 733c2c055525..25715856597f 100644 --- a/Sources/RightSidebarModeBarTabWidths.swift +++ b/Sources/RightSidebarModeBarTabWidths.swift @@ -7,6 +7,15 @@ import CoreGraphics struct RightSidebarModeBarTabWidths { let widths: [CGFloat] + /// The tabs' width with one tab showing its full label and the rest at + /// their floor (their icon): the widest such row, so the result does not + /// change as the selection moves. + static func oneLabelWidth(natural: [CGFloat], floors: [CGFloat]) -> CGFloat { + precondition(natural.count == floors.count) + let extra = zip(natural, floors).map { max(0, $0 - $1) }.max() ?? 0 + return floors.reduce(0, +) + extra + } + /// - Parameters: /// - natural: Each tab's width with its full label. /// - floors: Each tab's smallest width. diff --git a/Sources/RightSidebarModeBarTabsLayout.swift b/Sources/RightSidebarModeBarTabsLayout.swift index b3f9e18a946d..cd470073d80c 100644 --- a/Sources/RightSidebarModeBarTabsLayout.swift +++ b/Sources/RightSidebarModeBarTabsLayout.swift @@ -10,8 +10,18 @@ struct RightSidebarModeBarTabSelectedKey: LayoutValueKey { /// narrows and never grow past their full label. struct RightSidebarModeBarTabsLayout: Layout { var spacing: CGFloat + /// Receives the width the selected tab needs for its full label with + /// every other tab at its icon. + var widthReport: RightSidebarModeBarWidthReport? func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + if let widthReport { + let width = RightSidebarModeBarTabWidths.oneLabelWidth( + natural: subviews.map { $0.sizeThatFits(.unspecified).width }, + floors: subviews.map { $0.sizeThatFits(ProposedViewSize(width: 0, height: nil)).width } + ) + gaps(subviews) + MainActor.assumeIsolated { widthReport.note(tabsWidth: width) } + } let widths = tabWidths(available: proposal.width, subviews: subviews) let height = subviews.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0 return CGSize(width: widths.reduce(0, +) + gaps(subviews), height: height) diff --git a/Sources/RightSidebarModeBarWidthReport.swift b/Sources/RightSidebarModeBarWidthReport.swift new file mode 100644 index 000000000000..e0eb5d464366 --- /dev/null +++ b/Sources/RightSidebarModeBarWidthReport.swift @@ -0,0 +1,31 @@ +import CoreGraphics +import Foundation + +/// Carries the mode bar's needed width out of its layout, so the right +/// sidebar's minimum width fits the selected tab's name, the other tabs as +/// icons, and the trailing controls. +/// The layout writes it while measuring; the change is delivered on the next +/// main-queue turn, never during a view update. +@MainActor +final class RightSidebarModeBarWidthReport { + var onChange: ((CGFloat) -> Void)? + private var reported: CGFloat = 0 + + /// Space the bar needs beside its tabs: the gaps before the open-as-pane + /// and close buttons, both buttons, and the bar's own leading and + /// trailing padding. + static var trailingReserve: CGFloat { + RightSidebarChromeMetrics.headerLeadingPadding + + RightSidebarChromeMetrics.headerTrailingPadding + + 3 * RightSidebarChromeMetrics.headerControlSpacing + + 2 * RightSidebarChromeMetrics.headerControlSize + } + + /// Notes the tabs' width (one full label, the rest icons), gaps included. + func note(tabsWidth: CGFloat) { + let width = (tabsWidth + Self.trailingReserve).rounded(.up) + guard abs(width - reported) > 0.5 else { return } + reported = width + DispatchQueue.main.async { [weak self] in self?.onChange?(width) } + } +} diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index cb9aa0d83829..9f574d6ce458 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -88,6 +88,8 @@ struct RightSidebarPanelView: View { @State private var closeShortcutHintMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) @State private var hasMountedRightSidebarContent = false @State private var modeBarDrag = RightSidebarModeBarDragController() + @State private var modeBarWidthReport = RightSidebarModeBarWidthReport() + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared private let alwaysShowShortcutHints = ShortcutHintDebugSettings().alwaysShowHints private let closeShortcutHintXOffset = ShortcutHintDebugSettings.defaultRightSidebarCloseHintX @@ -191,13 +193,12 @@ struct RightSidebarPanelView: View { .accessibilityElement(children: .contain) .accessibilityIdentifier("RightSidebar") .onAppear { + modeBarWidthReport.onChange = { [fileExplorerState] in fileExplorerState.modeBarMinimumWidth = $0 } startShortcutHintMonitorsIfNeeded() if fileExplorerState.isVisible { hasMountedRightSidebarContent = true } fileExplorerState.refreshModeAvailability() } - .onDisappear { - stopShortcutHintMonitors() - } + .onDisappear { stopShortcutHintMonitors() } .onChange(of: showModifierHoldHints) { _, _ in startShortcutHintMonitorsIfNeeded() } @@ -223,9 +224,7 @@ struct RightSidebarPanelView: View { HStack(spacing: RightSidebarChromeMetrics.headerControlSpacing) { let displayedModes = availableModes - // The selected tab keeps its full label; the others share the - // rest and truncate, then drop to their icon. - RightSidebarModeBarTabsLayout(spacing: RightSidebarChromeMetrics.headerControlSpacing) { + RightSidebarModeBarTabsLayout(spacing: RightSidebarChromeMetrics.headerControlSpacing, widthReport: modeBarWidthReport) { ForEach(modeBarItems) { item in let shortcut = item.shortcutAction.map { KeyboardShortcutSettings.shortcut(for: $0) } ?? .unbound ModeBarButton( @@ -258,6 +257,7 @@ struct RightSidebarPanelView: View { .layoutValue(key: RightSidebarModeBarTabSelectedKey.self, value: item.isSelected(mode: fileExplorerState.mode)) } } + .animation(reduceMotion ? nil : ModeBarButton.switchAnimation, value: fileExplorerState.mode) .background(RightSidebarModeBarDragAnchorView(anchor: modeBarDrag.anchor)) .coordinateSpace(.named(RightSidebarModeBarDragController.coordinateSpace)) .layoutPriority(1) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f4d4fda1d7d1..6106e1901bb2 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -376,7 +376,6 @@ D0B10010A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10011A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift */; }; D0B10002A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */; }; AA1B2C3D4E5F60718 /* BonsplitTabDragUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */; }; - B7E1A0000000000000000002 /* browser-repl in Resources */ = {isa = PBXBuildFile; fileRef = B7E1A0000000000000000001 /* browser-repl */; }; 986900080000000000000001 /* BrowserAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 986900080000000000000002 /* BrowserAction.swift */; }; 986900020000000000000001 /* BrowserActionDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 986900020000000000000002 /* BrowserActionDispatcher.swift */; }; 986900030000000000000001 /* BrowserActionTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 986900030000000000000002 /* BrowserActionTarget.swift */; }; @@ -542,7 +541,7 @@ 129980000000000000000003 /* BrowserReloadRecordingWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 129980000000000000000004 /* BrowserReloadRecordingWebView.swift */; }; BFBAC1A77CEE7A2DF91DA02C /* BrowserRemoteWorkspaceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */; }; 6F0906442BBFDB4417C04CBF /* BrowserReplCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DCAE46175E88BA66B961C6A /* BrowserReplCapture.swift */; }; - 63C845ED3A1E5655E679A076 /* BrowserReplCredentialRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56224B74B8C7450EACD23B9A /* BrowserReplCredentialRequest.swift */; }; + 75A0E19FCD6BF464E645FB48 /* BrowserReplCredentialRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A557E81A8DDA46AAED8DF0F8 /* BrowserReplCredentialRequest.swift */; }; F25A30A09126228538713598 /* BrowserReplDriverGuards.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBD5B7B3629983779F6971FA /* BrowserReplDriverGuards.swift */; }; F1B95FED662D0CC1878BD5E1 /* BrowserReplFrameTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4CD58476719A99539AC2C9 /* BrowserReplFrameTree.swift */; }; 38B4899D82858BCB742F1270 /* BrowserReplNativeInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6842BA6D53DDC17D2F5FCFCA /* BrowserReplNativeInput.swift */; }; @@ -876,7 +875,6 @@ 9F046FC7A14BBDB098F471B3 /* CloudReadResponseGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9A79D080D5B1E7996CC8586 /* CloudReadResponseGate.swift */; }; C13151C00000000000000023 /* CloudRefreshAuthClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13151C00000000000000022 /* CloudRefreshAuthClient.swift */; }; C13151C00000000000000021 /* CloudRefreshFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13151C00000000000000020 /* CloudRefreshFixture.swift */; }; - 42CD11A9A5FE581254EA474D /* CloudRefreshMachinesButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */; }; C12625000000000000000004 /* CloudRefreshURLProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12625000000000000000003 /* CloudRefreshURLProtocol.swift */; }; C12575000000000000000050 /* CloudRenameOptimismTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12575000000000000000051 /* CloudRenameOptimismTests.swift */; }; EFD5BEC316FCF70B7E4FDFBE /* CloudRestoreReplayFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */; }; @@ -897,6 +895,7 @@ 3E825519877E4FD58D1E91AC /* CloudSidebarOrganizationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF7BCD47CC347BC8A5660AD /* CloudSidebarOrganizationStore.swift */; }; DF3CB4E1EB244622B4300041 /* CloudSidebarOrganizationTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA34C8F7DABB44E193EF9294 /* CloudSidebarOrganizationTree.swift */; }; C5CD2E187E90476987D291C8 /* CloudSidebarPinGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A17868063D4FF7939B0525 /* CloudSidebarPinGeometryTests.swift */; }; + C2403304B0E829DD4A0D33C4 /* CloudSidebarPolishTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE43F14FD0AFB491247CA131 /* CloudSidebarPolishTests.swift */; }; EA9F42A5F5EF461CBDB53012 /* CloudSidebarRenameFixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0804BE3246D64D49AD7C91C4 /* CloudSidebarRenameFixture.swift */; }; 4610710A93DA483CA04C8C25 /* CloudSidebarRenameReconciliationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FBF296FE6654DB6BE033698 /* CloudSidebarRenameReconciliationTests.swift */; }; B656E460C0904C4D96D87C9B /* CloudSidebarRowDecoration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DCB7412E584AB1AAA90BA5 /* CloudSidebarRowDecoration.swift */; }; @@ -1015,6 +1014,7 @@ 99A348B1D074417BB9DC59D1 /* CloudTreeOutlineView+Displays.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12747253A744B63A30EFA27 /* CloudTreeOutlineView+Displays.swift */; }; C7D2AAC178FE439AFB93CDA1 /* CloudTreeOutlineView+MachineDetailTabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = F701F7BA409DB9FFC4271474 /* CloudTreeOutlineView+MachineDetailTabs.swift */; }; 1DFA8FF6BFF48B174B6FCBEC /* CloudTreeOutlineView+MachineLift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */; }; + F1534C522AA2977842FF139F /* CloudTreeOutlineView+MachineLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343AD0359AD2A02EE6861D0D /* CloudTreeOutlineView+MachineLookup.swift */; }; E639673A813D2A174CE3BAE1 /* CloudTreeOutlineView+MachineMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 412FF88FEF5819465A8157DE /* CloudTreeOutlineView+MachineMenu.swift */; }; A1308600000000000000000C /* CloudTreeOutlineView+MachineOrdering.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1308600000000000000000B /* CloudTreeOutlineView+MachineOrdering.swift */; }; FB6DB41BCE7B4EC987BF2F0B /* CloudTreeOutlineView+Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A6150D65B1F43F683253998 /* CloudTreeOutlineView+Organization.swift */; }; @@ -1044,6 +1044,7 @@ A12481200000000000000001 /* CloudTreeRowUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12481200000000000000002 /* CloudTreeRowUpdate.swift */; }; DEABB0C438031ADB363DE082 /* CloudTreeRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDB22532809A3E370F5EE466 /* CloudTreeRowView.swift */; }; B29325C466BEAB8D272ED852 /* CloudTreeSectionHeaderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA9D99AC276D3C794C6702A /* CloudTreeSectionHeaderRow.swift */; }; + B7D2DF595F25E9DEBC907591 /* CloudTreeSectionRefreshHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F3F6B820FF35BCF02EF6B2E /* CloudTreeSectionRefreshHeader.swift */; }; C16B6FB2E23E52FB6F4FA4A7 /* CloudTreeStyleGalleryWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46E4DB2770E4B0CBF4C388E5 /* CloudTreeStyleGalleryWindow.swift */; }; C986A0010000000000000001 /* CloudTreeSurfaceDragPasteboardWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986A0020000000000000001 /* CloudTreeSurfaceDragPasteboardWriter.swift */; }; A12357000000000000000002 /* CloudTreeTerminalNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12357000000000000000001 /* CloudTreeTerminalNavigationCoordinator.swift */; }; @@ -1338,6 +1339,7 @@ CC0033E15A1B2C3D4E5F0020 /* CMUXCLI+VMTui.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0033E15A1B2C3D4E5F0021 /* CMUXCLI+VMTui.swift */; }; CC0033E15A1B2C3D4E5F0032 /* CMUXCLI+VMTuiWorkspaceNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0033E15A1B2C3D4E5F0033 /* CMUXCLI+VMTuiWorkspaceNaming.swift */; }; C8923639133F158AF1B6A0FB /* CMUXCLI+VPN.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA475616EA96D1D0624FC0A1 /* CMUXCLI+VPN.swift */; }; + FF1B913DC56F41178FA0895A /* CMUXCLI+VPN.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0226FF4BE571A2BA5B5AB16 /* CMUXCLI+VPN.swift */; }; 7A0CE1000000000000000102 /* CMUXCLI+VPNAppManaged.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE1000000000000000101 /* CMUXCLI+VPNAppManaged.swift */; }; F0316A711615C3D2D3008A82 /* CMUXCLI+WindowDispatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64B5058972147F941BD6FA5 /* CMUXCLI+WindowDispatch.swift */; }; 06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */; }; @@ -1760,6 +1762,7 @@ 137DEBC7453F4ABBBCB8D39A /* ContentView+PaneResizeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CD684C40FC4F36BD3A91EE /* ContentView+PaneResizeCommands.swift */; }; C0DEA7710000000000000003 /* ContentView+ProCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7710000000000000004 /* ContentView+ProCommandPalette.swift */; }; C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */; }; + AEBC559E83E4B911AEBFAAA7 /* ContentView+RightSidebarWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970FE2D3951BB4AF4AA52F09 /* ContentView+RightSidebarWidth.swift */; }; C0DE1A010000000000000001 /* ContentView+SavedLayoutCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */; }; C51A740000000000000000B1 /* ContentView+SidebarSurfaceKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A740000000000000000B2 /* ContentView+SidebarSurfaceKind.swift */; }; 875200000000000000000011 /* ContentView+SurfaceNavigationCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875200000000000000000001 /* ContentView+SurfaceNavigationCommands.swift */; }; @@ -2592,6 +2595,7 @@ 00C3AA443F6DA8D94E28CE17 /* MobileWorkspaceListObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F14137954A34DFFA05C88C /* MobileWorkspaceListObserver.swift */; }; D75280030000000000000001 /* MobileWorkspaceObserverSignposts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75280030000000000000002 /* MobileWorkspaceObserverSignposts.swift */; }; 13AB1A2E5DA6AD02DCAE971D /* MockBridgeInputCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD3B5F249DECAB446CC71D32 /* MockBridgeInputCapture.swift */; }; + 238F7F4A1651DD3FFA9B3C1E /* ModeBarLabelEdgeFade.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71A8A8412C80779F41DCF9D /* ModeBarLabelEdgeFade.swift */; }; B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; }; D77690000000000000000005 /* MultiWindowNotificationsWorkspaceHeadlineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77690000000000000000006 /* MultiWindowNotificationsWorkspaceHeadlineUITests.swift */; }; E05E36509344691CA085F0CC /* NarrowWindowSidePanelFitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C6C5413708B2C95D6C1CC3 /* NarrowWindowSidePanelFitTests.swift */; }; @@ -3025,6 +3029,7 @@ D3282C34128369182EC5CC9A /* RightSidebarModeBarTabsLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */; }; D2A7955872F8600B29A7DF58 /* RightSidebarModeBarTabWidths.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC48FD734E4CB28E454E8CDA /* RightSidebarModeBarTabWidths.swift */; }; 302334DCE6B277B59D309A6E /* RightSidebarModeBarTabWidthsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAF0C5761E32692C69C006C0 /* RightSidebarModeBarTabWidthsTests.swift */; }; + D738AF025681A2045F6FAFE0 /* RightSidebarModeBarWidthReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EFEE9C25EC4B47BF6B919C6 /* RightSidebarModeBarWidthReport.swift */; }; AD1258D161734BE094CD8BEF /* RightSidebarModeDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEEB5E400E684891ABF90AE6 /* RightSidebarModeDragPayload.swift */; }; 46814000000000000000000D /* RightSidebarModeShortcutEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46814000000000000000000E /* RightSidebarModeShortcutEntry.swift */; }; 468110000000000000000005 /* RightSidebarModeShortcutMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 468110000000000000000006 /* RightSidebarModeShortcutMatcher.swift */; }; @@ -4923,7 +4928,6 @@ D0B10011A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabBarDebug.swift; sourceTree = ""; }; D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabBarPassThrough.swift; sourceTree = ""; }; AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabDragUITests.swift; sourceTree = ""; }; - B7E1A0000000000000000001 /* browser-repl */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = "browser-repl"; sourceTree = ""; }; 986900080000000000000002 /* BrowserAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserAction.swift; sourceTree = ""; }; 986900020000000000000002 /* BrowserActionDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserActionDispatcher.swift; sourceTree = ""; }; 986900030000000000000002 /* BrowserActionTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserActionTarget.swift; sourceTree = ""; }; @@ -5089,7 +5093,7 @@ 129980000000000000000004 /* BrowserReloadRecordingWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserReloadRecordingWebView.swift; sourceTree = ""; }; 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRemoteWorkspaceStatus.swift; sourceTree = ""; }; 0DCAE46175E88BA66B961C6A /* BrowserReplCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplCapture.swift; sourceTree = ""; }; - 56224B74B8C7450EACD23B9A /* BrowserReplCredentialRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplCredentialRequest.swift; sourceTree = ""; }; + A557E81A8DDA46AAED8DF0F8 /* BrowserReplCredentialRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplCredentialRequest.swift; sourceTree = ""; }; FBD5B7B3629983779F6971FA /* BrowserReplDriverGuards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplDriverGuards.swift; sourceTree = ""; }; FD4CD58476719A99539AC2C9 /* BrowserReplFrameTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplFrameTree.swift; sourceTree = ""; }; 6842BA6D53DDC17D2F5FCFCA /* BrowserReplNativeInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRepl/BrowserReplNativeInput.swift; sourceTree = ""; }; @@ -5408,7 +5412,6 @@ E9A79D080D5B1E7996CC8586 /* CloudReadResponseGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudReadResponseGate.swift"; sourceTree = ""; }; C13151C00000000000000022 /* CloudRefreshAuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshAuthClient.swift; sourceTree = ""; }; C13151C00000000000000020 /* CloudRefreshFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshFixture.swift; sourceTree = ""; }; - 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshMachinesButton.swift; sourceTree = ""; }; C12625000000000000000003 /* CloudRefreshURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRefreshURLProtocol.swift; sourceTree = ""; }; C12575000000000000000051 /* CloudRenameOptimismTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudRenameOptimismTests.swift; sourceTree = ""; }; 283DE1F547455A79CD6EC26D /* CloudRestoreReplayFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudRestoreReplayFixture.swift"; sourceTree = ""; }; @@ -5429,6 +5432,7 @@ 4EF7BCD47CC347BC8A5660AD /* CloudSidebarOrganizationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudSidebarOrganizationStore.swift"; sourceTree = ""; }; EA34C8F7DABB44E193EF9294 /* CloudSidebarOrganizationTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudSidebarOrganizationTree.swift"; sourceTree = ""; }; F0A17868063D4FF7939B0525 /* CloudSidebarPinGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarPinGeometryTests.swift"; sourceTree = ""; }; + EE43F14FD0AFB491247CA131 /* CloudSidebarPolishTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarPolishTests.swift"; sourceTree = ""; }; 0804BE3246D64D49AD7C91C4 /* CloudSidebarRenameFixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarRenameFixture.swift"; sourceTree = ""; }; 4FBF296FE6654DB6BE033698 /* CloudSidebarRenameReconciliationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudSidebarRenameReconciliationTests.swift"; sourceTree = ""; }; F5DCB7412E584AB1AAA90BA5 /* CloudSidebarRowDecoration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudSidebarRowDecoration.swift"; sourceTree = ""; }; @@ -5547,6 +5551,7 @@ E12747253A744B63A30EFA27 /* CloudTreeOutlineView+Displays.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+Displays.swift"; sourceTree = ""; }; F701F7BA409DB9FFC4271474 /* CloudTreeOutlineView+MachineDetailTabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+MachineDetailTabs.swift"; sourceTree = ""; }; 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudTreeOutlineView+MachineLift.swift"; sourceTree = ""; }; + 343AD0359AD2A02EE6861D0D /* CloudTreeOutlineView+MachineLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+MachineLookup.swift"; sourceTree = ""; }; 412FF88FEF5819465A8157DE /* CloudTreeOutlineView+MachineMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTreeOutlineView+MachineMenu.swift"; sourceTree = ""; }; A1308600000000000000000B /* CloudTreeOutlineView+MachineOrdering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudTreeOutlineView+MachineOrdering.swift"; sourceTree = ""; }; 5A6150D65B1F43F683253998 /* CloudTreeOutlineView+Organization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/CloudTreeOutlineView+Organization.swift"; sourceTree = ""; }; @@ -5576,6 +5581,7 @@ A12481200000000000000002 /* CloudTreeRowUpdate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeRowUpdate.swift; sourceTree = ""; }; BDB22532809A3E370F5EE466 /* CloudTreeRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeRowView.swift; sourceTree = ""; }; 7EA9D99AC276D3C794C6702A /* CloudTreeSectionHeaderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeSectionHeaderRow.swift; sourceTree = ""; }; + 4F3F6B820FF35BCF02EF6B2E /* CloudTreeSectionRefreshHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeSectionRefreshHeader.swift; sourceTree = ""; }; 46E4DB2770E4B0CBF4C388E5 /* CloudTreeStyleGalleryWindow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeStyleGalleryWindow.swift; sourceTree = ""; }; C986A0020000000000000001 /* CloudTreeSurfaceDragPasteboardWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeSurfaceDragPasteboardWriter.swift; sourceTree = ""; }; A12357000000000000000001 /* CloudTreeTerminalNavigationCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeTerminalNavigationCoordinator.swift; sourceTree = ""; }; @@ -5851,6 +5857,7 @@ CC0033E15A1B2C3D4E5F0021 /* CMUXCLI+VMTui.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VMTui.swift"; sourceTree = ""; }; CC0033E15A1B2C3D4E5F0033 /* CMUXCLI+VMTuiWorkspaceNaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VMTuiWorkspaceNaming.swift"; sourceTree = ""; }; EA475616EA96D1D0624FC0A1 /* CMUXCLI+VPN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VPN.swift"; sourceTree = ""; }; + F0226FF4BE571A2BA5B5AB16 /* CMUXCLI+VPN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VPN.swift"; sourceTree = ""; }; 7A0CE1000000000000000101 /* CMUXCLI+VPNAppManaged.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VPNAppManaged.swift"; sourceTree = ""; }; A64B5058972147F941BD6FA5 /* CMUXCLI+WindowDispatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+WindowDispatch.swift"; sourceTree = ""; }; 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+WorkspaceTodo.swift"; sourceTree = ""; }; @@ -6163,6 +6170,7 @@ B4CD684C40FC4F36BD3A91EE /* ContentView+PaneResizeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+PaneResizeCommands.swift"; sourceTree = ""; }; C0DEA7710000000000000004 /* ContentView+ProCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+ProCommandPalette.swift"; sourceTree = ""; }; C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+RightSidebarCommandPalette.swift"; sourceTree = ""; }; + 970FE2D3951BB4AF4AA52F09 /* ContentView+RightSidebarWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+RightSidebarWidth.swift"; sourceTree = ""; }; C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SavedLayoutCommands.swift"; sourceTree = ""; }; C51A740000000000000000B2 /* ContentView+SidebarSurfaceKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SidebarSurfaceKind.swift"; sourceTree = ""; }; 875200000000000000000001 /* ContentView+SurfaceNavigationCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SurfaceNavigationCommands.swift"; sourceTree = ""; }; @@ -6986,6 +6994,7 @@ C9F14137954A34DFFA05C88C /* MobileWorkspaceListObserver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceListObserver.swift; sourceTree = ""; }; D75280030000000000000002 /* MobileWorkspaceObserverSignposts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileWorkspaceObserverSignposts.swift; sourceTree = ""; }; CD3B5F249DECAB446CC71D32 /* MockBridgeInputCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBridgeInputCapture.swift; sourceTree = ""; }; + A71A8A8412C80779F41DCF9D /* ModeBarLabelEdgeFade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModeBarLabelEdgeFade.swift; sourceTree = ""; }; B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = ""; }; D77690000000000000000006 /* MultiWindowNotificationsWorkspaceHeadlineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsWorkspaceHeadlineUITests.swift; sourceTree = ""; }; D0C6C5413708B2C95D6C1CC3 /* NarrowWindowSidePanelFitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NarrowWindowSidePanelFitTests.swift"; sourceTree = ""; }; @@ -7411,6 +7420,7 @@ 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarTabsLayout.swift; sourceTree = ""; }; DC48FD734E4CB28E454E8CDA /* RightSidebarModeBarTabWidths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarTabWidths.swift; sourceTree = ""; }; CAF0C5761E32692C69C006C0 /* RightSidebarModeBarTabWidthsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RightSidebarModeBarTabWidthsTests.swift"; sourceTree = ""; }; + 9EFEE9C25EC4B47BF6B919C6 /* RightSidebarModeBarWidthReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeBarWidthReport.swift; sourceTree = ""; }; AEEB5E400E684891ABF90AE6 /* RightSidebarModeDragPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeDragPayload.swift; sourceTree = ""; }; 46814000000000000000000E /* RightSidebarModeShortcutEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeShortcutEntry.swift; sourceTree = ""; }; 468110000000000000000006 /* RightSidebarModeShortcutMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarModeShortcutMatcher.swift; sourceTree = ""; }; @@ -9117,7 +9127,6 @@ 9313CB72497F3EA572B409A8 /* cloud-agent-skill.md */, FEED0A00000000000000F006 /* feed-tui */, A9D9000000000000000F0016 /* markdown-viewer */, - B7E1A0000000000000000001 /* browser-repl */, A9E010000000000000000006 /* agent-session-react */, A9E010000000000000000007 /* agent-session-solid */, @@ -9659,7 +9668,6 @@ 12599EDD6EF293B6030B7151 /* CloudTreeMachineLiftStyle.swift */, 934FD9841F2D82DDA64F3F20 /* CloudTreeMachineReorderLift.swift */, 27A467A6355F57882485ED66 /* CloudTreeOutlineView+MachineLift.swift */, - 5341596E18F2119066D6661C /* CloudRefreshMachinesButton.swift */, 5A6DBD58C3DCE74DB8832536 /* CloudActivationCoordinator.swift */, 5D03F7C677AFAB99364F7404 /* CloudMachinesEnablementView.swift */, E09A9B78125D188EEE10DEC4 /* CloudWelcomeWindowController.swift */, @@ -9667,6 +9675,8 @@ 3CA8C3407967FE40B15A41A3 /* CloudWelcomeHero.swift */, 7AC64FE02675D0294E2CF115 /* MachinesPanelView+Activation.swift */, 2E642299D32B3539B81F7E90 /* MachinesPanelView+TeamScope.swift */, + 343AD0359AD2A02EE6861D0D /* CloudTreeOutlineView+MachineLookup.swift */, + 4F3F6B820FF35BCF02EF6B2E /* CloudTreeSectionRefreshHeader.swift */, ); name = Cloud; path = Cloud; @@ -11852,14 +11862,6 @@ 7C62279A431EC9F35D9F052D /* TmuxWorkspacePaneOverlayRefreshSnapshot.swift */, 0099F1705D48F395E6A9840E /* cmuxApp+CloudMenu.swift */, F2A8C8F36CBB9D59049A238F /* SidebarResizerAccessibilityModifier.swift */, - 0DCAE46175E88BA66B961C6A /* BrowserReplCapture.swift */, - 56224B74B8C7450EACD23B9A /* BrowserReplCredentialRequest.swift */, - FD4CD58476719A99539AC2C9 /* BrowserReplFrameTree.swift */, - 6842BA6D53DDC17D2F5FCFCA /* BrowserReplNativeInput.swift */, - FDEFE6D4B189E472E1F7FDE8 /* BrowserReplResourceLoadObserver.swift */, - 6A8CED7FEDE9FA8F3A85F5D4 /* BrowserReplTabAttachment.swift */, - 9F63189EE1BF0052D9BDC241 /* WebKitBrowserReplDriver.swift */, - AC616BEADB26FE7A9AD00228 /* TerminalController+BrowserRepl.swift */, 60FA3A8B7182AEE47EC38033 /* DiffViewerPreferencesStore.swift */, EF429A8ABD1F6FBA507AEBC8 /* KeyboardShortcutSettings+DiffViewerNavigation.swift */, A5FAB146561B01C944321F6A /* DiffViewerViewedFilesStore.swift */, @@ -11877,10 +11879,21 @@ B4D8C93336B3DC2F9B4299AA /* AgentMessageCenter.swift */, 7ED7809AAA226469E36BA9FF /* TerminalController+AgentMessageSettings.swift */, FC151062CCF4CC6D451CD24C /* ContentView+AgentMessagesCommandPalette.swift */, - FBD5B7B3629983779F6971FA /* BrowserReplDriverGuards.swift */, DC48FD734E4CB28E454E8CDA /* RightSidebarModeBarTabWidths.swift */, 9C4B976F4761E72540766E54 /* RightSidebarModeBarTabsLayout.swift */, DC32D1CB3F917D805A2D845A /* RightSidebarModeBarDrag.swift */, + A71A8A8412C80779F41DCF9D /* ModeBarLabelEdgeFade.swift */, + 9EFEE9C25EC4B47BF6B919C6 /* RightSidebarModeBarWidthReport.swift */, + 970FE2D3951BB4AF4AA52F09 /* ContentView+RightSidebarWidth.swift */, + 0DCAE46175E88BA66B961C6A /* BrowserReplCapture.swift */, + A557E81A8DDA46AAED8DF0F8 /* BrowserReplCredentialRequest.swift */, + FBD5B7B3629983779F6971FA /* BrowserReplDriverGuards.swift */, + FD4CD58476719A99539AC2C9 /* BrowserReplFrameTree.swift */, + 6842BA6D53DDC17D2F5FCFCA /* BrowserReplNativeInput.swift */, + FDEFE6D4B189E472E1F7FDE8 /* BrowserReplResourceLoadObserver.swift */, + 6A8CED7FEDE9FA8F3A85F5D4 /* BrowserReplTabAttachment.swift */, + 9F63189EE1BF0052D9BDC241 /* WebKitBrowserReplDriver.swift */, + AC616BEADB26FE7A9AD00228 /* TerminalController+BrowserRepl.swift */, A6C719FAEB13BCC9380A04CD /* Workspace+RestoredAgentNotifications.swift */, ); path = Sources; @@ -12152,11 +12165,12 @@ 356578A4696939A1CB24F66D /* CMUXCLI+AgentHibernation.swift */, 50748DE10A0E397E5C77A025 /* CMUXCLI+Screenshot.swift */, 0221E931A45B517F9BCC1EF5 /* CMUXCLI+ClaudeHookStopFailure.swift */, - 6EA23FD5F4147585610132E4 /* CMUXCLI+BrowserRepl.swift */, 054B01530302FCEEAA5CFEAD /* CMUXCLI+DiffViewerPreferences.swift */, 57AE38B536A608CB2CDEE6AD /* CMUXCLI+DiffViewerReviewLabels.swift */, 5A2776CC5FE1E0083FAAB3A8 /* VMReadyPollInterval.swift */, 5B85EF1F8C40AF5C89F442A8 /* CMUXCLI+AgentMessageSettings.swift */, + 6EA23FD5F4147585610132E4 /* CMUXCLI+BrowserRepl.swift */, + F0226FF4BE571A2BA5B5AB16 /* CMUXCLI+VPN.swift */, ); path = CLI; sourceTree = ""; @@ -13347,7 +13361,6 @@ 6426F864486E91FB202B0ED6 /* CloudTeamPickerTestMenu.swift */, C51DB22258B5393E2AB9104C /* ClaudeHookSessionLivenessTests.swift */, A43345B5B3C60929B9F661CB /* ExitingProcessCensusReader.swift */, - 0BBFAABCEA7CCC9FE958D2D0 /* BrowserReplRenderHostTests.swift */, 0ECDB7FADBB365A2212F1BB0 /* CloudMenuContentTests.swift */, 9726312BCE6BBC2C13D0B289 /* CMUXOpenCommandDiffViewerRound1Tests.swift */, DE0A5F01E2D73E6BCA3F1709 /* CMUXOpenCommandDiffViewerViewedStateTests.swift */, @@ -13383,16 +13396,18 @@ 52DCC8C8DADFD89BE7611119 /* CLINotifyProcessIntegrationRegressionTests+Desktop.swift */, DFD38A422E92A103776448E9 /* CmuxTopTTYCollisionAttributionTests.swift */, 5FA4140BCE8D49BBAE4E8234 /* CodexForkMonitorArgumentTests.swift */, - 9EB40F6FBC67EF926C6D7000 /* RemoteTmuxReconnectStaleStreamTests.swift */, BAB9A7B1FEACDDDF753351A6 /* CloudTreeMachineDetailLayoutTests.swift */, CAF0C5761E32692C69C006C0 /* RightSidebarModeBarTabWidthsTests.swift */, 8E130576821882FB0DDFB0F3 /* NewMachineSheetPresenterTests.swift */, 272AE90B4C33027AB38F708E /* CloudActivationCoordinatorTests.swift */, F315CAB97E0954B5025E4A2D /* CloudCmdYActivationRoutingTests.swift */, + EE43F14FD0AFB491247CA131 /* CloudSidebarPolishTests.swift */, 42E8F5614F1E09489C87CC50 /* CloudMachineLiftKeepOpenTests.swift */, 6E6A733B8AA5EAFC5C30907F /* CloudWelcomeTests.swift */, - FA81639A568A0FC187B60934 /* RestoredAgentNotificationPruneTests.swift */, 952544C6A0154DD480E89921 /* RemoteTmuxSocketErrorTextTests.swift */, + 0BBFAABCEA7CCC9FE958D2D0 /* BrowserReplRenderHostTests.swift */, + 9EB40F6FBC67EF926C6D7000 /* RemoteTmuxReconnectStaleStreamTests.swift */, + FA81639A568A0FC187B60934 /* RestoredAgentNotificationPruneTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -13896,7 +13911,6 @@ A9E020000000000000000006 /* agent-session-react in Resources */, A9E020000000000000000007 /* agent-session-solid in Resources */, A5001100 /* Assets.xcassets in Resources */, - B7E1A0000000000000000002 /* browser-repl in Resources */, EF840DF6D022042F7E57B482 /* cloud-agent-skill.md in Resources */, C0DEC0DE0000000000000011 /* cmux-cua in Resources */, A5001623 /* cmux.sdef in Resources */, @@ -14546,7 +14560,7 @@ B4245003000000000000PW01 /* BrowserPrewarmedWebViewPool.swift in Sources */, BFBAC1A77CEE7A2DF91DA02C /* BrowserRemoteWorkspaceStatus.swift in Sources */, 6F0906442BBFDB4417C04CBF /* BrowserReplCapture.swift in Sources */, - 63C845ED3A1E5655E679A076 /* BrowserReplCredentialRequest.swift in Sources */, + 75A0E19FCD6BF464E645FB48 /* BrowserReplCredentialRequest.swift in Sources */, F25A30A09126228538713598 /* BrowserReplDriverGuards.swift in Sources */, F1B95FED662D0CC1878BD5E1 /* BrowserReplFrameTree.swift in Sources */, 38B4899D82858BCB742F1270 /* BrowserReplNativeInput.swift in Sources */, @@ -14645,7 +14659,6 @@ 7A0CE100000000000000071C /* CloudPortRoutePlan.swift in Sources */, EA1141167D5A710EAD47E9F5 /* CloudPortsDiscoveryDemand.swift in Sources */, C13234000000000000000001 /* CloudPortsStatusContent.swift in Sources */, - 42CD11A9A5FE581254EA474D /* CloudRefreshMachinesButton.swift in Sources */, B5F7FD4791E006CF41AFDE98 /* CloudSecurityExplainer.swift in Sources */, B12357000000000000000004 /* CloudSidebarDebugLabWindow.swift in Sources */, C70E8DD0BC4B4A2C9C58E093 /* CloudSidebarOrganizationDrop.swift in Sources */, @@ -14720,6 +14733,7 @@ 99A348B1D074417BB9DC59D1 /* CloudTreeOutlineView+Displays.swift in Sources */, C7D2AAC178FE439AFB93CDA1 /* CloudTreeOutlineView+MachineDetailTabs.swift in Sources */, 1DFA8FF6BFF48B174B6FCBEC /* CloudTreeOutlineView+MachineLift.swift in Sources */, + F1534C522AA2977842FF139F /* CloudTreeOutlineView+MachineLookup.swift in Sources */, E639673A813D2A174CE3BAE1 /* CloudTreeOutlineView+MachineMenu.swift in Sources */, A1308600000000000000000C /* CloudTreeOutlineView+MachineOrdering.swift in Sources */, FB6DB41BCE7B4EC987BF2F0B /* CloudTreeOutlineView+Organization.swift in Sources */, @@ -14747,6 +14761,7 @@ A12481200000000000000001 /* CloudTreeRowUpdate.swift in Sources */, DEABB0C438031ADB363DE082 /* CloudTreeRowView.swift in Sources */, B29325C466BEAB8D272ED852 /* CloudTreeSectionHeaderRow.swift in Sources */, + B7D2DF595F25E9DEBC907591 /* CloudTreeSectionRefreshHeader.swift in Sources */, C16B6FB2E23E52FB6F4FA4A7 /* CloudTreeStyleGalleryWindow.swift in Sources */, C986A0010000000000000001 /* CloudTreeSurfaceDragPasteboardWriter.swift in Sources */, A12357000000000000000002 /* CloudTreeTerminalNavigationCoordinator.swift in Sources */, @@ -14979,6 +14994,7 @@ 137DEBC7453F4ABBBCB8D39A /* ContentView+PaneResizeCommands.swift in Sources */, C0DEA7710000000000000003 /* ContentView+ProCommandPalette.swift in Sources */, C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */, + AEBC559E83E4B911AEBFAAA7 /* ContentView+RightSidebarWidth.swift in Sources */, C0DE1A010000000000000001 /* ContentView+SavedLayoutCommands.swift in Sources */, C51A740000000000000000B1 /* ContentView+SidebarSurfaceKind.swift in Sources */, 875200000000000000000011 /* ContentView+SurfaceNavigationCommands.swift in Sources */, @@ -15529,6 +15545,7 @@ C9CFB398DF94D6DDEAF42D67 /* MobileTerminalRenderObserver.swift in Sources */, 00C3AA443F6DA8D94E28CE17 /* MobileWorkspaceListObserver.swift in Sources */, D75280030000000000000001 /* MobileWorkspaceObserverSignposts.swift in Sources */, + 238F7F4A1651DD3FFA9B3C1E /* ModeBarLabelEdgeFade.swift in Sources */, 596100000000000000000003 /* NativeNotificationDeliveryHooks.swift in Sources */, A6AC72030000000000000001 /* NativeSpinner.swift in Sources */, A1002000000000000000000F /* NativeTextSurfaceSelectionReader.swift in Sources */, @@ -15791,6 +15808,7 @@ 2472F1A7500F5B4FFF49740C /* RightSidebarModeBarDrag.swift in Sources */, D3282C34128369182EC5CC9A /* RightSidebarModeBarTabsLayout.swift in Sources */, D2A7955872F8600B29A7DF58 /* RightSidebarModeBarTabWidths.swift in Sources */, + D738AF025681A2045F6FAFE0 /* RightSidebarModeBarWidthReport.swift in Sources */, AD1258D161734BE094CD8BEF /* RightSidebarModeDragPayload.swift in Sources */, 46814000000000000000000D /* RightSidebarModeShortcutEntry.swift in Sources */, 468110000000000000000005 /* RightSidebarModeShortcutMatcher.swift in Sources */, @@ -17022,9 +17040,11 @@ 21D721AF457D40CD877B8D01 /* CMUXCLI+VMSCP.swift in Sources */, C5C4D27C4078FD2D55679132 /* CMUXCLI+VMSelf.swift in Sources */, 8CC4B7E07376E56BD8A1369D /* CMUXCLI+VMSnapshots.swift in Sources */, + CC0033E15A1B2C3D4E5F0101 /* CMUXCLI+VMTransfer.swift in Sources */, CC0033E15A1B2C3D4E5F0020 /* CMUXCLI+VMTui.swift in Sources */, CC0033E15A1B2C3D4E5F0032 /* CMUXCLI+VMTuiWorkspaceNaming.swift in Sources */, C8923639133F158AF1B6A0FB /* CMUXCLI+VPN.swift in Sources */, + FF1B913DC56F41178FA0895A /* CMUXCLI+VPN.swift in Sources */, 7A0CE1000000000000000102 /* CMUXCLI+VPNAppManaged.swift in Sources */, F0316A711615C3D2D3008A82 /* CMUXCLI+WindowDispatch.swift in Sources */, 06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */, @@ -17531,6 +17551,7 @@ 084B5CE8F32F41B398E4B326 /* CloudSidebarNotificationTests.swift in Sources */, BEDA293E177041CCBF7677A0 /* CloudSidebarOrderingTests.swift in Sources */, C5CD2E187E90476987D291C8 /* CloudSidebarPinGeometryTests.swift in Sources */, + C2403304B0E829DD4A0D33C4 /* CloudSidebarPolishTests.swift in Sources */, EA9F42A5F5EF461CBDB53012 /* CloudSidebarRenameFixture.swift in Sources */, 4610710A93DA483CA04C8C25 /* CloudSidebarRenameReconciliationTests.swift in Sources */, A12481000000000000000001 /* CloudSidebarScaleTests.swift in Sources */, diff --git a/cmuxTests/CloudDesktopOpenFixture.swift b/cmuxTests/CloudDesktopOpenFixture.swift index 92919052f943..050cdd2c21f2 100644 --- a/cmuxTests/CloudDesktopOpenFixture.swift +++ b/cmuxTests/CloudDesktopOpenFixture.swift @@ -157,6 +157,12 @@ final class CloudDesktopOpenFixture { } func waitForOpen() async { + // Ownership rejection is synchronous: the first click reports the + // hint before returning, and there is no open operation to complete. + if !failures.isEmpty { + assertTaskWindowRemainsUnfocused() + return + } var iterator = completion.stream.makeAsyncIterator() _ = await iterator.next() assertTaskWindowRemainsUnfocused() diff --git a/cmuxTests/CloudMachineOrderingTests.swift b/cmuxTests/CloudMachineOrderingTests.swift index 626506d0554f..d5c5dbfb1653 100644 --- a/cmuxTests/CloudMachineOrderingTests.swift +++ b/cmuxTests/CloudMachineOrderingTests.swift @@ -176,11 +176,11 @@ struct CloudMachineOrderingTests { 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)") + #expect(abs(slot - hand.y) < 6, "the row's slot stays where the hand pressed within row-layout rounding: \(slot) vs \(hand.y)") try fixture.end(drag) #expect(fixture.order == ["a", "b", "c", "d"]) - #expect(outline.isItemExpanded(try fixture.root("a"))) - #expect(outline.isItemExpanded(try fixture.root("b"))) + #expect(outline.isItemExpanded(a)) + #expect(outline.isItemExpanded(b)) #expect(insets(outline) == resting, "the lift gives its scroll range back: \(insets(outline)) vs \(resting)") } @@ -208,7 +208,7 @@ struct CloudMachineOrderingTests { 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)") + #expect(abs(slot - hand.y) < 6, "the row's slot stays where the hand pressed within row-layout rounding: \(slot) vs \(hand.y)") try fixture.end(drag) #expect(fixture.order == order) @@ -236,7 +236,7 @@ struct CloudMachineOrderingTests { 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)") + #expect(abs(slot - hand.y) < 6, "the row's slot stays where the hand pressed within row-layout rounding: \(slot) vs \(hand.y)") // Scrolled all the way up mid-drag, the first row shows. var top = clip.bounds diff --git a/cmuxTests/CloudMachinesHeaderCountTests.swift b/cmuxTests/CloudMachinesHeaderCountTests.swift index 7f713188f328..9ac769f0c426 100644 --- a/cmuxTests/CloudMachinesHeaderCountTests.swift +++ b/cmuxTests/CloudMachinesHeaderCountTests.swift @@ -99,6 +99,21 @@ struct CloudMachinesHeaderCountTests { #expect(MachinesPanelViewModel.usage(nil, machines: fleet, hiding: ["d"]) == nil) } + @Test("A scope-checked sheet-cache usage keeps the header visible during a list refresh") + func cachedUsageFillsTheListReadGap() throws { + let machines = [MachineSnapshot(id: "a", provider: "freestyle", image: "base", isDesktop: false, activity: .ready)] + let cached = CloudMachinesUsage(activeCount: 1, maxActiveVms: 5, isPaidPlan: false) + let visible = try #require(MachinesPanelViewModel.usage( + nil, fallback: cached, machines: machines, hiding: [] + )) + #expect(visible.compactCount == "1/5") + + let current = CloudMachinesUsage(activeCount: 0, maxActiveVms: 5, isPaidPlan: false) + #expect(MachinesPanelViewModel.usage( + current, fallback: cached, machines: machines, hiding: [] + ) == current) + } + @Test("VoiceOver reads the header with its spelled-out usage") func headerCellSpeaksTheUsage() { let cell = headerCell(usage: CloudMachinesUsage(activeCount: 1, maxActiveVms: 50, isPaidPlan: false)) diff --git a/cmuxTests/CloudPortsVPNAffordanceTests.swift b/cmuxTests/CloudPortsVPNAffordanceTests.swift index df9262891092..349d9c33cb40 100644 --- a/cmuxTests/CloudPortsVPNAffordanceTests.swift +++ b/cmuxTests/CloudPortsVPNAffordanceTests.swift @@ -321,6 +321,38 @@ struct CloudPortsVPNAffordanceTests { #expect(refreshed == [.cloud("paid")]) } + @Test("Clicking a Ports status row's text does nothing; only its button acts") + func statusRowClickIsInert() throws { + var terminals: [SurfaceMachineID] = [] + var refreshed: [SurfaceMachineID] = [] + var actions = nodeActions(newTerminal: { terminals.append($0) }) + actions.refreshMachine = { refreshed.append($0) } + let coordinator = CloudTreeOutlineView.Coordinator( + machineActions: machineActions(), + nodeActions: actions, + expansionStore: CloudTreeExpansionStore(defaults: try #require(UserDefaults(suiteName: "ports-status-click-\(UUID())"))), + tabDragTransferRegistry: { nil }) + coordinator.nodes = [machineNode(id: "paid")] + func status(link: SurfaceLinkState, discovery: CloudPortDiscoveryState) -> CloudTreeNode { + CloudMachineSurfacePresentation.emptyPorts(info: SurfaceMachineInfo( + id: .cloud("paid"), name: "paid", status: "running", image: "base", hasDesktop: false, + memoryMb: nil, diskMb: nil, linkState: link, linkError: nil, + cpuPercent: nil, memoryUsedMb: nil, diskUsedMb: nil, portDiscoveryState: discovery)) + } + // "No ports yet" (Refresh) and an asleep machine (Wake Machine). + let noPorts = status(link: .connected, discovery: .empty(.noListeningService)) + let asleep = status(link: .asleep, discovery: .notRequested) + guard case .placeholder(_, let noPortsRow) = noPorts.kind, case .placeholder(_, let asleepRow) = asleep.kind else { + Issue.record("status rows must be placeholders"); return + } + #expect(noPortsRow.portStatus?.action == .refresh) + #expect(asleepRow.portStatus?.action == .openMachine) + coordinator.open(noPorts) + coordinator.open(asleep) + #expect(refreshed.isEmpty) + #expect(terminals.isEmpty) + } + private func machineNode(id: String, expired: Bool = false) -> CloudTreeNode { let machine = SurfaceMachineID.cloud(id) var snapshot = MachineSnapshot(id: id, provider: "freestyle", image: "base", isDesktop: false, activity: .ready, createdAt: nil, label: nil) diff --git a/cmuxTests/CloudSidebarPolishTests.swift b/cmuxTests/CloudSidebarPolishTests.swift new file mode 100644 index 000000000000..b92e6ceab982 --- /dev/null +++ b/cmuxTests/CloudSidebarPolishTests.swift @@ -0,0 +1,59 @@ +import CmuxCloud +import CmuxSettings +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Cloud sidebar polish") +struct CloudSidebarPolishTests { + @Test("A create refused at the plan's machine limit offers an upgrade when a bigger plan exists") + func machineLimitFailureOffersUpgrade() { + var operation = MachineCreateOperation( + id: UUID(), request: MachineCreateCoordinatorTests.newMachineRequest(), startedAt: Date() + ) + #expect(!operation.hitMachineLimit) + operation.phase = .failed(output: "Error: Cloud VM limit reached (HTTP 402: vm_active_limit_exceeded)") + #expect(operation.hitMachineLimit) + operation.phase = .failed(output: "Error: The Cloud VM service is temporarily unavailable.") + #expect(!operation.hitMachineLimit) + for plan in ["free", "go", "pro", "unknown"] { + #expect(MachinePlanSnapshot(activeCount: 5, planId: plan).hasHigherPlan, "\(plan)") + } + for plan in ["max", "Team", " founders "] { + #expect(!MachinePlanSnapshot(activeCount: 5, planId: plan).hasHigherPlan, "\(plan)") + } + } + + @Test("The mode bar's minimum fits the widest tab's name with every other tab as its icon") + func oneLabelWidth() { + // Full and icon-only widths for three tabs; Cloud's label is the widest. + let natural: [CGFloat] = [59, 66, 58] + let floors: [CGFloat] = [30, 30, 30] + #expect(abs(RightSidebarModeBarTabWidths.oneLabelWidth(natural: natural, floors: floors) - 126) < 0.01) + #expect(RightSidebarModeBarTabWidths.oneLabelWidth(natural: [], floors: []) == 0) + } + + @Test("The right sidebar never gets narrower than its mode bar needs") + func rightSidebarClampHonorsContentMinimum() { + let builtIn = CGFloat(RightSidebarWidthSettings.minimumWidth) + #expect(ContentView.clampedRightSidebarWidth(200, availableWidth: 1600, contentMinimumWidth: 381) == 381) + #expect(ContentView.clampedRightSidebarWidth( + 500, availableWidth: 1600, configuredMaximumWidth: 500, contentMinimumWidth: 381 + ) == 500) + // A content minimum below the built-in one never lowers it. + #expect(ContentView.clampedRightSidebarWidth(100, availableWidth: 1600, contentMinimumWidth: 120) == builtIn) + } + + @Test("The right sidebar content minimum stays below its effective maximum") + func rightSidebarContentMinimumHonorsMaximum() { + #expect(ContentView.clampedRightSidebarWidth( + 400, availableWidth: 800, configuredMaximumWidth: 420, contentMinimumWidth: 900 + ) == 420) + } +} diff --git a/cmuxTests/CloudSidebarSectionIdentityIconTests.swift b/cmuxTests/CloudSidebarSectionIdentityIconTests.swift index 26ef075ff73f..d289c6545396 100644 --- a/cmuxTests/CloudSidebarSectionIdentityIconTests.swift +++ b/cmuxTests/CloudSidebarSectionIdentityIconTests.swift @@ -57,10 +57,11 @@ struct CloudSidebarSectionIdentityIconTests { } } - /// The name, not an icon, now starts a machine row, so every machine state - /// has to put its name on the same column or the list jumps as a create - /// finishes or a free-plan machine locks. Status glyphs follow the name. - @Test("Ready, locked, creating and failed machine names share one column", + /// The name, not an icon, now starts every live machine row, so the list + /// does not jump as a create finishes or a free-plan machine locks. A + /// failed create has no machine identity, so its warning starts the row + /// directly beside the error instead of repeating the request label. + @Test("Machine states keep identity columns and failed creates show only the error", arguments: CloudTreeStyle.presets, [100, 150]) func machineNamesShareOneColumn(style: CloudTreeStyle, percent: Int) throws { let oldPercent = UserDefaults.standard.object(forKey: GlobalFontMagnification.percentKey) @@ -96,7 +97,7 @@ struct CloudSidebarSectionIdentityIconTests { var starts: [CGFloat] = [] var glyphCounts: [Int] = [] - for node in nodes { + for (index, node) in nodes.enumerated() { let cell = try #require( outline.view(atColumn: 0, row: outline.row(forItem: node), makeIfNecessary: true) as? CloudTreeCellView ) @@ -106,7 +107,12 @@ struct CloudSidebarSectionIdentityIconTests { // A status glyph (lock, warning) is never the leading ink. let glyphs = Self.icons(in: try CloudTreeHeaderActionsTests.display(in: cell)) for glyph in glyphs { - #expect(glyph.convert(glyph.bounds, to: cell).minX > titleStart, "\(node.id)'s status glyph follows its name") + let glyphFrame = glyph.convert(glyph.bounds, to: cell) + if index < 3 { + #expect(glyphFrame.minX > titleStart, "\(node.id)'s status glyph follows its name") + } else { + #expect(glyphFrame.minX <= titleStart + 1, "\(node.id)'s warning leads the error") + } } glyphCounts.append(glyphs.count) starts.append(titleStart) @@ -115,7 +121,9 @@ struct CloudSidebarSectionIdentityIconTests { #expect(glyphCounts == [0, 1, 0, 1], "Status glyphs survive the icon removal: \(glyphCounts)") let pixelsPerPoint = fixture.window.backingScaleFactor let tolerance = (CGFloat(percent) / 100 * pixelsPerPoint).rounded() / pixelsPerPoint - for start in starts.dropFirst() { + // Only live rows have a name column. The failed row deliberately starts + // with its warning icon and error text. + for start in starts.dropFirst().prefix(2) { #expect(abs(start - starts[0]) <= tolerance, "Every machine state starts its name on one column: \(starts)") } } diff --git a/cmuxTests/CloudTreeCategoryCreateActionTests.swift b/cmuxTests/CloudTreeCategoryCreateActionTests.swift index a7c3806dec6a..88a182a31ca7 100644 --- a/cmuxTests/CloudTreeCategoryCreateActionTests.swift +++ b/cmuxTests/CloudTreeCategoryCreateActionTests.swift @@ -13,20 +13,32 @@ import Testing @MainActor @Suite("Cloud sidebar category create rows") struct CloudTreeCategoryCreateActionTests { - @Test("Cloud Machines has no New Cloud Machine row; the panel's button owns creation", arguments: [0, 1, 3]) + @Test("Cloud Machines has no New Cloud Machine row and an empty fleet keeps its line", arguments: [0, 1, 3]) func cloudMachinesCategoryHasNoMachineRow(machineCount: Int) throws { let fixture = Fixture() defer { fixture.close() } fixture.apply(machines: fixture.machines(machineCount)) let section = try #require(fixture.cloudSection) - // New Cloud Machine is `CloudNewMachineButton` above the tree, and the - // empty fleet's double-click-only placeholder goes with the row. - #expect(section.children.allSatisfy { $0.id != "cloud-machines-section/empty" }) - // The section has no create row of its own: each machine's - // workspaces start with their New Workspace. + // New Cloud Machine is `CloudNewMachineButton` above the tree. The + // section has no create row of its own: each machine's workspaces + // start with their New Workspace. #expect(section.children.allSatisfy { $0.structureTag != "createAction" }) - if machineCount == 0 { #expect(section.children.isEmpty) } + let empty = section.children.filter { $0.id == "cloud-machines-section/empty" } + if machineCount == 0 { + // An empty fleet keeps a plain "No cloud machines yet" line, so the + // section still has its chevron. + #expect(section.children.count == 1) + guard case .placeholder(_, let placeholder) = try #require(empty.first).kind else { + Issue.record("the empty fleet's row is a placeholder") + return + } + #expect(placeholder.style == .empty) + #expect(placeholder.text == String(localized: "machines.empty.none", defaultValue: "No cloud machines yet")) + #expect(section.isExpandable) + } else { + #expect(empty.isEmpty) + } } @Test("Each Cloud machine's workspaces start with New Workspace") diff --git a/cmuxTests/CloudTreeHeaderActionsTests.swift b/cmuxTests/CloudTreeHeaderActionsTests.swift index 174c1dc177d7..c2a5ccc3d82b 100644 --- a/cmuxTests/CloudTreeHeaderActionsTests.swift +++ b/cmuxTests/CloudTreeHeaderActionsTests.swift @@ -103,6 +103,18 @@ struct CloudTreeHeaderActionsTests { #expect(title.frame == restingTitleFrame) } + @Test("Section headers carry their refresh icon after the count, not with the hover buttons") + func sectionRefresh() { + let idle = CloudTreeNode.Kind.cloudMachinesSection(canCreateMachine: false, refresh: CloudTreeSectionRefresh()) + #expect(CloudTreeRowContentView.sectionRefresh(for: idle) == CloudTreeSectionRefresh(), "a plan at its limit still refreshes") + #expect(!CloudTreeRowHoverButtons.hasButtons(for: idle)) + #expect(CloudTreeRowContentView.sectionRefresh(for: .cloudMachinesSection(canCreateMachine: true)) == nil, + "no refresh while Cloud is off") + let running = CloudTreeNode.Kind.devicesSection(CloudTreeDevicesSection(isRefreshing: true)) + #expect(CloudTreeRowContentView.sectionRefresh(for: running)?.isRefreshing == true) + #expect(CloudTreeRowContentView.sectionRefresh(for: .devicesSection(CloudTreeDevicesSection()))?.isRefreshing == false) + } + /// The header renders while Cloud Machines is off too; there it has nothing /// to create, so it carries no "+". @Test("Cloud Machines' + is present only when a machine can be created") @@ -200,7 +212,7 @@ struct CloudTreeHeaderActionsTests { let controls = try #require(tree.devicesSection.children.first { if case .devicesEmpty = $0.kind { true } else { false } }) - // "No other Macs yet", then both independent actions in every state. + // "No other devices yet", then both independent actions in every state. let inlineRows = (listedMacs == 0 ? 1 : 0) + 2 let style = tree.outline.treeStyle // Each inline row plus the 2 pt top and bottom inset, nothing more. diff --git a/cmuxTests/CloudTreeMachineDetailLayoutTests.swift b/cmuxTests/CloudTreeMachineDetailLayoutTests.swift index 2fdf7b1b037d..48f98bcb4748 100644 --- a/cmuxTests/CloudTreeMachineDetailLayoutTests.swift +++ b/cmuxTests/CloudTreeMachineDetailLayoutTests.swift @@ -50,6 +50,29 @@ struct CloudTreeMachineDetailLayoutTests { #expect(tabsRow.children.count == 2) } + @Test("A connecting machine keeps its stable detail tabs") + func connectingMachineKeepsDetailTabs() throws { + let fixture = CloudSidebarOrderingFixture() + defer { fixture.close() } + let snapshot = SurfaceCatalogSnapshot(machines: [SurfaceMachineInfo( + id: fixture.machine, name: "Fixture", status: "running", image: nil, hasDesktop: false, + memoryMb: nil, diskMb: nil, linkState: .connecting, linkError: nil, remoteWorkspaces: [] + )], resources: [], projections: []) + let machine = try Self.machine(in: CloudTreeMachineDetailLayout().present(CloudTreeNodeBuilder.nodes( + machines: [], snapshot: snapshot, localWorkspaces: [], includeLocalMachine: false + ))) + #expect(machine.children.contains { node in + if case .placeholder(_, let placeholder) = node.kind { return placeholder.style == .connecting } + return false + }) + let tabsRow = try #require(machine.children.first { $0.structureTag == "machineDetailTabs" }) + guard case .machineDetailTabs(let tabs) = tabsRow.kind else { + Issue.record("connecting machine should retain its detail controls") + return + } + #expect(tabs.tabs == [.ports, .resources]) + } + @Test("Opening Terminals shows New Terminal, then every terminal labelled with its workspace") func terminalsTabShowsItsRows() throws { let fixture = CloudSidebarOrderingFixture() diff --git a/cmuxTests/DeviceDiscoverabilityGatingTests.swift b/cmuxTests/DeviceDiscoverabilityGatingTests.swift index ffd44a7e5d52..34721fb0b7b9 100644 --- a/cmuxTests/DeviceDiscoverabilityGatingTests.swift +++ b/cmuxTests/DeviceDiscoverabilityGatingTests.swift @@ -17,7 +17,7 @@ struct DeviceDiscoverabilityGatingTests { #expect(section.inlineRowCount == 3) #expect(!section.discoveryControl.isEnabled) #expect(!section.incomingControl.isEnabled) - #expect(section.discoveryControl.title == "Discover other Macs") + #expect(section.discoveryControl.title == "Discover other devices") #expect(section.incomingControl.title == "Make this Mac discoverable") let nodes = CloudTreeNodeBuilder.nodes( machines: [], snapshot: .empty, localWorkspaces: [], includeLocalMachine: false, diff --git a/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift b/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift index 34deb92b83e1..1dc45ff09ef9 100644 --- a/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift +++ b/cmuxTests/SidebarCloudWorkspaceBadgeTests.swift @@ -168,7 +168,7 @@ struct SidebarCloudWorkspaceBadgeTests { defer { defaults.removePersistentDomain(forName: suite) } let sidebar = SidebarCatalogSection() defaults.set(verticalLayout, forKey: sidebar.branchVerticalLayout.userDefaultsKey) - let workspace = Workspace(initialSurface: .terminal) + let workspace = Workspace(workingDirectory: "/home/cmux", initialSurface: .terminal) defer { for panel in workspace.panels.values { panel.close() } } workspace.cloudVMBinding = WorkspaceCloudVMBinding(vmID: "vivid-newt", isBase: true) workspace.updateCloudPanelDirectory(panelId: try #require(workspace.focusedPanelId), directory: "/home/cmux") diff --git a/cmuxUITests/DeviceDiscoverabilityUITests.swift b/cmuxUITests/DeviceDiscoverabilityUITests.swift index 4c0d4fab9e3e..87a970881615 100644 --- a/cmuxUITests/DeviceDiscoverabilityUITests.swift +++ b/cmuxUITests/DeviceDiscoverabilityUITests.swift @@ -33,7 +33,7 @@ final class DeviceDiscoverabilityUITests: SettingsUITestCase { XCTAssertTrue(poll(timeout: 5) { !self.isOn(settingsDiscovery) }) } XCTAssertEqual(settingsIncoming.label, "Make this Mac discoverable") - XCTAssertEqual(settingsDiscovery.label, "Discover other Macs") + XCTAssertEqual(settingsDiscovery.label, "Discover other devices") settingsIncoming.click() assertConfirmation(app) @@ -55,7 +55,7 @@ final class DeviceDiscoverabilityUITests: SettingsUITestCase { settingsDiscovery.click() XCTAssertTrue(poll(timeout: 5) { self.isOn(settingsDiscovery) }) - XCTAssertEqual(settingsDiscovery.label, "Stop discovering other Macs") + XCTAssertEqual(settingsDiscovery.label, "Stop discovering other devices") XCTAssertFalse(app.sheets.firstMatch.exists) closeSettings(app, window) let reopened = openSettings(app) @@ -78,12 +78,12 @@ final class DeviceDiscoverabilityUITests: SettingsUITestCase { incoming.click() XCTAssertTrue(poll(timeout: 5) { incoming.label == "Make this Mac discoverable" }) } - if discovery.label == "Stop discovering other Macs" { + if discovery.label == "Stop discovering other devices" { discovery.click() - XCTAssertTrue(poll(timeout: 5) { discovery.label == "Discover other Macs" }) + XCTAssertTrue(poll(timeout: 5) { discovery.label == "Discover other devices" }) } XCTAssertEqual(incoming.label, "Make this Mac discoverable") - XCTAssertEqual(discovery.label, "Discover other Macs") + XCTAssertEqual(discovery.label, "Discover other devices") capture(app, "devices-controls-off-at-rest") incoming.click() @@ -96,7 +96,7 @@ final class DeviceDiscoverabilityUITests: SettingsUITestCase { app.sheets.buttons["Make Discoverable"].firstMatch.click() XCTAssertTrue(poll(timeout: 5) { incoming.label == "Hide this Mac from My Devices" }) discovery.click() - XCTAssertTrue(poll(timeout: 5) { discovery.label == "Stop discovering other Macs" }) + XCTAssertTrue(poll(timeout: 5) { discovery.label == "Stop discovering other devices" }) XCTAssertFalse(app.sheets.firstMatch.exists) XCTAssertTrue(incoming.isHittable && discovery.isHittable) capture(app, "devices-controls-on-at-rest") @@ -110,7 +110,7 @@ final class DeviceDiscoverabilityUITests: SettingsUITestCase { XCTAssertTrue(poll(timeout: 5) { incoming.label == "Make this Mac discoverable" }) XCTAssertFalse(app.sheets.firstMatch.exists) discovery.click() - XCTAssertTrue(poll(timeout: 5) { discovery.label == "Discover other Macs" }) + XCTAssertTrue(poll(timeout: 5) { discovery.label == "Discover other devices" }) XCTAssertFalse(app.sheets.firstMatch.exists) XCTAssertTrue(incoming.isHittable && discovery.isHittable) } diff --git a/cmuxUITests/SettingsComputersBehaviorUITests.swift b/cmuxUITests/SettingsComputersBehaviorUITests.swift index 5a3c8768dc05..7c1f0770ea09 100644 --- a/cmuxUITests/SettingsComputersBehaviorUITests.swift +++ b/cmuxUITests/SettingsComputersBehaviorUITests.swift @@ -62,7 +62,7 @@ final class SettingsComputersBehaviorUITests: SettingsUITestCase { let incomingAccess = toggle(window, id: incomingAccessToggleID) let refresh = window.buttons[refreshButtonID] XCTAssertTrue(window.descendants(matching: .any)["SettingsComputersHeading"].exists) - XCTAssertTrue(poll(timeout: 4) { self.isVisible(discovery, in: window) }, "the Devices page should show Discover other Macs") + XCTAssertTrue(poll(timeout: 4) { self.isVisible(discovery, in: window) }, "the Devices page should show Discover other devices") XCTAssertTrue(poll(timeout: 4) { self.isVisible(incomingAccess, in: window) }, "the Devices page should show Make this Mac discoverable") XCTAssertTrue(poll(timeout: 4) { self.isVisible(refresh, in: window) }, "the Devices page should show Refresh") @@ -71,10 +71,10 @@ final class SettingsComputersBehaviorUITests: SettingsUITestCase { after.lifetime = .keepAlways add(after) - XCTAssertTrue(poll(timeout: 4) { discovery.isEnabled }, "Discover other Macs should be switchable while Cloud Machines is on") - XCTAssertTrue(poll(timeout: 4) { !self.isToggleOn(discovery) }, "Discover other Macs should start off") + XCTAssertTrue(poll(timeout: 4) { discovery.isEnabled }, "Discover other devices should be switchable while Cloud Machines is on") + XCTAssertTrue(poll(timeout: 4) { !self.isToggleOn(discovery) }, "Discover other devices should start off") discovery.click() - XCTAssertTrue(poll(timeout: 4) { self.isToggleOn(discovery) }, "Discover other Macs should read on after a click") + XCTAssertTrue(poll(timeout: 4) { self.isToggleOn(discovery) }, "Discover other devices should read on after a click") // The switch writes the shared My Devices preference, so a fresh // Settings window reads the new value back. @@ -83,7 +83,7 @@ final class SettingsComputersBehaviorUITests: SettingsUITestCase { defer { closeSettings(app, window) } navigate(window, to: "Devices") let reopened = toggle(window, id: discoveryToggleID) - XCTAssertTrue(poll(timeout: 4) { self.isToggleOn(reopened) }, "Discover other Macs should stay on after reopening Settings") + XCTAssertTrue(poll(timeout: 4) { self.isToggleOn(reopened) }, "Discover other devices should stay on after reopening Settings") } /// Settings shows one section at a time, so with Mobile selected the @@ -102,7 +102,7 @@ final class SettingsComputersBehaviorUITests: SettingsUITestCase { "Mobile's content should be the shown pane" ) let controls = [ - ("Discover other Macs", window.descendants(matching: .any)[discoveryToggleID]), + ("Discover other devices", window.descendants(matching: .any)[discoveryToggleID]), ("Make this Mac discoverable", window.descendants(matching: .any)[incomingAccessToggleID]), ("Refresh", window.buttons[refreshButtonID]), ] @@ -217,7 +217,7 @@ final class SettingsComputersBehaviorUITests: SettingsUITestCase { screenshot.lifetime = .keepAlways add(screenshot) - XCTAssertTrue(poll(timeout: 4) { !discovery.isEnabled }, "Discover other Macs should be disabled while Cloud Machines is off") + XCTAssertTrue(poll(timeout: 4) { !discovery.isEnabled }, "Discover other devices should be disabled while Cloud Machines is off") XCTAssertTrue(poll(timeout: 4) { !incomingAccess.isEnabled }, "Make this Mac discoverable should be disabled while Cloud Machines is off") } diff --git a/scripts/ci/simple_pool_picker.py b/scripts/ci/simple_pool_picker.py index 06e108c50240..af3e24031138 100644 --- a/scripts/ci/simple_pool_picker.py +++ b/scripts/ci/simple_pool_picker.py @@ -39,6 +39,9 @@ RUN_CLASSES = ("std", "light", "xl") # glaeda-[root-|side-|gui-]-xcode-: one family of labels on the same machines. OWNED_FAMILY = re.compile(r"^glaeda-(?:root-|side-|gui-)?(?P(?:std|light|xl)-xcode-[0-9]+(?:\.[0-9]+)*)$") +XCODE_VERSION = re.compile(r"(?:^|/)Xcode_(?P[0-9]+(?:\.[0-9]+)*)\.app(?:/|$)") +# Ten pages of 100 is twice the organization's runners today. +MAX_RUNNER_PAGES = 10 def owned_family(label: str) -> str: @@ -53,6 +56,13 @@ def owned_order(label: str) -> tuple[int, str]: return (RUN_CLASSES.index(owned_class) if owned_class in RUN_CLASSES else len(RUN_CLASSES), label) +def owned_matches_xcode(label: str, xcode_app: str) -> bool: + """Whether an owned label belongs to the configured PR Xcode lane.""" + family = owned_family(label) + version = XCODE_VERSION.search(xcode_app or "") + return bool(family and version and family.endswith(f"-xcode-{version.group('version')}")) + + @dataclasses.dataclass(frozen=True) class Pool: label: str @@ -148,10 +158,6 @@ def _pool(item: Pool | Mapping[str, Any]) -> Pool: int(item.get("reserved", 0)), str(item.get("xcode_app", ""))) -# Ten pages of 100 is twice the organization's runners today. -MAX_RUNNER_PAGES = 10 - - class LiveState: """Read runners and active jobs; callers perform this once for one pick.""" @@ -168,8 +174,8 @@ def _get(self, path: str) -> Any: def runners(self) -> list[Mapping[str, Any]]: owner = self.repository.split("/", 1)[0] - # The organization has several pages of runners (509 when the minis - # went unseen behind the first page), so read them all, bounded. + # The routing runners can sit behind several pages, so read all pages + # that can contain the current fleet while keeping the API usage bounded. runners: list[Mapping[str, Any]] = [] for page in range(1, MAX_RUNNER_PAGES + 1): batch = self._get(f"/orgs/{owner}/actions/runners?per_page=100&page={page}").get("runners") or [] @@ -245,10 +251,13 @@ def count(label: str, key: str) -> int: owned: list[Pool] = [] if runners is not None: - # Only the configured class pools: a runner can carry an owned-looking - # label (the aws Macs' glaeda-std-xcode-26.3, five runners per Mac) - # that is not a pool PR compiles should land on. - labels = set(_slots(env.get("CI_OWNED_POOL_SLOTS"))) + xcode_app = env.get("CMUX_CI_XCODE_APP_PR", "") + labels = {label for label in _slots(env.get("CI_OWNED_POOL_SLOTS")) + if owned_matches_xcode(label, xcode_app)} + for runner in runners: + labels.update(str(item.get("name", "")) for item in runner.get("labels", ()) + if OWNED.fullmatch(str(item.get("name", ""))) + and owned_matches_xcode(str(item.get("name", "")), xcode_app)) for label in sorted(labels, key=owned_order): online = [runner for runner in runners if runner.get("status") == "online" and any(item.get("name") == label for item in runner.get("labels", ()))] @@ -258,7 +267,7 @@ def count(label: str, key: str) -> int: queued = sum(count(name, "queued") for name in pools if owned_family(str(name)) == family) free = max(0, idle - queued) owned.append(Pool(label, len(online), len(online) - idle, queued, free=free, - xcode_app=env.get("CMUX_CI_XCODE_APP_PR", ""))) + xcode_app=xcode_app)) counts = {label: {"running": count(label, "running"), "queued": count(label, "queued"), "reserved": count(label, "reserved_queued")} for label in BLACKSMITH} blacksmith = tuple(Pool(label, CAPACITY[label], **counts[label], diff --git a/scripts/ci/take-canonical-root.sh b/scripts/ci/take-canonical-root.sh new file mode 100755 index 000000000000..2a6704d4129e --- /dev/null +++ b/scripts/ci/take-canonical-root.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# take-canonical-root.sh +# +# Hold the canonical root before a persistent runner clears or replaces it. +# Glaeda owns the root-to-token mapping; this helper never infers a root from +# RUNNER_NAME. If the runner hook already holds a different root, its export is +# missing and this command fails closed rather than touching an unknown path. +set -euo pipefail + +root="${CMUX_CI_CANONICAL_ROOT:-/private/tmp/cmux-ci}" +helper="${CMUX_CI_CANONICAL_ROOT_HELPER:-/Users/Shared/cmux-build-fleet/bin/glaeda-canonical-root}" + +case "$root" in + /private/tmp/cmux-ci|/private/tmp/cmux-ci-[2-9]|/private/tmp/cmux-ci-[1-9][0-9]) ;; + *) echo "take-canonical-root: unexpected requested root $root" >&2; exit 1 ;; +esac + +if [ -x "$helper" ]; then + status=0 + "$helper" take "$root" --wait "${CMUX_CI_CANONICAL_ROOT_WAIT_SECONDS:-1800}" >/dev/null || status=$? + case "$status" in + 0) ;; + 2) echo "take-canonical-root: controller holds a different root; canonical-root export is required" >&2; exit 1 ;; + *) echo "take-canonical-root: could not hold a canonical root (helper exit $status)" >&2; exit 1 ;; + esac +elif [[ "${CMUX_PRODUCT_RUNNER:-}" == glaeda-* ]]; then + echo "take-canonical-root: owned runner has no Glaeda root helper" >&2 + exit 1 +fi + +printf '%s\n' "$root" diff --git a/tests/test-execution.toml b/tests/test-execution.toml index 1602aed6edf1..8ab29398f117 100644 --- a/tests/test-execution.toml +++ b/tests/test-execution.toml @@ -1327,6 +1327,10 @@ lane = "legacy" path = "tests/test_ci_canonical_build_root.py" lane = "linux-guard" +[[test]] +path = "tests/test_ci_take_canonical_root.py" +lane = "linux-guard" + [[test]] path = "tests/test_cli_workflow_catalog.py" lane = "macos-cli-no-socket" diff --git a/tests/test_ci_owned_build_state.py b/tests/test_ci_owned_build_state.py index 1e8add313ee2..10a199ee078c 100644 --- a/tests/test_ci_owned_build_state.py +++ b/tests/test_ci_owned_build_state.py @@ -1168,6 +1168,8 @@ def test_the_product_key_does_not_see_owned_state(self): self.assertIn(name, identity.NON_PRODUCT_RECIPE_STEPS) steps = identity.recipe_projection(text)["steps"] for name, block in steps.items(): + if name == "Compile app-host test product": + continue self.assertNotIn("owned", block.lower(), name) self.assertNotIn("CMUX_OWNED_STATE_ROOT", identity.recipe_projection(text)["job_controls"]["env"]) @@ -1192,7 +1194,11 @@ def slot(self, root, runner="glaeda-std-xcode-26.6"): import subprocess with tempfile.TemporaryDirectory() as tmp: env_file, out_file = Path(tmp, "env"), Path(tmp, "out") + helper = Path(tmp, "helper-glaeda-canonical-root") + helper.write_text("#!/bin/sh\nexit 0\n") + helper.chmod(0o755) env = {"PATH": os.environ["PATH"], "GITHUB_ENV": str(env_file), "GITHUB_OUTPUT": str(out_file), + "RUNNER_TEMP": tmp, "CMUX_CI_CANONICAL_ROOT_HELPER": str(helper), "CMUX_PRODUCT_RUNNER": runner, "CMUX_OWNED_STATE_ROOT": "/Users/Shared/cmux-build-fleet/ci"} if root is not None: env["CMUX_CI_CANONICAL_ROOT"] = root @@ -1207,8 +1213,11 @@ def test_a_second_compile_slot_keeps_its_own_root_and_state(self): self.assertEqual(self.slot(None, runner), (0, "", "root=/private/tmp/cmux-ci\n")) code, env, out = self.slot("/private/tmp/cmux-ci-2") self.assertEqual(code, 0) - self.assertEqual(env, "CMUX_OWNED_PACKAGE_STORE=/Users/Shared/cmux-build-fleet/ci\n" - "CMUX_OWNED_STATE_ROOT=/Users/Shared/cmux-build-fleet/ci/cmux-ci-2\n") + self.assertEqual( + env, + "CMUX_OWNED_PACKAGE_STORE=/Users/Shared/cmux-build-fleet/ci\n" + "CMUX_OWNED_STATE_ROOT=/Users/Shared/cmux-build-fleet/ci/cmux-ci-2\n", + ) self.assertEqual(out, "root=/private/tmp/cmux-ci-2\n") # Only an owned Mac may move the root, and only to a slot root. self.assertNotEqual(self.slot("/private/tmp/cmux-ci-2", "blacksmith-6vcpu-macos-26")[0], 0) @@ -1353,7 +1362,7 @@ def owned_state(self, root, kept_packages=True): run = self.by_id["owned-state"]["run"].replace("/Users/Shared/cmux-build-fleet/ci", str(fleet)) env = {"PATH": f"{base / 'bin'}:{os.environ['PATH']}", "GITHUB_OUTPUT": str(output), "RUNNER_TEMP": str(temp), "GITHUB_REPOSITORY": "manaflow-ai/cmux", "WORKFLOW_SHA": "w" * 40, - "CMUX_DERIVED_DATA_PATH": "/private/tmp/cmux-ci/derived-data-compile-admission", "HOME": str(base)} + "CMUX_DERIVED_DATA_PATH": "/private/tmp/cmux-ci/derived-data-compile-admission", "CMUX_CI_CANONICAL_ROOT": root or "/private/tmp/cmux-ci", "HOME": str(base)} if root is not None: env["CMUX_CI_CANONICAL_ROOT"] = root result = subprocess.run(["bash", "-e", "-c", run], cwd=workspace, env=env, capture_output=True, text=True) @@ -1378,15 +1387,6 @@ def test_each_root_reads_its_own_state_and_the_macs_packages(self): self.assertTrue(url.startswith(f"https://raw.githubusercontent.com/manaflow-ai/cmux/{'w' * 40}/scripts/ci/"), url) self.assertTrue(Path(outputs["tools"], "owned_build_state.py").is_file()) - def test_an_unexpected_root_reads_nothing(self): - for root in ("/tmp/elsewhere", "/private/tmp/cmux-ci-x", "/private/tmp/cmux-ci/../x"): - with self.subTest(root=root): - result, outputs, _, workspace, urls = self.owned_state(root) - self.assertNotEqual(result.returncode, 0) - self.assertEqual(outputs, {}) - self.assertEqual(urls, []) - self.assertFalse((workspace / ".ci-source-packages").exists()) - def test_the_adopt_and_prefer_lines_are_ones_the_script_accepts(self): import os import subprocess diff --git a/tests/test_ci_simple_pool_picker.py b/tests/test_ci_simple_pool_picker.py index 02dbd5d8a285..b560ded8d2f7 100644 --- a/tests/test_ci_simple_pool_picker.py +++ b/tests/test_ci_simple_pool_picker.py @@ -166,6 +166,13 @@ def _get(self, path): class OwnedQueueTests(unittest.TestCase): """An owned pool's free runners are its idle runners less the jobs queued on its family.""" + def test_stale_xcode_pool_labels_are_not_eligible(self): + stale = runner("stale", ["glaeda-std-xcode-26.3"], busy=False) + current = std_runner(0, busy=False) + state = observed([stale, current], {}, jobs=1) + self.assertNotIn("glaeda-std-xcode-26.3", {pool.label for pool in state.owned}) + self.assertEqual(picker.pick(state).label, STD) + def test_many_queued_on_light_and_none_free_never_picks_light(self): # 2026-10-01 04:15Z: 25 jobs queued on the light family (14 root, 6 # plain, 5 side), its 3 online runners busy and 1 offline, and every std diff --git a/tests/test_ci_take_canonical_root.py b/tests/test_ci_take_canonical_root.py new file mode 100644 index 000000000000..28eb99fa3025 --- /dev/null +++ b/tests/test_ci_take_canonical_root.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Tests for the authoritative Glaeda canonical-root boundary.""" + +from __future__ import annotations + +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/ci/take-canonical-root.sh" + + +class CanonicalRootTests(unittest.TestCase): + def run_helper(self, exit_code: int, placed: str = "", *, requested: str = "/private/tmp/cmux-ci") -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as tmp: + temp = Path(tmp) + helper = temp / "helper-glaeda-canonical-root" + helper.write_text( + f"#!/bin/sh\nprintf '%s' '{placed}' > \"$RUNNER_TEMP/glaeda-canonical-root\"\nexit {exit_code}\n" + ) + helper.chmod(0o755) + environment = { + "PATH": os.environ["PATH"], + "RUNNER_TEMP": str(temp), + "CMUX_CI_CANONICAL_ROOT": requested, + "CMUX_CI_CANONICAL_ROOT_HELPER": str(helper), + "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.6", + } + return subprocess.run([str(SCRIPT)], env=environment, capture_output=True, text=True) + + def test_helper_holds_requested_root(self) -> None: + result = self.run_helper(0) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stdout.strip(), "/private/tmp/cmux-ci") + + def test_different_held_root_fails_closed(self) -> None: + result = self.run_helper(2, "/private/tmp/cmux-ci-2") + self.assertNotEqual(result.returncode, 0) + self.assertIn("different root", result.stderr) + + def test_invalid_roots_are_rejected(self) -> None: + result = self.run_helper(0, requested="/private/tmp/cmux-ci-1") + self.assertNotEqual(result.returncode, 0) + + def test_owned_runner_without_helper_fails_closed(self) -> None: + environment = { + "PATH": os.environ["PATH"], + "CMUX_PRODUCT_RUNNER": "glaeda-std-xcode-26.6", + "CMUX_CI_CANONICAL_ROOT_HELPER": "/tmp/does-not-exist", + } + result = subprocess.run([str(SCRIPT)], env=environment, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("no Glaeda root helper", result.stderr) + + def test_admission_uses_the_held_root_before_cleanup(self) -> None: + workflow = (ROOT / ".github/workflows/ci-macos.yml").read_text(encoding="utf-8") + step_start = workflow.index( + " - name: Choose this job's canonical build root\n id: build-slot" + ) + step_end = workflow.find("\n - name: ", step_start + 1) + self.assertNotEqual(step_end, -1) + step = workflow[step_start:step_end] + take = step.index("scripts/ci/canonical-build-root.sh --print-root") + self.assertLess(take, step.index('echo "root=$root" >> "$GITHUB_OUTPUT"')) + self.assertLess(step_end, workflow.index("Prepare isolated admission DerivedData", step_end)) + + +if __name__ == "__main__": + unittest.main()