diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModel.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModel.swift new file mode 100644 index 000000000000..0e1669787fa3 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModel.swift @@ -0,0 +1,44 @@ +#if os(iOS) +import CMUXMobileCore +import CmuxMobileShellModel + +struct TerminalLoadingDiagnosticsModel: Equatable { + let title: String + let message: String + let rows: [TerminalLoadingDiagnosticsRow] + let isLoading: Bool + + init( + workspaceName: String, + terminalCount: Int, + macName: String?, + connectionStatus: MobileMacConnectionStatus, + tailnetStatus: TailnetStatus?, + activeRoute: CmxAttachRoute?, + storedRouteDescription: String?, + connectionError: String?, + connectionErrorGuidance: String?, + loadingTimedOut: Bool = false + ) { + self = TerminalLoadingDiagnosticsModelBuilder( + workspaceName: workspaceName, + terminalCount: terminalCount, + macName: macName, + connectionStatus: connectionStatus, + tailnetStatus: tailnetStatus, + activeRoute: activeRoute, + storedRouteDescription: storedRouteDescription, + connectionError: connectionError, + connectionErrorGuidance: connectionErrorGuidance, + loadingTimedOut: loadingTimedOut + ).makeModel() + } + + init(title: String, message: String, rows: [TerminalLoadingDiagnosticsRow], isLoading: Bool) { + self.title = title + self.message = message + self.rows = rows + self.isLoading = isLoading + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModelBuilder.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModelBuilder.swift new file mode 100644 index 000000000000..d23f01cb35cd --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsModelBuilder.swift @@ -0,0 +1,198 @@ +#if os(iOS) +import CMUXMobileCore +import CmuxMobileShellModel +import CmuxMobileSupport +import Foundation + +struct TerminalLoadingDiagnosticsModelBuilder { + let workspaceName: String + let terminalCount: Int + let macName: String? + let connectionStatus: MobileMacConnectionStatus + let tailnetStatus: TailnetStatus? + let activeRoute: CmxAttachRoute? + let storedRouteDescription: String? + let connectionError: String? + let connectionErrorGuidance: String? + let loadingTimedOut: Bool + + func makeModel() -> TerminalLoadingDiagnosticsModel { + let resolvedMacName = nonEmpty(macName) ?? nonEmpty(workspaceName) ?? L10n.string( + "mobile.terminal.loading.macFallback", + defaultValue: "Mac" + ) + var rows: [TerminalLoadingDiagnosticsRow] = [ + TerminalLoadingDiagnosticsRow( + id: "mac", + label: L10n.string("mobile.terminal.loading.mac", defaultValue: "Mac"), + value: String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.macStatusFormat", + defaultValue: "%@ · %@" + ), + resolvedMacName, + connectionStatus.label + ), + tone: tone(for: connectionStatus) + ), + TerminalLoadingDiagnosticsRow( + id: "terminals", + label: L10n.string("mobile.terminal.loading.terminals", defaultValue: "Terminals"), + value: terminalStatusText(count: terminalCount), + tone: terminalCount > 0 ? .good : .pending + ), + TerminalLoadingDiagnosticsRow( + id: "tailscale", + label: L10n.string("mobile.terminal.loading.tailscale", defaultValue: "Tailscale"), + value: tailnetStatusText(tailnetStatus), + tone: tone(for: tailnetStatus) + ), + TerminalLoadingDiagnosticsRow( + id: "route", + label: L10n.string("mobile.terminal.loading.route", defaultValue: "Route"), + value: routeText( + activeRoute: activeRoute, + storedRouteDescription: storedRouteDescription + ), + tone: activeRoute == nil && nonEmpty(storedRouteDescription) == nil ? .warning : .neutral + ), + ] + + if let detail = nonEmpty(connectionErrorGuidance) ?? nonEmpty(connectionError) { + rows.append(TerminalLoadingDiagnosticsRow( + id: "network", + label: L10n.string("mobile.terminal.loading.network", defaultValue: "Network"), + value: detail, + tone: .warning + )) + } + + let text = headerText(resolvedMacName: resolvedMacName) + return TerminalLoadingDiagnosticsModel( + title: text.title, + message: text.message, + rows: rows, + isLoading: connectionStatus == .connected && !loadingTimedOut + ) + } + + private func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + + private func terminalStatusText(count: Int) -> String { + guard count > 0 else { + return L10n.string("mobile.terminal.loading.terminalsWaiting", defaultValue: "No terminal list yet") + } + return L10n.terminalCount(count) + } + + private func headerText(resolvedMacName: String) -> (title: String, message: String) { + if connectionStatus == .connected && terminalCount == 0 && loadingTimedOut { + return ( + L10n.string("mobile.terminal.loading.emptyTitle", defaultValue: "No terminals yet"), + String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.emptyMessageFormat", + defaultValue: "%@ is connected. Create a terminal, or tap Refresh to check again." + ), + resolvedMacName + ) + ) + } + return ( + L10n.string("mobile.terminal.loading.title", defaultValue: "Loading terminals"), + String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.messageFormat", + defaultValue: "Waiting for %@ to send terminal metadata for this workspace." + ), + resolvedMacName + ) + ) + } + + private func tailnetStatusText(_ status: TailnetStatus?) -> String { + switch status { + case .active: + return L10n.string("mobile.terminal.loading.tailscale.active", defaultValue: "Active") + case .inactiveOrNotInstalled: + return L10n.string("mobile.terminal.loading.tailscale.inactive", defaultValue: "Off or not installed") + case .unknown: + return L10n.string("mobile.terminal.loading.tailscale.unknown", defaultValue: "Unknown") + case nil: + return L10n.string("mobile.terminal.loading.tailscale.notChecked", defaultValue: "Not checked") + } + } + + private func routeText(activeRoute: CmxAttachRoute?, storedRouteDescription: String?) -> String { + if let activeRoute { + return String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.routeActiveFormat", + defaultValue: "%@ · %@" + ), + routeKindText(activeRoute.kind), + endpointText(activeRoute.endpoint) + ) + } + if let storedRouteDescription = nonEmpty(storedRouteDescription) { + return String.localizedStringWithFormat( + L10n.string( + "mobile.terminal.loading.routeStoredFormat", + defaultValue: "Saved route · %@" + ), + storedRouteDescription + ) + } + return L10n.string("mobile.terminal.loading.routeMissing", defaultValue: "No saved route") + } + + private func routeKindText(_ kind: CmxAttachTransportKind) -> String { + switch kind { + case .tailscale: + return L10n.string("mobile.terminal.loading.route.tailscale", defaultValue: "Tailscale") + case .debugLoopback: + return L10n.string("mobile.terminal.loading.route.debugLoopback", defaultValue: "Debug loopback") + case .iroh: + return L10n.string("mobile.terminal.loading.route.iroh", defaultValue: "Iroh") + case .websocket: + return L10n.string("mobile.terminal.loading.route.websocket", defaultValue: "WebSocket") + } + } + + private func endpointText(_ endpoint: CmxAttachEndpoint) -> String { + switch endpoint { + case let .hostPort(host, port): + return "\(host):\(port)" + case let .peer(id, _, directAddrs, _): + return directAddrs.first ?? id + case let .url(url): + return url + } + } + + private func tone(for status: MobileMacConnectionStatus) -> TerminalLoadingDiagnosticsTone { + switch status { + case .connected: + return .good + case .reconnecting: + return .pending + case .unavailable: + return .warning + } + } + + private func tone(for status: TailnetStatus?) -> TerminalLoadingDiagnosticsTone { + switch status { + case .active: + return .good + case .inactiveOrNotInstalled: + return .warning + case .unknown, nil: + return .neutral + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift new file mode 100644 index 000000000000..36ade96fac79 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsOverlay.swift @@ -0,0 +1,188 @@ +#if os(iOS) +import CMUXMobileCore +import CmuxMobileShellModel +import CmuxMobileSupport +import SwiftUI + +struct TerminalLoadingDiagnosticsOverlay: View { + let workspace: MobileWorkspacePreview + let host: String + let connectionStatus: MobileMacConnectionStatus + let tailnetStatus: TailnetStatus? + let activeRoute: CmxAttachRoute? + let storedRouteDescription: String? + let connectionError: String? + let connectionErrorGuidance: String? + let createTerminal: () -> Void + let refreshConnection: () -> Void + let canCreateTerminal: Bool + + private static let terminalMetadataTimeout: Duration = .seconds(10) + + @State private var terminalMetadataTimedOut = false + @State private var refreshGeneration = 0 + + private var model: TerminalLoadingDiagnosticsModel { + TerminalLoadingDiagnosticsModel( + workspaceName: workspace.name, + terminalCount: workspace.terminals.count, + macName: workspace.macDisplayName ?? host, + connectionStatus: workspace.macConnectionStatus ?? connectionStatus, + tailnetStatus: tailnetStatus, + activeRoute: activeRoute, + storedRouteDescription: storedRouteDescription, + connectionError: connectionError, + connectionErrorGuidance: connectionErrorGuidance, + loadingTimedOut: terminalMetadataTimedOut + ) + } + + var body: some View { + VStack(spacing: 16) { + if model.isLoading { + ProgressView() + .controlSize(.large) + .tint(.white) + .accessibilityHidden(true) + } else { + Image(systemName: "terminal") + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + .accessibilityHidden(true) + } + + VStack(spacing: 6) { + Text(model.title) + .font(.headline) + .foregroundStyle(.white) + Text(model.message) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.72)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(spacing: 8) { + ForEach(model.rows) { row in + HStack(spacing: 10) { + Circle() + .fill(color(for: row.tone)) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + Text(row.label) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.62)) + Spacer(minLength: 8) + Text(row.value) + .font(.caption.monospacedDigit()) + .foregroundStyle(.white.opacity(0.88)) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .padding(14) + .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + + HStack(spacing: 10) { + Button(action: retryLoading) { + Label( + L10n.string("mobile.terminal.loading.refresh", defaultValue: "Refresh"), + systemImage: "arrow.clockwise" + ) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 9) + .background(.white.opacity(0.12), in: Capsule()) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .accessibilityIdentifier("MobileLoadingRefreshButton") + + if canCreateTerminal { + Button(action: createTerminal) { + Label( + L10n.string("mobile.terminal.loading.createTerminal", defaultValue: "Create Terminal"), + systemImage: "plus" + ) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 18) + .padding(.vertical, 9) + .background(Color.accentColor, in: Capsule()) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .accessibilityIdentifier("MobileLoadingCreateTerminalButton") + } + } + } + .padding(24) + .frame(maxWidth: 420) + .accessibilityIdentifier("MobileTerminalLoadingDiagnostics") + .task(id: deadlineTaskID) { + await updateTerminalMetadataDeadline() + } + } + + private func color(for tone: TerminalLoadingDiagnosticsTone) -> Color { + switch tone { + case .good: + return .green + case .pending: + return .orange + case .warning: + return .red + case .neutral: + return .white.opacity(0.45) + } + } + + private var effectiveConnectionStatus: MobileMacConnectionStatus { + workspace.macConnectionStatus ?? connectionStatus + } + + private var deadlineTaskID: String { + [ + workspace.id.rawValue, + String(workspace.terminals.count), + connectionStatusKey(effectiveConnectionStatus), + String(refreshGeneration), + ].joined(separator: ":") + } + + private func updateTerminalMetadataDeadline() async { + terminalMetadataTimedOut = false + guard effectiveConnectionStatus == .connected, + workspace.terminals.isEmpty else { + return + } + do { + try await ContinuousClock().sleep(for: Self.terminalMetadataTimeout) + } catch { + return + } + guard effectiveConnectionStatus == .connected, + workspace.terminals.isEmpty else { + return + } + terminalMetadataTimedOut = true + } + + private func retryLoading() { + terminalMetadataTimedOut = false + refreshGeneration &+= 1 + refreshConnection() + } + + private func connectionStatusKey(_ status: MobileMacConnectionStatus) -> String { + switch status { + case .connected: + return "connected" + case .reconnecting: + return "reconnecting" + case .unavailable: + return "unavailable" + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsRow.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsRow.swift new file mode 100644 index 000000000000..28afdce94504 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsRow.swift @@ -0,0 +1,8 @@ +#if os(iOS) +struct TerminalLoadingDiagnosticsRow: Equatable, Identifiable { + let id: String + let label: String + let value: String + let tone: TerminalLoadingDiagnosticsTone +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsTone.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsTone.swift new file mode 100644 index 000000000000..296be604b993 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalLoadingDiagnosticsTone.swift @@ -0,0 +1,8 @@ +#if os(iOS) +enum TerminalLoadingDiagnosticsTone: Equatable { + case good + case pending + case warning + case neutral +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalLoadingDiagnostics.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalLoadingDiagnostics.swift new file mode 100644 index 000000000000..4e41f4dda3e5 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalLoadingDiagnostics.swift @@ -0,0 +1,74 @@ +#if os(iOS) +import CMUXMobileCore +import CmuxMobileShellModel + +extension WorkspaceDetailView { + var loadingDiagnosticsConnectionStatus: MobileMacConnectionStatus { + if let macDeviceID = workspace.macDeviceID, + !macDeviceID.isEmpty, + !loadingDiagnosticsMatchesForegroundMac(macDeviceID) { + return .unavailable + } + return workspace.macConnectionStatus ?? connectionStatus + } + + var loadingDiagnosticsStoredRouteDescription: String? { + guard let macDeviceID = workspace.macDeviceID, + !macDeviceID.isEmpty else { + return nil + } + return store.displayPairedMacs + .first { mac in + mac.macDeviceID == macDeviceID || store.pairedMacAliasIDs(for: mac.macDeviceID).contains(macDeviceID) + } + .flatMap { CmxAttachRoute.deviceTreeRouteDescription(for: $0.routes) } + } + + var activeLoadingDiagnosticsRoute: CmxAttachRoute? { + guard let macDeviceID = workspace.macDeviceID, + !macDeviceID.isEmpty else { + return loadingDiagnosticsConnectionStatus == .connected ? store.activeRoute : nil + } + if loadingDiagnosticsMatchesForegroundMac(macDeviceID) { + return store.activeRoute + } + return nil + } + + var loadingDiagnosticsConnectionError: String? { + loadingDiagnosticsMatchesForegroundMac(workspace.macDeviceID) ? store.connectionError : nil + } + + var loadingDiagnosticsConnectionErrorGuidance: String? { + loadingDiagnosticsMatchesForegroundMac(workspace.macDeviceID) ? store.connectionErrorGuidance : nil + } + + func refreshLoadingDiagnosticsConnection() { + Task { + if let macDeviceID = workspace.macDeviceID, + !macDeviceID.isEmpty { + _ = await store.switchToMac(macDeviceID: macDeviceID) + } + await store.reconnectOrRefresh() + } + } + + private func loadingDiagnosticsMatchesForegroundMac(_ macDeviceID: String?) -> Bool { + guard let macDeviceID, + !macDeviceID.isEmpty else { + return false + } + if macDeviceID == store.connectedMacDeviceID { + return true + } + if macDeviceID == store.activeTicket?.macDeviceID { + return true + } + guard let connectedMacDeviceID = store.connectedMacDeviceID, + store.pairedMacAliasIDs(for: macDeviceID).contains(connectedMacDeviceID) else { + return false + } + return true + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index d546c9ccfe3c..f583d1943ee2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -1,5 +1,6 @@ import CmuxAgentChat import CmuxAgentChatUI +import CMUXMobileCore import CmuxMobileBrowser import CmuxMobileDiagnostics import CmuxMobileShell @@ -33,6 +34,7 @@ struct WorkspaceDetailView: View { let signOut: (() -> Void)? /// Phone-local browser surfaces, injected from the app root. @Environment(BrowserSurfaceStore.self) private var browserStore + @Environment(\.tailscaleStatusMonitor) private var tailscaleStatusMonitor /// Drives the destructive close-workspace confirmation dialog. @State var isConfirmingClose = false #if canImport(UIKit) @@ -240,6 +242,23 @@ struct WorkspaceDetailView: View { } else { TerminalPalette.background .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay { + if loadingDiagnosticsConnectionStatus == .connected { + TerminalLoadingDiagnosticsOverlay( + workspace: workspace, + host: host, + connectionStatus: loadingDiagnosticsConnectionStatus, + tailnetStatus: tailscaleStatusMonitor?.status, + activeRoute: activeLoadingDiagnosticsRoute, + storedRouteDescription: loadingDiagnosticsStoredRouteDescription, + connectionError: loadingDiagnosticsConnectionError, + connectionErrorGuidance: loadingDiagnosticsConnectionErrorGuidance, + createTerminal: createTerminal, + refreshConnection: refreshLoadingDiagnosticsConnection, + canCreateTerminal: true + ) + } + } } #else TerminalPalette.background @@ -254,8 +273,11 @@ struct WorkspaceDetailView: View { } .overlay { // Show a reconnecting/offline state instead of a black terminal. - if connectionStatus != .connected { - TerminalDisconnectedOverlay(status: connectionStatus, host: host) { + if loadingDiagnosticsConnectionStatus != .connected { + TerminalDisconnectedOverlay( + status: loadingDiagnosticsConnectionStatus, + host: workspace.macDisplayName ?? host + ) { Task { if let macDeviceID = workspace.macDeviceID, !macDeviceID.isEmpty, diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift new file mode 100644 index 000000000000..6d5e80db756a --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalLoadingDiagnosticsModelTests.swift @@ -0,0 +1,105 @@ +#if os(iOS) +import CMUXMobileCore +import CmuxMobileShellModel +@testable import CmuxMobileShellUI +import Testing + +@Suite struct TerminalLoadingDiagnosticsModelTests { + @Test func connectedTailscaleRouteExplainsLoadingTerminalState() throws { + let route = try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.10", port: 58465) + ) + + let model = TerminalLoadingDiagnosticsModel( + workspaceName: "cmux", + terminalCount: 0, + macName: "Studio", + connectionStatus: .connected, + tailnetStatus: .active, + activeRoute: route, + storedRouteDescription: nil, + connectionError: nil, + connectionErrorGuidance: nil + ) + + #expect(model.title == "Loading terminals") + #expect(model.message == "Waiting for Studio to send terminal metadata for this workspace.") + #expect(model.isLoading) + #expect(model.rows[id: "mac"]?.value == "Studio · Connected") + #expect(model.rows[id: "tailscale"]?.value == "Active") + #expect(model.rows[id: "route"]?.value == "Tailscale · 100.64.0.10:58465") + #expect(model.rows[id: "terminals"]?.value == "No terminal list yet") + } + + @Test func connectedEmptyWorkspaceTimesOutToExplicitRecoveryCopy() { + let model = TerminalLoadingDiagnosticsModel( + workspaceName: "cmux", + terminalCount: 0, + macName: "Studio", + connectionStatus: .connected, + tailnetStatus: .active, + activeRoute: nil, + storedRouteDescription: nil, + connectionError: nil, + connectionErrorGuidance: nil, + loadingTimedOut: true + ) + + #expect(model.title == "No terminals yet") + #expect(model.message == "Studio is connected. Create a terminal, or tap Refresh to check again.") + #expect(!model.isLoading) + } + + @Test func inactiveTailscaleAndSavedRouteSurfaceNetworkGuidance() { + let model = TerminalLoadingDiagnosticsModel( + workspaceName: "cmux", + terminalCount: 0, + macName: nil, + connectionStatus: .unavailable, + tailnetStatus: .inactiveOrNotInstalled, + activeRoute: nil, + storedRouteDescription: "100.64.0.20:58465", + connectionError: "Timed out", + connectionErrorGuidance: "Check that both devices are on the same Tailscale." + ) + + #expect(model.rows[id: "mac"]?.value == "cmux · Disconnected") + #expect(model.rows[id: "mac"]?.tone == .warning) + #expect(model.rows[id: "tailscale"]?.value == "Off or not installed") + #expect(model.rows[id: "tailscale"]?.tone == .warning) + #expect(model.rows[id: "route"]?.value == "Saved route · 100.64.0.20:58465") + #expect(model.rows[id: "network"]?.value == "Check that both devices are on the same Tailscale.") + } + + @Test func routeAndMacStatusUseLocalizedFormatKeys() throws { + let route = try CmxAttachRoute( + id: "debug", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 3942) + ) + + let model = TerminalLoadingDiagnosticsModel( + workspaceName: "cmux", + terminalCount: 1, + macName: "Mini", + connectionStatus: .reconnecting, + tailnetStatus: .unknown, + activeRoute: route, + storedRouteDescription: nil, + connectionError: nil, + connectionErrorGuidance: nil + ) + + #expect(model.rows[id: "mac"]?.value == "Mini · Reconnecting") + #expect(model.rows[id: "route"]?.value == "Debug loopback · 127.0.0.1:3942") + } +} + +private extension [TerminalLoadingDiagnosticsRow] { + subscript(id id: String) -> TerminalLoadingDiagnosticsRow? { + first { $0.id == id } + } +} +#endif diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 73bb4104888b..1a92dbc2cdbc 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -117577,6 +117577,431 @@ } } }, + "mobile.terminal.loading.createTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを作成" + } + } + } + }, + "mobile.terminal.loading.emptyMessageFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is connected. Create a terminal, or tap Refresh to check again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に接続済みです。ターミナルを作成するか、「更新」をタップして再確認してください。" + } + } + } + }, + "mobile.terminal.loading.emptyTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No terminals yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルはまだありません" + } + } + } + }, + "mobile.terminal.loading.mac": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + } + } + }, + "mobile.terminal.loading.macFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + } + } + }, + "mobile.terminal.loading.macStatusFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, + "mobile.terminal.loading.messageFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for %@ to send terminal metadata for this workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ からこのワークスペースのターミナル情報が送信されるのを待っています。" + } + } + } + }, + "mobile.terminal.loading.network": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + } + } + }, + "mobile.terminal.loading.refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + } + } + }, + "mobile.terminal.loading.route": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Route" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルート" + } + } + } + }, + "mobile.terminal.loading.routeActiveFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, + "mobile.terminal.loading.route.debugLoopback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Debug loopback" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバッグループバック" + } + } + } + }, + "mobile.terminal.loading.route.iroh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iroh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Iroh" + } + } + } + }, + "mobile.terminal.loading.route.tailscale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + } + } + }, + "mobile.terminal.loading.route.websocket": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "WebSocket" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WebSocket" + } + } + } + }, + "mobile.terminal.loading.routeMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No saved route" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存済みルートなし" + } + } + } + }, + "mobile.terminal.loading.routeStoredFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved route · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存済みルート · %@" + } + } + } + }, + "mobile.terminal.loading.tailscale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + } + } + }, + "mobile.terminal.loading.tailscale.active": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効" + } + } + } + }, + "mobile.terminal.loading.tailscale.inactive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off or not installed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフまたは未インストール" + } + } + } + }, + "mobile.terminal.loading.tailscale.notChecked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not checked" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未確認" + } + } + } + }, + "mobile.terminal.loading.tailscale.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + } + } + }, + "mobile.terminal.loading.terminals": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, + "mobile.terminal.loading.terminalsWaiting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No terminal list yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル一覧はまだありません" + } + } + } + }, + "mobile.terminal.loading.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを読み込み中" + } + } + } + }, "mobile.pairing.checking": { "extractionState": "manual", "localizations": { diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index ef80c1446033..5034b44d4879 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -8195,6 +8195,431 @@ } } }, + "mobile.terminal.loading.createTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを作成" + } + } + } + }, + "mobile.terminal.loading.emptyMessageFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is connected. Create a terminal, or tap Refresh to check again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に接続済みです。ターミナルを作成するか、「更新」をタップして再確認してください。" + } + } + } + }, + "mobile.terminal.loading.emptyTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No terminals yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルはまだありません" + } + } + } + }, + "mobile.terminal.loading.mac": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + } + } + }, + "mobile.terminal.loading.macFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mac" + } + } + } + }, + "mobile.terminal.loading.macStatusFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, + "mobile.terminal.loading.messageFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for %@ to send terminal metadata for this workspace." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ からこのワークスペースのターミナル情報が送信されるのを待っています。" + } + } + } + }, + "mobile.terminal.loading.network": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + } + } + }, + "mobile.terminal.loading.refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + } + } + }, + "mobile.terminal.loading.route": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Route" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルート" + } + } + } + }, + "mobile.terminal.loading.routeActiveFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · %@" + } + } + } + }, + "mobile.terminal.loading.route.debugLoopback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Debug loopback" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバッグループバック" + } + } + } + }, + "mobile.terminal.loading.route.iroh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Iroh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Iroh" + } + } + } + }, + "mobile.terminal.loading.route.tailscale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + } + } + }, + "mobile.terminal.loading.route.websocket": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "WebSocket" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WebSocket" + } + } + } + }, + "mobile.terminal.loading.routeMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No saved route" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存済みルートなし" + } + } + } + }, + "mobile.terminal.loading.routeStoredFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Saved route · %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保存済みルート · %@" + } + } + } + }, + "mobile.terminal.loading.tailscale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale" + } + } + } + }, + "mobile.terminal.loading.tailscale.active": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効" + } + } + } + }, + "mobile.terminal.loading.tailscale.inactive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Off or not installed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフまたは未インストール" + } + } + } + }, + "mobile.terminal.loading.tailscale.notChecked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not checked" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未確認" + } + } + } + }, + "mobile.terminal.loading.tailscale.unknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明" + } + } + } + }, + "mobile.terminal.loading.terminals": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + } + } + }, + "mobile.terminal.loading.terminalsWaiting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No terminal list yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル一覧はまだありません" + } + } + } + }, + "mobile.terminal.loading.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading terminals" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを読み込み中" + } + } + } + }, "terminal.zoom.restore_built_in": { "extractionState": "manual", "localizations": {