diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index df8874fcebc8..681812bb0951 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -527767,6 +527767,242 @@ } } } + }, + "cloudPane.newTerminalFailed.invalidResult": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The machine did not return the new terminal. Refresh the machine before trying again." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Maschine hat das neue Terminal nicht zurückgegeben. Aktualisiere die Maschine, bevor du es erneut versuchst." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La machine n’a pas renvoyé le nouveau terminal. Actualisez la machine avant de réessayer." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يُرجع الجهاز الطرفية الجديدة. حدّث الجهاز قبل المحاولة مرة أخرى." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La máquina no devolvió la nueva terminal. Actualiza la máquina antes de volver a intentarlo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "機器未傳回新的終端機。請先重新整理機器再試一次。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "机器未返回新的终端。请先刷新机器再重试。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "머신이 새 터미널을 반환하지 않았습니다. 다시 시도하기 전에 머신을 새로 고치세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マシンから新しいターミナルが返されませんでした。マシンを更新してから、もう一度お試しください。" + } + } + } + }, + "cloudPane.newTerminalFailed.noWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This machine has no available workspace. Refresh the machine and try again." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf dieser Maschine ist kein Arbeitsbereich verfügbar. Aktualisiere die Maschine und versuche es erneut." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun espace de travail n’est disponible sur cette machine. Actualisez la machine et réessayez." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد مساحة عمل متاحة على هذا الجهاز. حدّث الجهاز وحاول مرة أخرى." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta máquina no tiene un espacio de trabajo disponible. Actualiza la máquina e inténtalo de nuevo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此機器沒有可用的工作區。請重新整理機器後再試一次。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此机器没有可用的工作区。请刷新机器后重试。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 머신에 사용 가능한 작업 공간이 없습니다. 머신을 새로 고친 후 다시 시도하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このマシンには利用可能なワークスペースがありません。マシンを更新して、もう一度お試しください。" + } + } + } + }, + "cloudPane.newTerminalFailed.stateUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The machine’s current state could not be loaded. Reconnect and try again." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der aktuelle Zustand der Maschine konnte nicht geladen werden. Stelle die Verbindung erneut her und versuche es noch einmal." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de charger l’état actuel de la machine. Reconnectez-vous et réessayez." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر تحميل الحالة الحالية للجهاز. أعد الاتصال وحاول مرة أخرى." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo cargar el estado actual de la máquina. Vuelve a conectarte e inténtalo de nuevo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法載入機器的目前狀態。請重新連線後再試一次。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法加载机器的当前状态。请重新连接后重试。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "머신의 현재 상태를 불러올 수 없습니다. 다시 연결한 후 시도하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マシンの現在の状態を読み込めませんでした。再接続して、もう一度お試しください。" + } + } + } + }, + "cloudPane.newTerminalFailed.shortTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t open terminal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal konnte nicht geöffnet werden" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d’ouvrir le terminal" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر فتح الطرفية" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo abrir la terminal" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法開啟終端機" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法打开终端" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널을 열 수 없습니다" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを開けませんでした" + } + } + } } }, "version": "1.0" diff --git a/Sources/Cloud/CloudDiagnosticFailure.swift b/Sources/Cloud/CloudDiagnosticFailure.swift index ffa620260209..6b5a2191c0fa 100644 --- a/Sources/Cloud/CloudDiagnosticFailure.swift +++ b/Sources/Cloud/CloudDiagnosticFailure.swift @@ -71,6 +71,15 @@ enum CloudDiagnosticFailure: String, Codable, Sendable, Error { } } if error is CloudMachineLinkManager.ManagerError { return .connectFailure(error) } + if let error = error as? SurfaceCatalogError { + switch error { + case .unknownResource, .destinationNotFound, .nothingToOpen: return .notFound + case .noProvider, .unavailable: return .network + case .ambiguousRemotePlacement: return .conflict + case .unsupported: return .unsupported + case .partialOperation: return .response + } + } if error is DecodingError { return .response } return .unknown } diff --git a/Sources/CloudTerminalCreationCoordinator.swift b/Sources/CloudTerminalCreationCoordinator.swift index 3281a26810e2..5b9e237fd67e 100644 --- a/Sources/CloudTerminalCreationCoordinator.swift +++ b/Sources/CloudTerminalCreationCoordinator.swift @@ -1,4 +1,7 @@ import Foundation +import os + +nonisolated private let cloudTerminalCreationLogger = Logger(subsystem: "com.cmuxterm.app", category: "CloudTerminalCreation") /// Coordinates one asynchronous Cloud terminal creation behind a reserved pane. /// @@ -9,6 +12,7 @@ import Foundation final class CloudTerminalCreationCoordinator { typealias Create = @MainActor () async throws -> SurfaceResource typealias Project = @MainActor (SurfaceResource) async throws -> (projection: SurfaceProjection, reused: Bool) + typealias Failure = @MainActor (Error, CloudOperationContext?) -> Void typealias DiscardProjection = @MainActor (SurfaceProjection) -> Void private let create: Create @@ -41,6 +45,32 @@ final class CloudTerminalCreationCoordinator { self.discardProjection = discardProjection } + /// All Cloud terminal gestures establish a root before reaching the link. + /// Its process/snapshot spans and the copied failure share one Axiom trace. + static func perform( + recorder: CloudOperationRecorder?, + file: StaticString = #fileID, + line: UInt = #line, + onFailure: Failure, + _ work: @MainActor () async throws -> T + ) async rethrows -> T { + let context = recorder?.begin(.terminal, foreground: false, file: file, line: line) + return try await CloudOperationContext.$current.withValue(context) { + do { + let value = try await work() + if let context { await context.recorder.finish(context) } + return value + } catch { + if CloudDiagnosticFailure.classify(error) != .cancelled { + cloudTerminalCreationLogger.error("Terminal creation failed: failure=\(CloudDiagnosticFailure.classify(error).rawValue, privacy: .public) trace=\(context?.traceID ?? "unavailable", privacy: .public) error=\(String(reflecting: error), privacy: .private)") + onFailure(error, context) + } + if let context { await context.recorder.finish(context, error: error) } + throw error + } + } + } + /// Begins creation or retries the last remote resource's local projection. func start() { // A repeated retry is still the same intent. Cancelling a create can @@ -55,31 +85,36 @@ final class CloudTerminalCreationCoordinator { if self.generation == operationGeneration { self.task = nil } } do { - let resource: SurfaceResource - if let createdResource = self.createdResource { - resource = createdResource - } else { - resource = try await self.create() - guard self.generation == operationGeneration else { return } - self.createdResource = resource - } - try Task.checkCancellation() - let projectionResult = try await self.project(resource) - guard self.generation == operationGeneration, - !Task.isCancelled else { - if !projectionResult.reused { - self.discardProjection(projectionResult.projection) + try await Self.perform(recorder: AppDelegate.shared?.cloudOperations, onFailure: { error, _ in + guard self.generation == operationGeneration, !Task.isCancelled else { return } + self.onFailure(error) + }) { + let resource: SurfaceResource + if let createdResource = self.createdResource { + resource = createdResource + } else { + resource = try await CloudOperationContext.phase(.provider, self.create) + guard self.generation == operationGeneration else { throw CancellationError() } + self.createdResource = resource } - return + try Task.checkCancellation() + let projectionResult = try await CloudOperationContext.phase(.materialize) { try await self.project(resource) } + guard self.generation == operationGeneration, + !Task.isCancelled else { + if !projectionResult.reused { + self.discardProjection(projectionResult.projection) + } + throw CancellationError() + } + self.onSuccess() } - self.onSuccess() - } catch is CancellationError { - if self.generation == operationGeneration { self.onCancel() } - return } catch { - guard self.generation == operationGeneration, - !Task.isCancelled else { return } - self.onFailure(error) + guard self.generation == operationGeneration else { return } + if CloudDiagnosticFailure.classify(error) == .cancelled { + self.onCancel() + } + // perform already delivered non-cancellation failures inside its + // diagnostic context. } } } diff --git a/Sources/CloudTerminalReconnectOverlayView.swift b/Sources/CloudTerminalReconnectOverlayView.swift index f472667cb723..7a60aa871673 100644 --- a/Sources/CloudTerminalReconnectOverlayView.swift +++ b/Sources/CloudTerminalReconnectOverlayView.swift @@ -1,194 +1,96 @@ import AppKit +import SwiftUI -/// Native reconnect card shared by the terminal portal and its representable anchor. +/// A native, pane-owned host. Connection state and creation failures share the +/// same responsive card, so a narrow terminal never gets a 260pt minimum dialog. @MainActor final class CloudTerminalReconnectOverlayView: NSView { var onReconnect: (() -> Void)? var onDismiss: (() -> Void)? - - private let cardView = NSVisualEffectView(frame: .zero) - private let iconView = NSImageView(frame: .zero) - private let spinner = NSProgressIndicator(frame: .zero) - private let titleLabel = NSTextField(wrappingLabelWithString: "") - private let detailLabel = NSTextField(wrappingLabelWithString: "") - private let reconnectButton = NSButton(frame: .zero) - private let dismissButton = NSButton(frame: .zero) - private lazy var cardWidth = cardView.widthAnchor.constraint(equalToConstant: 360) private(set) var currentPresentation: CloudTerminalReconnectOverlayPolicy.Presentation? + private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) + private var renderedWidth: CGFloat = 0 + private var needsContentUpdate = true - /// Creates a card whose controls are localized and hit-testable by AppKit. override init(frame frameRect: NSRect) { super.init(frame: frameRect) - wantsLayer = true - layer?.backgroundColor = NSColor.black.withAlphaComponent(0.12).cgColor autoresizingMask = [.width, .height] - - cardView.translatesAutoresizingMaskIntoConstraints = false - cardView.material = .hudWindow - cardView.blendingMode = .withinWindow - cardView.state = .active - cardView.wantsLayer = true - cardView.layer?.cornerRadius = 12 - cardView.layer?.masksToBounds = true - cardView.layer?.borderWidth = 1 - cardView.layer?.borderColor = NSColor.white.withAlphaComponent(0.11).cgColor - addSubview(cardView) - - iconView.translatesAutoresizingMaskIntoConstraints = false - iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 24, weight: .medium) - iconView.contentTintColor = NSColor.secondaryLabelColor - - spinner.translatesAutoresizingMaskIntoConstraints = false - spinner.style = .spinning - spinner.controlSize = .regular - spinner.isDisplayedWhenStopped = false - - titleLabel.translatesAutoresizingMaskIntoConstraints = false - titleLabel.alignment = .center - titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) - titleLabel.textColor = .labelColor - titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - - detailLabel.translatesAutoresizingMaskIntoConstraints = false - detailLabel.alignment = .center - detailLabel.font = .systemFont(ofSize: 12, weight: .medium) - detailLabel.textColor = .secondaryLabelColor - detailLabel.maximumNumberOfLines = 3 - - reconnectButton.translatesAutoresizingMaskIntoConstraints = false - reconnectButton.title = String(localized: "cloud.overlay.reconnect.button", defaultValue: "Reconnect") - reconnectButton.image = NSImage( - systemSymbolName: "arrow.clockwise", - accessibilityDescription: nil - ) - reconnectButton.imagePosition = .imageLeading - reconnectButton.bezelStyle = .rounded - reconnectButton.controlSize = .regular - reconnectButton.target = self - reconnectButton.action = #selector(handleReconnect) - - dismissButton.translatesAutoresizingMaskIntoConstraints = false - dismissButton.image = NSImage( - systemSymbolName: "xmark", - accessibilityDescription: String(localized: "common.close", defaultValue: "Close") - ) - dismissButton.bezelStyle = .texturedRounded - dismissButton.isBordered = false - dismissButton.controlSize = .small - dismissButton.toolTip = String(localized: "common.close", defaultValue: "Close") - dismissButton.setAccessibilityLabel(String(localized: "common.close", defaultValue: "Close")) - dismissButton.target = self - dismissButton.action = #selector(handleDismiss) - - let stack = NSStackView(views: [iconView, spinner, titleLabel, detailLabel, reconnectButton]) - stack.translatesAutoresizingMaskIntoConstraints = false - stack.orientation = .vertical - stack.alignment = .centerX - stack.spacing = 10 - cardView.addSubview(stack) - cardView.addSubview(dismissButton) - - // A Cloud split can be narrower than the standard card. The pane owns - // the maximum width; labels wrap within it and controls stay reachable. - updateCardWidth() - NSLayoutConstraint.activate([ - cardView.centerXAnchor.constraint(equalTo: centerXAnchor), - cardView.centerYAnchor.constraint(equalTo: centerYAnchor), - cardWidth, - stack.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 22), - stack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -22), - stack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24), - stack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24), - iconView.widthAnchor.constraint(equalToConstant: 28), - iconView.heightAnchor.constraint(equalToConstant: 28), - spinner.widthAnchor.constraint(equalToConstant: 24), - spinner.heightAnchor.constraint(equalToConstant: 24), - titleLabel.widthAnchor.constraint(equalTo: stack.widthAnchor), - detailLabel.widthAnchor.constraint(equalTo: stack.widthAnchor), - reconnectButton.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor), - dismissButton.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 7), - dismissButton.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -7), - dismissButton.widthAnchor.constraint(equalToConstant: 22), - dismissButton.heightAnchor.constraint(equalToConstant: 22), - ]) + hostingView.sizingOptions = [.intrinsicContentSize] + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + addSubview(hostingView) } - required init?(coder: NSCoder) { - fatalError("init(coder:) not implemented") - } + required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } override func layout() { - updateCardWidth() super.layout() + guard let presentation = currentPresentation else { return } + let width = max(1, min(360, bounds.width - 24)) + if needsContentUpdate || renderedWidth != width { + renderedWidth = width + needsContentUpdate = false + hostingView.rootView = AnyView( + Content( + presentation: presentation, + onReconnect: { [weak self] in self?.onReconnect?() }, + onDismiss: { [weak self] in self?.onDismiss?() } + ) + .frame(width: width) + .fixedSize(horizontal: false, vertical: true) + ) + } + let height = ceil(hostingView.fittingSize.height) + let frame = NSRect(x: bounds.midX - width / 2, y: bounds.midY - height / 2, width: width, height: height) + if hostingView.frame != frame { hostingView.frame = frame } } - private func updateCardWidth() { - let width = min(360, max(1, bounds.width - 24)) - if cardWidth.constant != width { cardWidth.constant = width } - } - - /// Routes hits to the two controls while keeping the rest of the card passive. override func hitTest(_ point: NSPoint) -> NSView? { guard !isHidden, alphaValue > 0 else { return nil } - if let dismissHit = dismissButton.hitTest(convert(point, to: dismissButton)) { - return dismissHit - } - if let buttonHit = reconnectButton.hitTest(convert(point, to: reconnectButton)) { - return buttonHit - } - if cardView.frame.contains(point) { - return self - } - return nil - } - - /// Invokes the selected action when AppKit delivers a card click. - override func mouseDown(with event: NSEvent) { - let pointInButton = reconnectButton.convert(event.locationInWindow, from: nil) - let pointInDismiss = dismissButton.convert(event.locationInWindow, from: nil) - if !dismissButton.isHidden, - dismissButton.bounds.contains(pointInDismiss) { - onDismiss?() - return - } - if reconnectButton.isHidden == false, - reconnectButton.bounds.contains(pointInButton) { - onReconnect?() - } + let localPoint = convert(point, from: superview) + guard hostingView.frame.contains(localPoint) else { return nil } + return hostingView.hitTest(localPoint) ?? self } override func menu(for event: NSEvent) -> NSMenu? { currentPresentation.map { CloudErrorCopy.menu($0.copyableError) } } - /// Applies a new connection snapshot without rebuilding unchanged labels. - /// - /// - Parameter presentation: The title, detail, and available actions to show. func apply(_ presentation: CloudTerminalReconnectOverlayPolicy.Presentation) { guard currentPresentation != presentation else { return } currentPresentation = presentation - titleLabel.stringValue = presentation.title - detailLabel.stringValue = presentation.detail - reconnectButton.menu = CloudErrorCopy.menu(presentation.copyableError) - reconnectButton.isHidden = !presentation.showsReconnectButton - spinner.isHidden = !presentation.showsProgress - iconView.isHidden = presentation.showsProgress - if presentation.showsProgress { - spinner.startAnimation(nil) - } else { - spinner.stopAnimation(nil) - } - iconView.image = NSImage( - systemSymbolName: presentation.showsReconnectButton ? "wifi.exclamationmark" : "arrow.triangle.2.circlepath", - accessibilityDescription: nil - ) + needsContentUpdate = true + needsLayout = true } - @objc private func handleReconnect() { - onReconnect?() - } + private struct Content: View { + let presentation: CloudTerminalReconnectOverlayPolicy.Presentation + let onReconnect: () -> Void + let onDismiss: () -> Void + #if DEBUG + @AppStorage("cloudPaneFailurePrototypeStyle") private var prototypeStyle = "compact-bordered" + #endif + + private var style: CloudFailureCard.Style { + #if DEBUG + CloudFailureCard.Style(rawValue: prototypeStyle) ?? .compactBordered + #else + .compactBordered + #endif + } - @objc private func handleDismiss() { - onDismiss?() + var body: some View { + VStack(spacing: 10) { + if presentation.showsProgress { ProgressView().controlSize(.small) } + CloudFailureCard( + title: presentation.title, + detail: presentation.detail, + copyableText: presentation.copyableError, + style: style, + onRetry: presentation.showsReconnectButton ? onReconnect : nil, + onDismiss: onDismiss + ) + } + } } } diff --git a/Sources/Panels/CloudPaneCreationFailureView.swift b/Sources/Panels/CloudPaneCreationFailureView.swift index ce2bbfd1d905..0ee9d5f0e266 100644 --- a/Sources/Panels/CloudPaneCreationFailureView.swift +++ b/Sources/Panels/CloudPaneCreationFailureView.swift @@ -2,290 +2,369 @@ import AppKit import CmuxAppKitSupportUI import SwiftUI -/// Installs the failure card in the window's native overlay layer. -/// -/// Cloud terminal views are AppKit portal views. A SwiftUI overlay mounted in -/// the workspace content can render behind the terminal and let terminal text -/// show through the error. The bridge keeps a native card above the portal -/// host while keeping all points outside the card untouched. +/// Mounts the latest cloud pane creation failure above one workspace's content. struct CloudPaneCreationFailurePresentation: ViewModifier { let failureStore: CloudPaneCreationFailureStore var isWorkspaceVisible = true + var sourceView: NSView? + #if DEBUG + @AppStorage("cloudPaneFailurePrototypeStyle") private var prototypeStyle = "compact-bordered" + #endif + + private var style: CloudPaneCreationFailureView.Style { + #if DEBUG + CloudPaneCreationFailureView.Style(rawValue: prototypeStyle) ?? .compactBordered + #else + .compactBordered + #endif + } + /// Adds the failure card above the workspace content when a failure exists. func body(content: Content) -> some View { - content.background( - CloudPaneCreationFailureWindowBridge( - failure: failureStore.failure, - isWorkspaceVisible: isWorkspaceVisible, - onRetry: failureStore.canRetry ? { [weak failureStore] id in - failureStore?.retry(id: id) - } : nil, + content.background { + NativeOverlay( + failure: isWorkspaceVisible ? failureStore.failure : nil, + sourceView: sourceView, + style: style, + onRetry: failureStore.canRetry ? { [weak failureStore] id in failureStore?.retry(id: id) } : nil, onDismiss: { [weak failureStore] id in failureStore?.dismiss(id: id) } ) - ) - } -} - -@MainActor -private struct CloudPaneCreationFailureWindowBridge: NSViewRepresentable { - let failure: CloudPaneCreationFailure? - let isWorkspaceVisible: Bool - let onRetry: ((UUID) -> Void)? - let onDismiss: (UUID) -> Void - - func makeNSView(context: Context) -> CloudPaneCreationFailureOverlayHostView { - let view = CloudPaneCreationFailureOverlayHostView(frame: .zero) - view.isHidden = !isWorkspaceVisible - view.update(failure: failure, onRetry: onRetry, onDismiss: onDismiss) - return view - } - - func updateNSView(_ nsView: CloudPaneCreationFailureOverlayHostView, context: Context) { - nsView.isHidden = !isWorkspaceVisible - nsView.update(failure: failure, onRetry: onRetry, onDismiss: onDismiss) - } - - static func dismantleNSView(_ nsView: CloudPaneCreationFailureOverlayHostView, coordinator: ()) { - nsView.detach() - } -} - -/// Owns a card that is inserted above the window's portal views. -@MainActor -final class CloudPaneCreationFailureOverlayHostView: NSView { - private let card = CloudPaneCreationFailureOverlayView(frame: .zero) - private let chromeComposition = AppWindowChromeComposition() - private var installConstraints: [NSLayoutConstraint] = [] - private weak var installedContainer: NSView? - private weak var installedReference: NSView? - private var pendingFailure: CloudPaneCreationFailure? - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - translatesAutoresizingMaskIntoConstraints = false - wantsLayer = true - layer?.backgroundColor = NSColor.clear.cgColor - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func update( - failure: CloudPaneCreationFailure?, - onRetry: ((UUID) -> Void)?, - onDismiss: @escaping (UUID) -> Void - ) { - pendingFailure = failure - guard let failure else { - removeCard() - return } - card.update(failure: failure, onRetry: onRetry, onDismiss: onDismiss) - _ = ensureInstalled() } - override var isHidden: Bool { - didSet { _ = ensureInstalled() } - } - - override func hitTest(_ point: NSPoint) -> NSView? { nil } + /// The anchor stays in the workspace layout; the interactive card is a + /// native sibling above the terminal/browser portals, like the palette. + struct NativeOverlay: NSViewRepresentable { + let failure: CloudPaneCreationFailure? + let sourceView: NSView? + let style: CloudPaneCreationFailureView.Style + let onRetry: ((UUID) -> Void)? + let onDismiss: (UUID) -> Void + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> AnchorView { + let view = AnchorView() + view.coordinator = context.coordinator + context.coordinator.anchor = view + return view + } - override func layout() { - super.layout() - _ = ensureInstalled() - } + func updateNSView(_ view: AnchorView, context: Context) { + context.coordinator.update( + failure: failure, + layoutDirection: context.environment.layoutDirection, + colorScheme: context.environment.colorScheme, + sourceView: sourceView, + style: style, + onRetry: onRetry, + onDismiss: onDismiss + ) + } - override func viewWillMove(toWindow newWindow: NSWindow?) { - if newWindow !== window { removeCard() } - super.viewWillMove(toWindow: newWindow) - } + static func dismantleNSView(_ view: AnchorView, coordinator: Coordinator) { + view.coordinator = nil + coordinator.removeCard() + } - override func viewWillMove(toSuperview newSuperview: NSView?) { - if newSuperview == nil { removeCard() } - super.viewWillMove(toSuperview: newSuperview) - } + @MainActor + final class AnchorView: NSView { + weak var coordinator: Coordinator? + override var isHidden: Bool { + didSet { coordinator?.synchronize() } + } + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow !== window { coordinator?.removeCard() } + super.viewWillMove(toWindow: newWindow) + } + override func viewWillMove(toSuperview newSuperview: NSView?) { + if newSuperview == nil { coordinator?.removeCard() } + super.viewWillMove(toSuperview: newSuperview) + } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + coordinator?.synchronize() + } + override func layout() { + super.layout() + coordinator?.synchronize() + } + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + coordinator?.synchronize() + } + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + coordinator?.synchronize() + } + } - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - _ = ensureInstalled() + @MainActor + final class Coordinator { + private struct RenderState: Equatable { + let failure: CloudPaneCreationFailure + let width: CGFloat + let layoutDirection: LayoutDirection + let colorScheme: ColorScheme + let style: CloudPaneCreationFailureView.Style + } + + weak var anchor: AnchorView? + private var failure: CloudPaneCreationFailure? + private var onDismiss: ((UUID) -> Void)? + private var onRetry: ((UUID) -> Void)? + private var layoutDirection: LayoutDirection = .leftToRight + private var colorScheme: ColorScheme = .light + private var card: NSHostingView? + private var rendered: RenderState? + private weak var sourceView: NSView? + private var style: CloudPaneCreationFailureView.Style = .compactBordered + private var geometryObservers: [NSObjectProtocol] = [] + private var observedViews: [ObjectIdentifier] = [] + private var isSynchronizing = false + private let chromeComposition = AppWindowChromeComposition() + + func update( + failure: CloudPaneCreationFailure?, + layoutDirection: LayoutDirection, + colorScheme: ColorScheme, + sourceView: NSView?, + style: CloudPaneCreationFailureView.Style, + onRetry: ((UUID) -> Void)?, + onDismiss: @escaping (UUID) -> Void + ) { + self.failure = failure + self.layoutDirection = layoutDirection + self.colorScheme = colorScheme + self.sourceView = sourceView + self.style = style + self.onRetry = onRetry + self.onDismiss = onDismiss + synchronize() + } + + func removeCard() { + card?.removeFromSuperview() + card = nil + rendered = nil + geometryObservers.forEach(NotificationCenter.default.removeObserver) + geometryObservers.removeAll() + observedViews.removeAll() + } + + deinit { geometryObservers.forEach(NotificationCenter.default.removeObserver) } + + private func observeGeometry(from source: NSView, through container: NSView) { + var views: [NSView] = [] + var current: NSView? = source + while let view = current, view !== container { + views.append(view) + current = view.superview + } + let identities = views.map(ObjectIdentifier.init) + guard observedViews != identities else { return } + geometryObservers.forEach(NotificationCenter.default.removeObserver) + geometryObservers.removeAll() + observedViews = identities + for view in views { + view.postsFrameChangedNotifications = true + view.postsBoundsChangedNotifications = true + for name in [NSView.frameDidChangeNotification, NSView.boundsDidChangeNotification] { + geometryObservers.append(NotificationCenter.default.addObserver(forName: name, object: view, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.synchronize() } + }) + } + } + } + + func synchronize() { + // Measuring the SwiftUI card can synchronously lay out its + // anchor. The anchor remains the sole source of geometry. + guard !isSynchronizing else { return } + isSynchronizing = true + defer { isSynchronizing = false } + guard let failure, let anchor, let window = anchor.window, let sourceView, + !anchor.isHiddenOrHasHiddenAncestor, + sourceView.window === window, !sourceView.isHiddenOrHasHiddenAncestor, + let target = chromeComposition.contentOverlayTargetResolver.installationTarget(for: window) else { + removeCard() + return + } + observeGeometry(from: sourceView, through: target.container) + // The originating terminal defines placement, even if focus + // moves while the remote request is in flight. Its native + // content bounds exclude Bonsplit's tab and split controls. + let bounds = target.container.convert(sourceView.visibleRect, from: sourceView) + .intersection(target.container.convert(target.reference.bounds, from: target.reference)) + guard !bounds.isNull, bounds.width > 32, bounds.height > 24 else { + removeCard() + return + } + let width = min(style == .dialog ? 320 : 360, bounds.width - 24) + let nextRender = RenderState(failure: failure, width: width, layoutDirection: layoutDirection, colorScheme: colorScheme, style: style) + let root = AnyView( + CloudPaneCreationFailureView( + failure: failure, style: style, + onRetry: onRetry == nil ? nil : { [weak self] in self?.onRetry?(failure.id) }, + onDismiss: { [weak self] in self?.onDismiss?(failure.id) } + ) + .environment(\.layoutDirection, layoutDirection) + .environment(\.colorScheme, colorScheme) + .frame(width: width) + .fixedSize(horizontal: false, vertical: true) + ) + let host = card ?? NSHostingView(rootView: root) + if card == nil { + host.identifier = NSUserInterfaceItemIdentifier("cmux.cloudPaneCreationFailure.card") + host.sizingOptions = [.intrinsicContentSize] + host.wantsLayer = true + host.layer?.backgroundColor = NSColor.clear.cgColor + } + card = host + if host.superview !== target.container { + host.removeFromSuperview() + // Portals install just above the content reference (or + // each other), keeping later portal mounts below this card. + // Palette and other foreground controls retain their order. + let foregroundSurface = target.container.subviews.last { + $0 is WindowTerminalHostView || $0 is WindowBrowserHostView + } ?? target.reference + target.container.addSubview(host, positioned: .above, relativeTo: foregroundSurface) + } + var height = host.frame.height + if rendered != nextRender { + host.rootView = root + height = ceil(host.fittingSize.height) + rendered = nextRender + } + let x = bounds.midX - width / 2 + let y = bounds.midY - height / 2 + let frame = NSRect(x: x, y: y, width: width, height: height) + if host.frame != frame { host.frame = frame } + } + } } +} - func detach() { - pendingFailure = nil - removeCard() +/// A workspace failure and a reserved terminal use the same responsive content. +struct CloudPaneCreationFailureView: View { + typealias Style = CloudFailureCard.Style + let failure: CloudPaneCreationFailure + var style: Style = .compactBordered + var onRetry: (() -> Void)? = nil + let onDismiss: () -> Void + + var body: some View { + CloudFailureCard( + title: failure.displayTitle, detail: failure.errorText, + copyableText: failure.copyableText, style: style, + onRetry: onRetry, onDismiss: onDismiss + ) } +} - private func removeCard() { - NSLayoutConstraint.deactivate(installConstraints) - installConstraints.removeAll() - card.removeFromSuperview() - installedContainer = nil - installedReference = nil +/// Text takes the entire card width. The close control cannot compress the body +/// into a narrow column, and copying remains a contextual troubleshooting action. +struct CloudFailureCard: View { + enum Style: String, Equatable { case compact, compactBordered = "compact-bordered", dialog, inline } + let title: String + let detail: String + let copyableText: String + var style: Style = .compactBordered + var onRetry: (() -> Void)? = nil + let onDismiss: () -> Void + + private var cornerRadius: CGFloat { + switch style { + case .compactBordered: 0 + case .inline: 3 + case .compact, .dialog: 9 + } } - @discardableResult - private func ensureInstalled() -> Bool { - guard pendingFailure != nil, !isHiddenOrHasHiddenAncestor, - let window, - let target = chromeComposition.contentOverlayTargetResolver.installationTarget(for: window) else { - removeCard() - return false + var body: some View { + VStack(alignment: style == .dialog ? .center : .leading, spacing: 10) { + Header(title: title, style: style, onDismiss: onDismiss) + Text(detail) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .multilineTextAlignment(style == .dialog ? .center : .leading) + .frame(maxWidth: .infinity, alignment: style == .dialog ? .center : .leading) + .fixedSize(horizontal: false, vertical: true) + if let onRetry { + Button(String(localized: "common.retry", defaultValue: "Retry"), action: onRetry) + .buttonStyle(.bordered) + .controlSize(.small) + .fixedSize() + .accessibilityIdentifier("CloudPaneCreationFailureRetry") + } } - card.fit(width: min(420, max(160, bounds.width - 32))) - if card.superview !== target.container || installedContainer !== target.container || installedReference !== target.reference { - NSLayoutConstraint.deactivate(installConstraints) - installConstraints.removeAll() - card.removeFromSuperview() - target.container.addSubview(card, positioned: .above, relativeTo: nil) - installConstraints = [ - card.centerXAnchor.constraint(equalTo: centerXAnchor), - card.centerYAnchor.constraint(equalTo: centerYAnchor), - ] - NSLayoutConstraint.activate(installConstraints) - installedContainer = target.container - installedReference = target.reference + .padding(12) + .frame(maxWidth: .infinity, alignment: style == .dialog ? .center : .leading) + .background(Color(nsColor: .windowBackgroundColor), in: RoundedRectangle(cornerRadius: cornerRadius)) + .overlay { + if style == .compactBordered { + Rectangle() + .strokeBorder(Color.primary.opacity(0.22), lineWidth: 1) + } else if style != .inline { + RoundedRectangle(cornerRadius: 9) + .strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5) + } } - return true - } -} - -/// A native, opaque failure card above portal-hosted terminal views. -@MainActor -final class CloudPaneCreationFailureOverlayView: NSView { - private let iconView = NSImageView(frame: .zero) - private let titleLabel = NSTextField(wrappingLabelWithString: "") - private let detailLabel = NSTextField(wrappingLabelWithString: "") - private let recoveryLabel = NSTextField(wrappingLabelWithString: "") - private let retryButton = NSButton(frame: .zero) - private let dismissButton = NSButton(frame: .zero) - private var currentFailure: CloudPaneCreationFailure? - private var onRetry: ((UUID) -> Void)? - private var onDismiss: ((UUID) -> Void)? - private lazy var cardWidth = widthAnchor.constraint(equalToConstant: 420) - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - translatesAutoresizingMaskIntoConstraints = false - wantsLayer = true - layer?.cornerRadius = 12 - layer?.borderWidth = 1 - layer?.borderColor = NSColor.systemOrange.withAlphaComponent(0.38).cgColor - updateBackgroundColor() - layer?.shadowColor = NSColor.black.withAlphaComponent(0.22).cgColor - layer?.shadowOpacity = 1 - layer?.shadowRadius = 12 - layer?.shadowOffset = CGSize(width: 0, height: -4) - - iconView.translatesAutoresizingMaskIntoConstraints = false - iconView.image = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: nil) - iconView.contentTintColor = .systemOrange - iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 19, weight: .semibold) - - for label in [titleLabel, detailLabel, recoveryLabel] { - label.translatesAutoresizingMaskIntoConstraints = false - label.maximumNumberOfLines = 4 - label.lineBreakMode = .byWordWrapping + .overlay(alignment: .leading) { + if style == .inline { Rectangle().fill(Color.secondary.opacity(0.35)).frame(width: 2) } } - titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) - titleLabel.textColor = .labelColor - detailLabel.font = .systemFont(ofSize: 12, weight: .medium) - detailLabel.textColor = .secondaryLabelColor - recoveryLabel.font = .systemFont(ofSize: 11) - recoveryLabel.textColor = .secondaryLabelColor - - retryButton.translatesAutoresizingMaskIntoConstraints = false - retryButton.title = String(localized: "common.retry", defaultValue: "Retry") - retryButton.bezelStyle = .rounded - retryButton.controlSize = .regular - retryButton.target = self - retryButton.action = #selector(handleRetry) - retryButton.setAccessibilityIdentifier("CloudPaneCreationFailureRetry") - - dismissButton.translatesAutoresizingMaskIntoConstraints = false - dismissButton.title = String(localized: "cloudPane.newTerminalFailed.ok", defaultValue: "OK") - dismissButton.bezelStyle = .rounded - dismissButton.controlSize = .regular - dismissButton.target = self - dismissButton.action = #selector(handleDismiss) - dismissButton.keyEquivalent = "\u{1b}" - dismissButton.keyEquivalentModifierMask = [] - dismissButton.setAccessibilityIdentifier("CloudPaneCreationFailureDismiss") - - let labels = NSStackView(views: [titleLabel, detailLabel, recoveryLabel]) - labels.translatesAutoresizingMaskIntoConstraints = false - labels.orientation = .vertical - labels.alignment = .leading - labels.spacing = 6 - let actions = NSStackView(views: [retryButton, dismissButton]) - actions.translatesAutoresizingMaskIntoConstraints = false - actions.orientation = .horizontal - actions.alignment = .centerY - actions.spacing = 8 - addSubview(iconView) - addSubview(labels) - addSubview(actions) - - NSLayoutConstraint.activate([ - cardWidth, - iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), - iconView.topAnchor.constraint(equalTo: topAnchor, constant: 22), - iconView.widthAnchor.constraint(equalToConstant: 24), - iconView.heightAnchor.constraint(equalToConstant: 24), - labels.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 12), - labels.topAnchor.constraint(equalTo: topAnchor, constant: 20), - labels.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), - titleLabel.widthAnchor.constraint(equalTo: labels.widthAnchor), - detailLabel.widthAnchor.constraint(equalTo: labels.widthAnchor), - recoveryLabel.widthAnchor.constraint(equalTo: labels.widthAnchor), - labels.bottomAnchor.constraint(equalTo: actions.topAnchor, constant: -14), - actions.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), - actions.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16), - ]) - setAccessibilityIdentifier("CloudPaneCreationFailure") - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func fit(width: CGFloat) { - if cardWidth.constant != width { cardWidth.constant = width } + .shadow(color: .black.opacity(style == .compact || style == .dialog ? 0.09 : 0), radius: 8, y: 3) + .accessibilityIdentifier("CloudPaneCreationFailure") + .cloudErrorCopyMenu(copyableText) } - override func viewDidChangeEffectiveAppearance() { - super.viewDidChangeEffectiveAppearance() - updateBackgroundColor() - } - - private func updateBackgroundColor() { - effectiveAppearance.performAsCurrentDrawingAppearance { - layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + private struct Header: View { + let title: String + let style: Style + let onDismiss: () -> Void + var body: some View { + VStack(spacing: 8) { + if style == .dialog { + HStack { + Image(systemName: "terminal") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Spacer(minLength: 8) + DismissButton(onDismiss: onDismiss) + } + } + HStack(alignment: .top, spacing: 8) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .multilineTextAlignment(style == .dialog ? .center : .leading) + .frame(maxWidth: .infinity, alignment: style == .dialog ? .center : .leading) + .fixedSize(horizontal: false, vertical: true) + .layoutPriority(1) + if style != .dialog { DismissButton(onDismiss: onDismiss) } + } + } } } - func update( - failure: CloudPaneCreationFailure, - onRetry: ((UUID) -> Void)?, - onDismiss: @escaping (UUID) -> Void - ) { - currentFailure = failure - self.onRetry = onRetry - self.onDismiss = onDismiss - retryButton.isHidden = onRetry == nil - titleLabel.stringValue = failure.title - detailLabel.stringValue = failure.errorText - recoveryLabel.stringValue = failure.recoveryText - needsLayout = true - } - - override func menu(for event: NSEvent) -> NSMenu? { - currentFailure.map { CloudErrorCopy.menu($0.copyableText) } - } - - @objc private func handleDismiss() { - guard let id = currentFailure?.id else { return } - onDismiss?(id) - } - - @objc private func handleRetry() { - guard let id = currentFailure?.id else { return } - onRetry?(id) + private struct DismissButton: View { + let onDismiss: () -> Void + var body: some View { + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .medium)) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .fixedSize() + .keyboardShortcut(.cancelAction) + .help(String(localized: "machines.pending.dismiss", defaultValue: "Dismiss")) + .accessibilityLabel(String(localized: "machines.pending.dismiss", defaultValue: "Dismiss")) + .accessibilityIdentifier("CloudPaneCreationFailureDismiss") + } } } diff --git a/Sources/Surfaces/CloudPaneCreationFailure.swift b/Sources/Surfaces/CloudPaneCreationFailure.swift index 8e727e1f5676..402d7aaf81e9 100644 --- a/Sources/Surfaces/CloudPaneCreationFailure.swift +++ b/Sources/Surfaces/CloudPaneCreationFailure.swift @@ -4,14 +4,19 @@ import Foundation struct CloudPaneCreationFailure: Identifiable, Equatable { let id: UUID let machine: SurfaceMachineID + let sourcePanelID: UUID? let title: String + let displayTitle: String let errorText: String let recoveryText: String + let diagnosticReference: String? /// Builds a privacy-safe, localized snapshot from a provider error. - init(machine: SurfaceMachineID, error: Error, title: String? = nil, recoveryText: String? = nil) { + init(machine: SurfaceMachineID, error: Error, title: String? = nil, recoveryText: String? = nil, context: CloudOperationContext? = nil, sourcePanelID: UUID? = nil) { id = UUID() self.machine = machine + self.sourcePanelID = sourcePanelID + displayTitle = title ?? String(localized: "cloudPane.newTerminalFailed.shortTitle", defaultValue: "Couldn’t open terminal") self.title = title ?? String( format: String( localized: "cloudPane.newTerminalFailed.title", @@ -19,10 +24,10 @@ struct CloudPaneCreationFailure: Identifiable, Equatable { ), machine.rawValue ) - let diagnostic = CloudDiagnosticFailure.classify(error) - errorText = diagnostic == .unknown - ? String(localized: "cloudPane.newTerminalFailed.unknownError", defaultValue: "The machine returned an unknown error.") - : diagnostic.label + errorText = Self.errorMessage(error) + diagnosticReference = context.map { + "operation=\($0.operationID.uuidString.lowercased()) trace=\($0.traceID)" + } self.recoveryText = recoveryText ?? String( localized: "cloudPane.newTerminalFailed.recovery", defaultValue: "Check that the machine is connected, then retry this request." @@ -31,6 +36,29 @@ struct CloudPaneCreationFailure: Identifiable, Equatable { /// The localized text copied from the card's context menu for troubleshooting. var copyableText: String { - "\(title)\n\(errorText)\n\(recoveryText)" + [title, errorText, recoveryText, diagnosticReference].compactMap { $0 }.joined(separator: "\n") + } + + /// Only known, structured errors may supply detail. A process response can + /// contain terminal content or credentials, so never copy arbitrary error text. + private static func errorMessage(_ error: Error) -> String { + if let error = error as? CmuxTuiSurfaceProvider.ProviderError { + switch error { + case .remoteWorkspaceNotFound, .remotePlacementUnavailable, .remoteTabNotFound, + .terminalExited, .terminalAttachTimedOut: + if let message = error.errorDescription { return message } + case .noWorkspaceOnMachine: + return String(localized: "cloudPane.newTerminalFailed.noWorkspace", defaultValue: "This machine has no available workspace. Refresh the machine and try again.") + case .stateUnavailable: + return String(localized: "cloudPane.newTerminalFailed.stateUnavailable", defaultValue: "The machine’s current state could not be loaded. Reconnect and try again.") + case .terminalNotCreated: + return String(localized: "cloudPane.newTerminalFailed.invalidResult", defaultValue: "The machine did not return the new terminal. Refresh the machine before trying again.") + default: break + } + } + if let error = error as? SurfaceCatalogError, case .ambiguousRemotePlacement = error { + return error.localizedDescription + } + return CloudDiagnosticFailure.classify(error).label } } diff --git a/Sources/Surfaces/CloudPaneCreationFailureStore.swift b/Sources/Surfaces/CloudPaneCreationFailureStore.swift index fa45cb3c4a03..366ea442da58 100644 --- a/Sources/Surfaces/CloudPaneCreationFailureStore.swift +++ b/Sources/Surfaces/CloudPaneCreationFailureStore.swift @@ -29,13 +29,13 @@ final class CloudPaneCreationFailureStore { } /// Publishes a newly formatted failure, replacing any older card for this workspace. - func present(machine: SurfaceMachineID, error: Error, requestID: UUID, title: String? = nil, recoveryText: String? = nil) { + func present(machine: SurfaceMachineID, error: Error, requestID: UUID, title: String? = nil, recoveryText: String? = nil, context: CloudOperationContext? = nil, sourcePanelID: UUID? = nil) { guard activeRequestID == requestID else { requests.removeValue(forKey: requestID) return } failedRequestID = requestID - phase = .failed(CloudPaneCreationFailure(machine: machine, error: error, title: title, recoveryText: recoveryText)) + phase = .failed(CloudPaneCreationFailure(machine: machine, error: error, title: title, recoveryText: recoveryText, context: context ?? CloudOperationContext.current, sourcePanelID: sourcePanelID)) } /// Retains each independent intent until it completes or is dismissed. Retry diff --git a/Sources/Surfaces/Workspace+CloudPlacementFailure.swift b/Sources/Surfaces/Workspace+CloudPlacementFailure.swift index 5bf4e0e4162a..9a9feacb4ac5 100644 --- a/Sources/Surfaces/Workspace+CloudPlacementFailure.swift +++ b/Sources/Surfaces/Workspace+CloudPlacementFailure.swift @@ -17,7 +17,8 @@ extension Workspace { error: error, requestID: requestID, title: title, - recoveryText: recovery + recoveryText: recovery, + sourcePanelID: focusedPanelId ) } } diff --git a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift index 91d3436b6f5e..3885e596eb4d 100644 --- a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift +++ b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift @@ -1,3 +1,4 @@ +import AppKit import Bonsplit import CmuxWorkspaces import Foundation @@ -16,6 +17,19 @@ import Foundation /// same grace a reconnect uses, and a failure is explained inside the pane with Retry. @MainActor extension Workspace { + /// The pane that initiated the request owns its error, regardless of later + /// focus changes. A hidden source tab must not cover the tab replacing it. + var cloudPaneCreationFailureSourceView: NSView? { + guard let panelID = cloudPaneCreationFailureStore.failure?.sourcePanelID, + let paneID = paneId(forPanelId: panelID), + let surfaceID = surfaceIdFromPanelId(panelID), + bonsplitController.selectedTab(inPane: paneID)?.id == surfaceID else { return nil } + if let terminal = panels[panelID] as? TerminalPanel { return terminal.hostedView } + if let browser = panels[panelID] as? BrowserPanel { return browser.webView } + return nil + } + + /// The cloud resource behind a panel, when the panel projects one. func cloudProjectedResource(forPanel panelID: UUID, catalog: SurfaceCatalog? = nil) -> SurfaceResource? { let catalog = catalog ?? SurfaceCatalog.shared @@ -113,11 +127,14 @@ extension Workspace { // No pane exists yet for this request, so the ambiguity is reported on // the workspace card rather than inside a pane. Task { @MainActor in - self.presentCloudPaneCreationFailure( - machine: machine, - error: SurfaceCatalogError.ambiguousRemotePlacement(resource.id, workspaceID: ""), - requestID: requestID - ) + try? await CloudTerminalCreationCoordinator.perform( + recorder: AppDelegate.shared?.cloudOperations, + onFailure: { error, context in + self.presentCloudPaneCreationFailure(machine: machine, error: error, requestID: requestID, context: context, sourcePanelID: sourcePanelID) + } + ) { + throw SurfaceCatalogError.ambiguousRemotePlacement(resource.id, workspaceID: "") + } } if let pendingPane { closeUntouchedPane(pendingPane) } return true @@ -289,10 +306,10 @@ extension Workspace { /// Publishes a non-modal failure card for a cloud terminal request. @MainActor - func presentCloudPaneCreationFailure(machine: SurfaceMachineID, error: Error, requestID: UUID) { + func presentCloudPaneCreationFailure(machine: SurfaceMachineID, error: Error, requestID: UUID, context: CloudOperationContext? = nil, sourcePanelID: UUID? = nil) { #if DEBUG cmuxDebugLog("cloud.pane.createFailed machine=\(machine.rawValue) error=\(String(reflecting: error))") #endif - cloudPaneCreationFailureStore.present(machine: machine, error: error, requestID: requestID) + cloudPaneCreationFailureStore.present(machine: machine, error: error, requestID: requestID, context: context, sourcePanelID: sourcePanelID ?? focusedPanelId) } } diff --git a/Sources/Surfaces/Workspace+CloudTerminalReservation.swift b/Sources/Surfaces/Workspace+CloudTerminalReservation.swift index e47ec1d7b0f8..cfd1cf0114c9 100644 --- a/Sources/Surfaces/Workspace+CloudTerminalReservation.swift +++ b/Sources/Surfaces/Workspace+CloudTerminalReservation.swift @@ -97,11 +97,11 @@ extension Workspace { func failReservedCloudTerminalPane(_ reservation: CloudTerminalPaneReservation, error: Error) { guard cloudPendingCreations[reservation.panelID] === reservation else { return } setCloudManualMirrorTabLoading(panelID: reservation.panelID, false) - let failure = CloudPaneCreationFailure(machine: reservation.machine, error: error) + let failure = CloudPaneCreationFailure(machine: reservation.machine, error: error, context: CloudOperationContext.current) setCloudMaterializationFailure( surfaceID: reservation.panelID, - detail: "\(failure.errorText) \(failure.recoveryText)", - reference: nil + detail: failure.errorText, + reference: failure.copyableText ) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 3708073abbbb..24635707fc75 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7565,7 +7565,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos reference: String? ) -> CloudTerminalReconnectOverlayPolicy.Presentation { var presentation = CloudTerminalReconnectOverlayPolicy.Presentation( - title: String(localized: "cloud.overlay.materializationFailed.title", defaultValue: "Cloud terminal could not start"), + title: String(localized: "cloudPane.newTerminalFailed.shortTitle", defaultValue: "Couldn’t open terminal"), detail: detail, showsProgress: false, showsReconnectButton: true diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift index ccfa90ca3ddb..56f9a3ead9bf 100644 --- a/Sources/WorkspaceContentView.swift +++ b/Sources/WorkspaceContentView.swift @@ -424,7 +424,8 @@ struct WorkspaceContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .modifier(CloudPaneCreationFailurePresentation( failureStore: workspace.cloudPaneCreationFailureStore, - isWorkspaceVisible: isWorkspaceVisible + isWorkspaceVisible: isWorkspaceVisible, + sourceView: workspace.cloudPaneCreationFailureSourceView )) } private func syncBonsplitNotificationBadges() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 460941dc90d2..918879de0b0d 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -663,6 +663,35 @@ struct cmuxApp: App { Button("Cloud Tree Style Gallery…") { CloudTreeStyleGalleryWindowController.shared.show() } + Menu("Cloud Terminal Error Style") { + Button("Preview in Selected Terminal") { + guard let workspace = activeTabManager.selectedWorkspace, + let panelID = workspace.focusedPanelId, + workspace.terminalPanel(for: panelID) != nil else { return } + let failure = CloudPaneCreationFailure( + machine: .cloud("preview"), + error: CmuxTuiSurfaceProvider.ProviderError.stateUnavailable("preview") + ) + workspace.setCloudMaterializationFailure( + surfaceID: panelID, + detail: failure.errorText, + reference: "Design preview" + ) + } + Divider() + Button("Compact") { + UserDefaults.standard.set("compact", forKey: "cloudPaneFailurePrototypeStyle") + } + Button("Compact + 1px Border") { + UserDefaults.standard.set("compact-bordered", forKey: "cloudPaneFailurePrototypeStyle") + } + Button("Dialog") { + UserDefaults.standard.set("dialog", forKey: "cloudPaneFailurePrototypeStyle") + } + Button("Inline") { + UserDefaults.standard.set("inline", forKey: "cloudPaneFailurePrototypeStyle") + } + } Button( String( localized: "debug.menu.browserProfilePopoverDebug", diff --git a/cmuxTests/CloudTerminalCreationCoordinatorTests.swift b/cmuxTests/CloudTerminalCreationCoordinatorTests.swift index 12377bb2e060..7a15cf3c0944 100644 --- a/cmuxTests/CloudTerminalCreationCoordinatorTests.swift +++ b/cmuxTests/CloudTerminalCreationCoordinatorTests.swift @@ -96,6 +96,24 @@ struct CloudTerminalCreationCoordinatorTests { #expect(failed == 0) } + @Test @MainActor + func classifiedCancellationReportsCancelWithoutFailure() async { + var cancelled = 0 + var failed = 0 + let coordinator = CloudTerminalCreationCoordinator( + create: { throw URLError(.cancelled) }, + project: { _ in throw URLError(.cancelled) }, + onFailure: { _ in failed += 1 }, + onCancel: { cancelled += 1 }, + onSuccess: {} + ) + + coordinator.start() + await Self.yieldUntil { cancelled >= 1 } + #expect(cancelled == 1) + #expect(failed == 0) + } + @Test @MainActor func cancellationDiscardsAProjectionCreatedByTheStaleOperation() async { let resource = Self.resource(key: "term_1") diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index 9630638ad24a..58c2c9ae594d 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -1,7 +1,10 @@ import AppKit import Bonsplit +import CmuxAppKitSupportUI +import CmuxAuthRuntime import CmuxPanes import Testing +import SwiftUI #if canImport(cmux_DEV) @testable import cmux_DEV @@ -122,19 +125,21 @@ import Testing } /// Exercises the cloud shortcut failure route and verifies it stays non-modal. - @Test("Failed cloud pane creation does not enter a process-modal run loop") - func failedCloudPaneCreationStaysInWorkspaceState() async throws { + @Test("Cloud shortcut failures show their cause and export a matching diagnostic", arguments: [false, true]) + func failedCloudPaneCreationStaysInWorkspaceState(split: Bool) async throws { let harness = try Harness() defer { harness.tearDown() } + let diagnostics = CloudPaneCapturedDiagnostics() + let identity = AuthenticatedSessionIdentity(generation: 1, accountID: "test-account") + let recorder = CloudOperationRecorder(uploader: diagnostics, identity: { identity }) + let previousRecorder = harness.appDelegate.cloudOperations + harness.appDelegate.cloudOperations = recorder + defer { harness.appDelegate.cloudOperations = previousRecorder } let workspace = harness.workspace let paneID = try #require(workspace.bonsplitController.focusedPaneId) let sourcePanelID = try #require(workspace.focusedPanelId) let machine = SurfaceMachineID.cloud("failed-pane-\(UUID().uuidString)") - let error = NSError( - domain: "CloudPaneCreationFailureTests", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "connection refused"] - ) + let error = CmuxTuiSurfaceProvider.ProviderError.remoteTabNotFound("tab-failure") let provider = CloudCreationProvider(machine: machine, workingDirectory: nil, creationError: error) let catalog = SurfaceCatalog.shared catalog.register(provider) @@ -161,29 +166,137 @@ import Testing remoteTabID: "tab-failure" )) - #expect(workspace.routeCloudPaneTerminalTab(inPane: paneID, focus: false)) + if split { + #expect(workspace.routeCloudPaneTerminalSplit(from: sourcePanelID, orientation: .horizontal, insertFirst: false, focus: false)) + } else { + #expect(workspace.routeCloudPaneTerminalTab(inPane: paneID, focus: false)) + } let pendingPanelID = try #require(workspace.cloudPendingCreations.keys.first) await provider.creationAttemptSignal.wait() - let failure = try await waitForPaneFailure(workspace, panelID: pendingPanelID) + let presentation = try await waitForPaneFailure(workspace, panelID: pendingPanelID) + let deadline = ContinuousClock.now + .seconds(2) + while recorder.operations.first?.outcome == nil, ContinuousClock.now < deadline { + await Task.yield() + } #expect(NSApp.modalWindow == nil) - #expect(workspace.cloudPaneCreationFailureStore.failure == nil) - #expect(workspace.cloudPendingCreations[pendingPanelID]?.machine == machine) - #expect(!failure.detail.isEmpty) - #expect(!failure.detail.contains("connection refused")) - #expect(failure.showsReconnectButton) + #expect(workspace.cloudPaneCreationFailureStore.failure == nil, "A reserved terminal owns its error; no duplicate source-pane card") + let panelID = try #require(workspace.cloudMaterializationFailures.keys.first) + #expect(panelID != sourcePanelID) + #expect(workspace.cloudMaterializationFailures.count == 1) + let failure = try #require(workspace.cloudMaterializationFailures[panelID]) + #expect(panelID == pendingPanelID) + #expect(workspace.cloudPendingCreations[panelID]?.machine == machine) + #expect(failure.detail == error.errorDescription) + #expect(presentation.showsReconnectButton) + let operation = try #require(recorder.operations.first) + #expect(operation.operation == .terminal) + #expect(operation.failure == .notFound) + #expect(failure.reference?.contains(operation.traceID) == true) + let spans = await diagnostics.spans + #expect(spans.contains { $0.parentSpanId == nil && $0.failure == .notFound && $0.traceId == operation.traceID }) var requestIterator = provider.creationRequests.stream.makeAsyncIterator() let firstRequest = await requestIterator.next() - #expect(workspace.retryReservedCloudTerminalPane(surfaceId: pendingPanelID)) + #expect(workspace.retryReservedCloudTerminalPane(surfaceId: panelID)) let retryRequest = await requestIterator.next() - _ = try await waitForPaneFailure(workspace, panelID: pendingPanelID) #expect(firstRequest != nil) #expect(retryRequest == firstRequest) + _ = try await waitForPaneFailure(workspace, panelID: panelID) #expect(workspace.cloudPendingCreations.count == 1) + #expect(workspace.closePanel(panelID, force: true)) + #expect(workspace.cloudPendingCreations[panelID] == nil) + #expect(workspace.panels[panelID] == nil) + workspace.cloudPaneCreationFailureStore.cancelAll() + } + + @Test("Provider and placement failures retain safe error categories") + func knownCloudTerminalFailuresAreNotUnknown() { + let errors: [(Error, CloudDiagnosticFailure)] = [ + (CmuxTuiSurfaceProvider.ProviderError.remoteTabNotFound("tab"), .notFound), + (CmuxTuiSurfaceProvider.ProviderError.noWorkspaceOnMachine("machine"), .placement), + (CmuxTuiSurfaceProvider.ProviderError.stateUnavailable("machine"), .response), + (CmuxTuiSurfaceProvider.ProviderError.terminalExited("term"), .process), + (CmuxTuiSurfaceProvider.ProviderError.terminalNotCreated("private response"), .process), + (SurfaceCatalogError.ambiguousRemotePlacement(.init(machine: .cloud("machine"), kind: .terminal, key: "term"), workspaceID: "private-workspace"), .conflict) + ] + for (error, expected) in errors { + #expect(CloudDiagnosticFailure.classify(error) == expected) + let failure = CloudPaneCreationFailure(machine: .cloud("machine"), error: error) + #expect(!failure.errorText.contains("unknown error")) + #expect(!failure.copyableText.contains("private response")) + #expect(!failure.copyableText.contains("private-workspace")) + } + } + + @Test("Cloud failure controls stay above native surfaces and stop intercepting input after dismissal") + func cloudFailureOwnsItsRenderedHitRegion() async throws { + let harness = try Harness() + defer { harness.tearDown() } + let window = try #require(NSApp.windows.first { + $0.identifier?.rawValue == "cmux.main.\(harness.windowId.uuidString)" + }) + let target = try #require(AppWindowChromeComposition().contentOverlayTargetResolver.installationTarget(for: window)) + let store = harness.workspace.cloudPaneCreationFailureStore + let sourcePanelID = try #require(harness.workspace.focusedPanelId) + let source = try #require(harness.workspace.terminalPanel(for: sourcePanelID)) + let request = store.beginRequest() + harness.workspace.presentCloudPaneCreationFailure( + machine: .cloud("overlay-test"), + error: CmuxTuiSurfaceProvider.ProviderError.stateUnavailable("overlay-test"), + requestID: request + ) + + func card() -> NSView? { + target.container.subviews.first { $0.identifier?.rawValue == "cmux.cloudPaneCreationFailure.card" } + } + let deadline = ContinuousClock.now + .seconds(3) + while card() == nil, ContinuousClock.now < deadline { + window.contentView?.layoutSubtreeIfNeeded() + await Task.yield() + } + let overlay = try #require(card()) + #expect(overlay.frame.width > 100 && overlay.frame.height > 50) + let terminalFrame = target.container.convert(source.hostedView.bounds, from: source.hostedView) + #expect(abs(overlay.frame.midX - terminalFrame.midX) < 2) + #expect(abs(overlay.frame.midY - terminalFrame.midY) < 2) + #expect(terminalFrame.contains(overlay.frame), "The card must not cover Bonsplit tabs or adjacent panes") + + // A browser portal installed after the card must remain underneath it. + let browserPortal = WindowBrowserPortal(window: window) + _ = browserPortal.webViewAtWindowPoint(.zero) + let nativeHosts = target.container.subviews.filter { + $0 is WindowTerminalHostView || $0 is WindowBrowserHostView + } + #expect(!nativeHosts.isEmpty) + let overlayIndex = try #require(target.container.subviews.firstIndex(of: overlay)) + for host in nativeHosts { + #expect(try #require(target.container.subviews.firstIndex(of: host)) < overlayIndex) + } + let point = overlay.convert(NSPoint(x: overlay.bounds.midX, y: overlay.bounds.midY), to: target.container.superview) + let hit = try #require(target.container.hitTest(point)) + #expect(hit === overlay || hit.isDescendant(of: overlay)) + + let outside = overlay.convert(NSPoint(x: -20, y: overlay.bounds.midY), to: target.container.superview) + if let outsideHit = target.container.hitTest(outside) { + #expect(outsideHit !== overlay && !outsideHit.isDescendant(of: overlay)) + } + + store.dismiss(id: try #require(store.failure?.id)) + let dismissDeadline = ContinuousClock.now + .seconds(3) + while card() != nil, ContinuousClock.now < dismissDeadline { + await Task.yield() + } + #expect(card() == nil) + } - #expect(workspace.closePanel(pendingPanelID, force: true)) - #expect(workspace.cloudPendingCreations[pendingPanelID] == nil) - #expect(workspace.panels[pendingPanelID] == nil) + @Test("Failure text uses the full width in narrow terminals", arguments: [CGFloat(166), 260, 360]) + func narrowFailureCardRemainsReadable(width: CGFloat) { + let failure = CloudPaneCreationFailure(machine: .cloud("narrow-pane"), error: CmuxTuiSurfaceProvider.ProviderError.stateUnavailable("narrow-pane")) + let host = NSHostingView(rootView: CloudPaneCreationFailureView(failure: failure, onRetry: {}, onDismiss: {}) + .frame(width: width).fixedSize(horizontal: false, vertical: true)) + let size = host.fittingSize + #expect(abs(size.width - width) < 1) + #expect(size.height < 210, "The detail must not be squeezed into a side column") } @Test("A retained Cloud projection never falls back to a local terminal", arguments: ["split", "tab", "splitButton"]) @@ -241,58 +354,41 @@ import Testing #expect(CloudDiagnosticFailure.classify(error).label.contains("placement")) } - @Test("Creation failure belongs to its visible workspace and stays above native content") + @Test("Creation failure belongs to its visible workspace and detaches with its anchor") func failureCardTracksWorkspaceVisibilityAndWindow() throws { let window = NSWindow(contentRect: NSRect(x: 20, y: 20, width: 720, height: 480), styleMask: [.titled, .closable], backing: .buffered, defer: false) window.isReleasedWhenClosed = false defer { window.close() } let content = try #require(window.contentView) - let root = try #require(content.superview) - let host = CloudPaneCreationFailureOverlayHostView(frame: content.bounds) - host.translatesAutoresizingMaskIntoConstraints = true - host.autoresizingMask = [.width, .height] + let target = try #require(AppWindowChromeComposition().contentOverlayTargetResolver.installationTarget(for: window)) + let root = target.container + let source = NSView(frame: content.bounds) + content.addSubview(source) + let host = CloudPaneCreationFailurePresentation.NativeOverlay.AnchorView(frame: content.bounds) + let coordinator = CloudPaneCreationFailurePresentation.NativeOverlay.Coordinator() + coordinator.anchor = host + host.coordinator = coordinator content.addSubview(host) - defer { host.detach() } - // Like WindowTerminalHostView, this sibling is above SwiftUI content. - let terminal = NSView(frame: content.frame) - root.addSubview(terminal, positioned: .above, relativeTo: content) + defer { coordinator.removeCard() } let failure = CloudPaneCreationFailure(machine: .cloud("fixture"), error: URLError(.timedOut)) - var retriedIDs: [UUID] = [] - var dismissedIDs: [UUID] = [] - host.update( - failure: failure, - onRetry: { retriedIDs.append($0) }, - onDismiss: { dismissedIDs.append($0) } - ) - root.layoutSubtreeIfNeeded() - let card = try #require(root.subviews.compactMap { $0 as? CloudPaneCreationFailureOverlayView }.first) - #expect(!card.isDescendant(of: content)) - let cardIndex = try #require(root.subviews.firstIndex(of: card)) - let terminalIndex = try #require(root.subviews.firstIndex(of: terminal)) - #expect(cardIndex > terminalIndex) - #expect(card.frame.width > 0 && card.frame.height > 0) - #expect(content.convert(card.bounds, from: card).minX >= 0) - #expect(content.convert(card.bounds, from: card).maxX <= content.bounds.maxX) - #expect(host.hitTest(NSPoint(x: 5, y: 5)) == nil, "The bridge must not intercept terminal input") - let buttons = card.subviews - .compactMap { $0 as? NSStackView } - .flatMap { $0.arrangedSubviews.compactMap { $0 as? NSButton } } - let retry = try #require(buttons.first { $0.accessibilityIdentifier() == "CloudPaneCreationFailureRetry" }) - retry.performClick(nil) - #expect(retriedIDs == [failure.id]) - let dismiss = try #require(buttons.first { $0.accessibilityIdentifier() == "CloudPaneCreationFailureDismiss" }) - dismiss.performClick(nil) - #expect(dismissedIDs == [failure.id]) - + coordinator.update(failure: failure, layoutDirection: .leftToRight, colorScheme: .light, + sourceView: source, style: .compact, onRetry: nil, onDismiss: { _ in }) + func card() -> NSView? { + root.subviews.first { $0.identifier?.rawValue == "cmux.cloudPaneCreationFailure.card" } + } + let visibleCard = try #require(card()) + #expect(!visibleCard.isDescendant(of: content)) + #expect(visibleCard.frame.width > 0 && visibleCard.frame.height > 0) + #expect(content.convert(visibleCard.bounds, from: visibleCard).minX >= 0) + #expect(content.convert(visibleCard.bounds, from: visibleCard).maxX <= content.bounds.maxX) + #expect(host.hitTest(NSPoint(x: 5, y: 5)) == nil) host.isHidden = true - host.layoutSubtreeIfNeeded() - #expect(card.superview == nil, "Switching workspaces must remove its window-level error") + #expect(card() == nil, "Switching workspaces must remove its window-level error") host.isHidden = false - host.layoutSubtreeIfNeeded() - #expect(card.superview === root) + #expect(card() != nil) host.removeFromSuperview() - #expect(card.superview == nil, "An unmounted workspace must not leave an orphan card") + #expect(card() == nil, "An unmounted workspace must not leave an orphan card") } /// Ensures a suspended older request cannot replace a newer request's failure. @@ -551,3 +647,9 @@ import Testing } } } + +private actor CloudPaneCapturedDiagnostics: CloudTelemetrySending { + private(set) var spans: [CloudTelemetrySpan] = [] + func enqueue(_ span: CloudTelemetrySpan, identity: AuthenticatedSessionIdentity) { spans.append(span) } + func clearForSignOut() { spans.removeAll() } +}