diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 5b84f6922f1e..571e77a6e42e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -288080,16 +288080,874 @@ "machines.new.size.label": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حجم الجهاز" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Veličina mašine" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Maskinstørrelse" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Maschinengröße" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Machine size" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tamaño de la máquina" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taille de la machine" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dimensioni macchina" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マシンサイズ" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំហំម៉ាស៊ីន" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "머신 크기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Maskinstørrelse" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozmiar maszyny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tamanho da máquina" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Размер машины" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาดเครื่อง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Makine boyutu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Розмір машини" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "机器大小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "機器大小" + } + } + } + }, + "machines.new.size.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر ملف الذاكرة والقرص لهذا الجهاز." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Odaberite profil memorije i diska za ovu mašinu." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vælg maskinens hukommelses- og diskprofil." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wählen Sie das Speicher- und Festplattenprofil für diese Maschine." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose the memory and disk profile for this machine." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elige el perfil de memoria y disco de esta máquina." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez le profil de mémoire et de disque de cette machine." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Scegli il profilo di memoria e disco per questa macchina." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このマシンのメモリとディスク構成を選択します。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ជ្រើសរើសទម្រង់អង្គចងចាំ និងថាសសម្រាប់ម៉ាស៊ីននេះ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 머신의 메모리 및 디스크 프로필을 선택하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Velg minne- og diskprofil for denne maskinen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wybierz profil pamięci i dysku dla tej maszyny." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Escolha o perfil de memória e disco desta máquina." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите профиль памяти и диска для этой машины." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลือกโปรไฟล์หน่วยความจำและดิสก์สำหรับเครื่องนี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu makine için bellek ve disk profilini seçin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Виберіть профіль пам’яті й диска для цієї машини." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择此机器的内存和磁盘配置。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇此機器的記憶體與磁碟設定。" + } + } + } + }, + "machines.new.size.required": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مطلوب" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obavezno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Påkrævet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erforderlich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Required" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Obligatorio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Requis" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Obbligatorio" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "必須" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ត្រូវការ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "필수" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Påkrevd" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wymagane" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Obrigatório" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обязательно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "จำเป็น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gerekli" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Обов’язково" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "必填" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "必填" + } + } + } + }, + "machines.new.size.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حجم الذاكرة العشوائية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Veličina RAM-a" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "RAM-størrelse" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "RAM-Größe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "RAM size" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tamaño de RAM" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Taille de la RAM" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Dimensione RAM" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "RAM サイズ" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំហំ RAM" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "RAM 크기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "RAM-størrelse" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozmiar RAM" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tamanho da RAM" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Размер ОЗУ" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ขนาด RAM" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "RAM boyutu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Розмір ОЗП" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内存大小" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "記憶體大小" + } + } + } + }, + "machines.new.size.menu": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB ذاكرة · قرص %2$d GB" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB RAM · %2$d GB disk" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB RAM · %2$d GB disk" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB RAM · %2$d GB Festplatte" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB RAM · %2$d GB disk" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB de RAM · %2$d GB de disco" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$d Go de RAM · disque de %2$d Go" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB di RAM · %2$d GB di disco" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "RAM %1$d GB · ディスク %2$d GB" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB RAM · ថាស %2$d GB" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "RAM %1$dGB · 디스크 %2$dGB" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB RAM · %2$d GB disk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB RAM · dysk %2$d GB" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB de RAM · disco de %2$d GB" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%1$d ГБ ОЗУ · диск %2$d ГБ" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "RAM %1$d GB · ดิสก์ %2$d GB" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB RAM · %2$d GB disk" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%1$d ГБ ОЗП · диск %2$d ГБ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB 内存 · %2$d GB 磁盘" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$d GB 記憶體 · %2$d GB 磁碟" + } + } + } + }, + "machines.new.size.ram": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الذاكرة العشوائية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, "en": { "stringUnit": { "state": "translated", - "value": "Size" + "value": "RAM" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "RAM" } }, "ja": { "stringUnit": { "state": "translated", - "value": "サイズ" + "value": "RAM" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ОЗУ" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "RAM" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "ОЗП" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内存" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "記憶體" + } + } + } + }, + "machines.new.size.disk": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "القرص" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Disk" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Disk" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Festplatte" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disk" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Disco" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Disque" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disco" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ディスク" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ថាស" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "디스크" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Disk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dysk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Disco" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Диск" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ดิสก์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Disk" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Диск" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "磁盘" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "磁碟" } } } diff --git a/Sources/Cloud/NewMachineModel.swift b/Sources/Cloud/NewMachineModel.swift index 4553ac9ff417..a07b24d2d138 100644 --- a/Sources/Cloud/NewMachineModel.swift +++ b/Sources/Cloud/NewMachineModel.swift @@ -22,6 +22,7 @@ struct MachineSizeOption: Equatable, Sendable { self.diskMb = diskMb } + /// The localized RAM value shown as the selected picker title. var title: String { String( format: String(localized: "machines.new.size.option", defaultValue: "%d GB RAM"), @@ -29,12 +30,30 @@ struct MachineSizeOption: Equatable, Sendable { ) } + /// The localized disk value shown below the selected picker title. var detail: String { String( format: String(localized: "machines.new.size.detail", defaultValue: "%d GB disk included"), diskMb / 1024 ) } + + /// The localized disk value shown in the resource summary. + var diskTitle: String { + String( + format: String(localized: "machines.new.size.gb", defaultValue: "%d GB"), + diskMb / 1024 + ) + } + + /// The localized, compact row title shown in the size menu. + var menuTitle: String { + String( + format: String(localized: "machines.new.size.menu", defaultValue: "%1$d GB RAM · %2$d GB disk"), + memoryMb / 1024, + diskMb / 1024 + ) + } } /// State behind the New Machine sheet: what the person picked, what the plan @@ -116,7 +135,7 @@ final class NewMachineModel { ) { self.mode = mode self.plan = plan - let serverOptions = memoryOptionsMb.filter { MachineSizeOption(memoryMb: $0) != nil } + let serverOptions = Set(memoryOptionsMb.filter { MachineSizeOption(memoryMb: $0) != nil }).sorted() // An empty list means an older control plane did not advertise the // ladder. Preserve its 20 GiB default and omit --size entirely. self.availableMemoryOptionsMb = serverOptions diff --git a/Sources/Cloud/NewMachineSheet.swift b/Sources/Cloud/NewMachineSheet.swift index 21af6ad62c43..9783ab51a4c0 100644 --- a/Sources/Cloud/NewMachineSheet.swift +++ b/Sources/Cloud/NewMachineSheet.swift @@ -11,83 +11,189 @@ struct NewMachineSheet: View { var body: some View { VStack(alignment: .leading, spacing: 14) { header - fields + if model.supportsSize { + sizeSection + } planSection if let errorText = model.errorText { errorBox(errorText) } buttons } - .padding(20) - .frame(width: 460) + .padding(24) + .frame(width: 500) .accessibilityIdentifier("NewMachineSheet") } private var header: some View { - VStack(alignment: .leading, spacing: 4) { - Text(model.isBaseSetup - ? String(localized: "machines.new.title.base", defaultValue: "Set Up Base") - : String(localized: "machines.new.title", defaultValue: "New Machine")) - .cmuxFont(size: 15, weight: .semibold) - Text(model.isBaseSetup - ? String( - localized: "machines.new.subtitle.base", - defaultValue: "Base is your persistent cloud machine. Opening it later reuses this same machine; reset Base to start over." - ) - : String( - localized: "machines.new.subtitle", - defaultValue: "A cloud computer with devtools and coding agents preinstalled. It keeps its home directory between sessions." - )) - .cmuxFont(size: 12) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + HStack(alignment: .top, spacing: 12) { + Image(systemName: model.isBaseSetup ? "externaldrive.fill" : "cpu") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 32, height: 32) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + + VStack(alignment: .leading, spacing: 4) { + Text(model.isBaseSetup + ? String(localized: "machines.new.title.base", defaultValue: "Set Up Base") + : String(localized: "machines.new.title", defaultValue: "New Machine")) + .cmuxFont(size: 16, weight: .semibold) + Text(model.isBaseSetup + ? String( + localized: "machines.new.subtitle.base", + defaultValue: "Base is your persistent cloud machine. Opening it later reuses this same machine; reset Base to start over." + ) + : String( + localized: "machines.new.subtitle", + defaultValue: "A cloud computer with devtools and coding agents preinstalled. It keeps its home directory between sessions." + )) + .cmuxFont(size: 12) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } } - private var fields: some View { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 10) { - if model.supportsSize { - GridRow { - label(String(localized: "machines.new.size.label", defaultValue: "Size")) - VStack(alignment: .leading, spacing: 4) { - Picker("", selection: $model.memoryMb) { - ForEach(model.memoryOptions, id: \.self) { memoryMb in - if let size = MachineSizeOption(memoryMb: memoryMb) { - Text(size.title).tag(memoryMb) - } - } + private var sizeSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "machines.new.size.label", defaultValue: "Machine size")) + .cmuxFont(size: 13, weight: .semibold) + Text(String( + localized: "machines.new.size.help", + defaultValue: "Choose the memory and disk profile for this machine." + )) + .cmuxFont(size: 11) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Text(String(localized: "machines.new.size.required", defaultValue: "Required")) + .cmuxFont(size: 10, weight: .medium) + .foregroundStyle(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 4) + .background(Color.primary.opacity(0.06), in: Capsule()) + } + + if let selectedSize = model.selectedSize { + Picker(selection: $model.memoryMb) { + ForEach(model.memoryOptions, id: \.self) { memoryMb in + if let size = MachineSizeOption(memoryMb: memoryMb) { + Text(size.menuTitle).tag(memoryMb) } - .pickerStyle(.segmented) - .labelsHidden() - .accessibilityIdentifier("NewMachineSheet.size") - if let size = model.selectedSize { - Text(size.detail) + } + } label: { + HStack(spacing: 10) { + Image(systemName: "memorychip") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text(selectedSize.title) + .cmuxFont(size: 13, weight: .semibold) + Text(selectedSize.detail) .cmuxFont(size: 11) .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) } + Spacer(minLength: 8) } + .frame(maxWidth: .infinity, alignment: .leading) + } + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 11) + .padding(.vertical, 9) + .background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(Color.primary.opacity(0.12), lineWidth: 1) + ) + .accessibilityIdentifier("NewMachineSheet.size") + .accessibilityLabel(String(localized: "machines.new.size.accessibilityLabel", defaultValue: "RAM size")) + .accessibilityValue(selectedSize.menuTitle) + } + + if let selectedSize = model.selectedSize { + HStack(spacing: 0) { + resourceMetric( + symbol: "memorychip", + label: String(localized: "machines.new.size.ram", defaultValue: "RAM"), + value: selectedSize.title + ) + resourceDivider + resourceMetric( + symbol: "internaldrive", + label: String(localized: "machines.new.size.disk", defaultValue: "Disk"), + value: selectedSize.diskTitle + ) } + .padding(.horizontal, 4) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("NewMachineSheet.resources") } } + .padding(13) + .background(Color.primary.opacity(0.025), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.primary.opacity(0.09), lineWidth: 1) + ) + .accessibilityIdentifier("NewMachineSheet.sizeSection") + } + + private var resourceDivider: some View { + Rectangle() + .fill(Color.primary.opacity(0.12)) + .frame(width: 1, height: 26) + .padding(.horizontal, 9) + } + + private func resourceMetric(symbol: String, label: String, value: String) -> some View { + HStack(spacing: 6) { + Image(systemName: symbol) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 14) + VStack(alignment: .leading, spacing: 1) { + Text(label.uppercased()) + .cmuxFont(size: 9, weight: .medium) + .foregroundStyle(.tertiary) + Text(value) + .cmuxFont(size: 11, weight: .medium, monospacedDigit: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) } @ViewBuilder private var planSection: some View { if model.planMeterText != nil || model.freeAccessNoteText != nil { - VStack(alignment: .leading, spacing: 3) { - if let meter = model.planMeterText { - Text(meter) - .cmuxFont(size: 11, weight: .medium) - .foregroundStyle(.secondary) - } - if let note = model.freeAccessNoteText { - Text(note) - .cmuxFont(size: 11) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + HStack(alignment: .top, spacing: 9) { + Image(systemName: "chart.bar.fill") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 16, height: 16) + VStack(alignment: .leading, spacing: 3) { + if let meter = model.planMeterText { + Text(meter) + .cmuxFont(size: 11, weight: .medium) + .foregroundStyle(.secondary) + } + if let note = model.freeAccessNoteText { + Text(note) + .cmuxFont(size: 11) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.primary.opacity(0.025), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + ) .accessibilityIdentifier("NewMachineSheet.plan") } } @@ -113,26 +219,33 @@ struct NewMachineSheet: View { } private var buttons: some View { - HStack(spacing: 8) { - Text(model.isBaseSetup - ? String(localized: "machines.new.background.note.base", defaultValue: "Setup continues in the Machines panel.") - : String(localized: "machines.new.background.note", defaultValue: "Creation continues in the Machines panel.")) - .cmuxFont(size: 11) - .foregroundStyle(.tertiary) - .lineLimit(1) - .accessibilityIdentifier("NewMachineSheet.backgroundNote") - Spacer() - Button(String(localized: "machines.new.cancel", defaultValue: "Cancel")) { - model.cancel() - } - .keyboardShortcut(.cancelAction) - .accessibilityIdentifier("NewMachineSheet.cancel") - Button(createTitle) { - model.create() + VStack(spacing: 10) { + Divider() + HStack(alignment: .center, spacing: 8) { + Text(model.isBaseSetup + ? String(localized: "machines.new.background.note.base", defaultValue: "Setup continues in the Machines panel.") + : String(localized: "machines.new.background.note", defaultValue: "Creation continues in the Machines panel.")) + .cmuxFont(size: 11) + .foregroundStyle(.tertiary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .accessibilityIdentifier("NewMachineSheet.backgroundNote") + Spacer() + Button(String(localized: "machines.new.cancel", defaultValue: "Cancel")) { + model.cancel() + } + .keyboardShortcut(.cancelAction) + .buttonStyle(.bordered) + .accessibilityIdentifier("NewMachineSheet.cancel") + Button(createTitle) { + model.create() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("NewMachineSheet.create") } - .keyboardShortcut(.defaultAction) - .accessibilityIdentifier("NewMachineSheet.create") } + .padding(.top, 2) } private var createTitle: String { @@ -144,9 +257,4 @@ struct NewMachineSheet: View { : String(localized: "machines.new.create", defaultValue: "Create") } - private func label(_ text: String) -> some View { - Text(text) - .cmuxFont(size: 12) - .gridColumnAlignment(.trailing) - } } diff --git a/cmuxTests/NewMachineModelTests.swift b/cmuxTests/NewMachineModelTests.swift index 16b49620bea1..ce293a3cf2a9 100644 --- a/cmuxTests/NewMachineModelTests.swift +++ b/cmuxTests/NewMachineModelTests.swift @@ -39,8 +39,10 @@ struct NewMachineModelTests { @Test func sizeLabelsDescribeMemoryAndDisk() { #expect(MachineSizeOption(memoryMb: 4096)?.title == "4 GB RAM") #expect(MachineSizeOption(memoryMb: 4096)?.detail == "16 GB disk included") + #expect(MachineSizeOption(memoryMb: 4096)?.diskTitle == "16 GB") #expect(MachineSizeOption(memoryMb: 8192)?.title == "8 GB RAM") #expect(MachineSizeOption(memoryMb: 8192)?.detail == "32 GB disk included") + #expect(MachineSizeOption(memoryMb: 8192)?.menuTitle == "8 GB RAM · 32 GB disk") #expect(MachineSizeOption(memoryMb: 16384)?.title == "16 GB RAM") #expect(MachineSizeOption(memoryMb: 16384)?.detail == "64 GB disk included") #expect(MachineSizeOption(memoryMb: 24576)?.title == "24 GB RAM") @@ -51,9 +53,9 @@ struct NewMachineModelTests { #expect(MachineSizeOption(memoryMb: 65536)?.detail == "128 GB disk included") } - @Test func serverOptionsAreUsedInPickerOrder() { + @Test func serverOptionsAreSortedAndDeduplicated() { let plan = MachinePlanSnapshot(activeCount: 0, maxActiveVms: 50, planId: "pro") - let (model, _) = makeModel(plan: plan, memoryOptionsMb: [8192, 16384]) + let (model, _) = makeModel(plan: plan, memoryOptionsMb: [16384, 8192, 8192]) #expect(model.memoryOptions == [8192, 16384]) #expect(model.memoryMb == 8192) }