Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ final class CloudMachineCreation: Identifiable {
/// What `cloud new-machine` asked for (size, idle pause); a Retry asks for the same, with
/// the same idempotency key.
private(set) var request: CloudMachineRequest
/// The name the first app server attempt sent (``CloudService/createMachine``): the one asked for, else
/// a readable default. Every Retry sends the same one, since an approval holds the exact params. Not
/// part of ``request``: a named request is a request option the app server's create refuses.
var pinnedMachineName: String?

/// The create went to the server once, to `team`: a Retry may find the machine it made, and
/// goes to that same team.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ final class CloudService {
@ObservationIgnored var confirmCreate: (@MainActor (CloudMachineCreateFlow.Prompt) async -> Bool)?
/// Machine creations in flight (sidebar can show a placeholder).
private(set) var creating = 0
/// The names app server creates in flight asked for, typed or default: a default picked meanwhile skips them.
@ObservationIgnored private var machineNamesInFlight: [String] = []
/// New Cloud Workspace runs from the click to the open terminal: each
/// window's progress view and sidebar row (cx-lu8f).
let creations = CloudCreations()
Expand Down Expand Up @@ -544,7 +546,7 @@ final class CloudService {
request: CloudMachineRequest = CloudMachineRequest(), proceed: () throws -> Void = {}) async throws -> CloudMachineSession {
if let reason = unavailableReason { throw ActionFailure(message: reason) }
guard auth.isSignedIn else { throw ActionFailure(message: CloudStrings.signInFirst) }
let name = name ?? request.name
var name = name ?? request.name
// The app server's create takes none of them yet, and its machines have no `/api/vm` file
// or exec route for a bootstrap: refuse rather than make a machine without what was asked.
if configuration.linkSource == .appServer, !request.isDefault {
Expand All @@ -554,6 +556,24 @@ final class CloudService {
if configuration.linkSource != .appServer, request.environment != nil {
throw ActionFailure(message: CloudEnvironmentStrings().unavailable)
}
// The cmux-next backend stores no name and no slug for an unnamed machine, so it would be titled by
// its raw id: give it a readable one, unique among this account's machines and creations in flight.
// The classic `/api/vm` create names it itself (its slug).
var reserved: String?
if configuration.linkSource == .appServer {
if let pinned = creation?.pinnedMachineName {
name = pinned
} else if name?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true {
name = CloudMachine.defaultName(
taken: machines.cloud.map(\.machine.title) + creations.all.compactMap(\.pinnedMachineName) + machineNamesInFlight,
base: CloudStrings.defaultMachineName, numbered: CloudStrings.defaultMachineName(numbered:))
}
creation?.pinnedMachineName = name
// Held until this create ends, typed names included, so a default picked meanwhile is another one.
reserved = name
if let name { machineNamesInFlight.append(name) }
}
defer { if let reserved, let index = machineNamesInFlight.firstIndex(of: reserved) { machineNamesInFlight.remove(at: index) } }
creating += 1
defer { creating -= 1 }
let onSent: @MainActor @Sendable () async -> Void = { [weak creation] in creation?.note(.creating) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

extension CloudMachine {
/// The name a new machine gets when the person typed none: `base` ("Cloud Machine") when no
/// machine in `taken` has that title, else the first free `numbered(n)` from 2 ("Cloud Machine 2").
/// Titles compare trimmed and case-insensitively, so a renamed "cloud machine" still counts.
public static func defaultName(taken: some Sequence<String>, base: String, numbered: (Int) -> String) -> String {
let used = Set(taken.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() })
let free: (String) -> Bool = { !used.contains($0.lowercased()) }
if free(base) { return base }
var number = 2
while !free(numbered(number)) { number += 1 }
return numbered(number)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ public enum CloudStrings {
String(localized: "cloud.unavailable.localBackend", defaultValue: "This build has no Cloud backend. Rebuild it with ./scripts/reload.sh --tag <tag> --direct-backend.", table: "Cloud", bundle: .module)
}
public static var noClient: String { String(localized: "cloud.unavailable.noClient", defaultValue: "The bundled cmux-tui client is missing, so Cloud machines cannot connect. Reinstall cmux from cmux.com/download, or rebuild a local build with cmux-tui bundled.", table: "Cloud", bundle: .module) }
/// The name a new Cloud machine gets when the person typed none (``CloudMachine/defaultName(taken:base:numbered:)``),
/// so the sidebar and menus never title it by its raw id.
nonisolated public static var defaultMachineName: String { String(localized: "cloud.machine.defaultName", defaultValue: "Cloud Machine", table: "Cloud", bundle: .module) }
/// The default name when ``defaultMachineName`` is taken: "Cloud Machine 2".
nonisolated public static func defaultMachineName(numbered number: Int) -> String {
String(format: String(localized: "cloud.machine.defaultNameNumbered", defaultValue: "Cloud Machine %lld", table: "Cloud", bundle: .module), number)
}
nonisolated public static var signInFirst: String { String(localized: "cloud.failed.signInFirst", defaultValue: "Sign in to use Cloud machines: run Sign In from the command palette, or `cmux cloud sign-in`.", table: "Cloud", bundle: .module) }
public static var noMachine: String { String(localized: "cloud.failed.noMachine", defaultValue: "No Cloud machine is selected. Right-click a machine or pass --target machine:<id>; `cmux cloud list-machines` lists ids.", table: "Cloud", bundle: .module) }
/// Why a Cloud machine needs an update, for its sidebar header and the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9704,6 +9704,270 @@
}
}
}
},
"cloud.machine.defaultName": {
"comment": "The name a new Cloud machine gets when the person typed none (sidebar, menus, Cloud page).",
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Cloud Machine"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "クラウドマシン"
}
},
"ar": {
"stringUnit": {
"state": "needs_review",
"value": "جهاز سحابي"
}
},
"bs": {
"stringUnit": {
"state": "needs_review",
"value": "Mašina u oblaku"
}
},
"da": {
"stringUnit": {
"state": "needs_review",
"value": "Cloudmaskine"
}
},
"de": {
"stringUnit": {
"state": "needs_review",
"value": "Cloud-Maschine"
}
},
"es": {
"stringUnit": {
"state": "needs_review",
"value": "Máquina en la nube"
}
},
"fr": {
"stringUnit": {
"state": "needs_review",
"value": "Machine cloud"
}
},
"it": {
"stringUnit": {
"state": "needs_review",
"value": "Macchina cloud"
}
},
"km": {
"stringUnit": {
"state": "needs_review",
"value": "ម៉ាស៊ីនពពក"
}
},
"ko": {
"stringUnit": {
"state": "needs_review",
"value": "클라우드 머신"
}
},
"nb": {
"stringUnit": {
"state": "needs_review",
"value": "Skymaskin"
}
},
"pl": {
"stringUnit": {
"state": "needs_review",
"value": "Maszyna w chmurze"
}
},
"pt-BR": {
"stringUnit": {
"state": "needs_review",
"value": "Máquina na nuvem"
}
},
"ru": {
"stringUnit": {
"state": "needs_review",
"value": "Облачная машина"
}
},
"th": {
"stringUnit": {
"state": "needs_review",
"value": "เครื่องบนคลาวด์"
}
},
"tr": {
"stringUnit": {
"state": "needs_review",
"value": "Bulut makinesi"
}
},
"uk": {
"stringUnit": {
"state": "needs_review",
"value": "Хмарна машина"
}
},
"vi": {
"stringUnit": {
"state": "needs_review",
"value": "Máy đám mây"
}
},
"zh-Hans": {
"stringUnit": {
"state": "needs_review",
"value": "云端机器"
}
},
"zh-Hant": {
"stringUnit": {
"state": "needs_review",
"value": "雲端機器"
}
}
}
},
"cloud.machine.defaultNameNumbered": {
"comment": "The default Cloud machine name when the plain one is taken; %lld is 2, 3, …",
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Cloud Machine %lld"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "クラウドマシン %lld"
}
},
"ar": {
"stringUnit": {
"state": "needs_review",
"value": "جهاز سحابي %lld"
}
},
"bs": {
"stringUnit": {
"state": "needs_review",
"value": "Mašina u oblaku %lld"
}
},
"da": {
"stringUnit": {
"state": "needs_review",
"value": "Cloudmaskine %lld"
}
},
"de": {
"stringUnit": {
"state": "needs_review",
"value": "Cloud-Maschine %lld"
}
},
"es": {
"stringUnit": {
"state": "needs_review",
"value": "Máquina en la nube %lld"
}
},
"fr": {
"stringUnit": {
"state": "needs_review",
"value": "Machine cloud %lld"
}
},
"it": {
"stringUnit": {
"state": "needs_review",
"value": "Macchina cloud %lld"
}
},
"km": {
"stringUnit": {
"state": "needs_review",
"value": "ម៉ាស៊ីនពពក %lld"
}
},
"ko": {
"stringUnit": {
"state": "needs_review",
"value": "클라우드 머신 %lld"
}
},
"nb": {
"stringUnit": {
"state": "needs_review",
"value": "Skymaskin %lld"
}
},
"pl": {
"stringUnit": {
"state": "needs_review",
"value": "Maszyna w chmurze %lld"
}
},
"pt-BR": {
"stringUnit": {
"state": "needs_review",
"value": "Máquina na nuvem %lld"
}
},
"ru": {
"stringUnit": {
"state": "needs_review",
"value": "Облачная машина %lld"
}
},
"th": {
"stringUnit": {
"state": "needs_review",
"value": "เครื่องบนคลาวด์ %lld"
}
},
"tr": {
"stringUnit": {
"state": "needs_review",
"value": "Bulut makinesi %lld"
}
},
"uk": {
"stringUnit": {
"state": "needs_review",
"value": "Хмарна машина %lld"
}
},
"vi": {
"stringUnit": {
"state": "needs_review",
"value": "Máy đám mây %lld"
}
},
"zh-Hans": {
"stringUnit": {
"state": "needs_review",
"value": "云端机器 %lld"
}
},
"zh-Hant": {
"stringUnit": {
"state": "needs_review",
"value": "雲端機器 %lld"
}
}
}
}
},
"version": "1.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import CmuxNextCloud
import Testing

/// A new cmux-next Cloud machine with no typed name gets a readable one, never its raw id.
@Suite struct CloudMachineDefaultNameTests {
private func pick(_ taken: [String]) -> String {
CloudMachine.defaultName(taken: taken, base: "Cloud Machine", numbered: { "Cloud Machine \($0)" })
}

@Test func theFirstMachineIsCloudMachine() {
#expect(pick([]) == "Cloud Machine")
#expect(pick(["vm_8f2c1a", "build-box"]) == "Cloud Machine")
}

@Test func aTakenNameCountsOnFromTwo() {
#expect(pick(["Cloud Machine"]) == "Cloud Machine 2")
#expect(pick(["Cloud Machine", "Cloud Machine 2"]) == "Cloud Machine 3")
#expect(pick(["Cloud Machine", "Cloud Machine 3"]) == "Cloud Machine 2", "the first free number")
}

@Test func titlesCompareTrimmedAndCaseInsensitively() {
#expect(pick([" cloud machine "]) == "Cloud Machine 2")
}

@Test func aMachineWithNoNameIsTitledByItsDefaultOnlyOnceNamed() {
let unnamed = CloudMachine(id: "vm_8f2c1a", provider: "cmux-next")
#expect(unnamed.title == "vm_8f2c1a", "an old record keeps its id; only new creates get a name")
let named = CloudMachine(id: "vm_8f2c1a", provider: "cmux-next", displayName: pick([unnamed.title]))
#expect(named.title == "Cloud Machine")
}
}
Loading