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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2202,7 +2202,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
threadID: String,
action: FeatureSourceControlAction,
message: String?
) async throws -> FeatureSourceControlStatus {
) async throws {
let route = try threadRoute(for: threadID)
let client = route.client
let context = try workspaceContext(route: route)
Expand All @@ -2221,10 +2221,6 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
}
}
}

return NativeWorkspaceMapper.sourceControl(
try await client.refreshVCSStatus(cwd: context.cwd)
)
}

func terminalSnapshot(
Expand Down
1 change: 1 addition & 0 deletions apps/swift-ios/DesignSystem/T3Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ enum T3Typography {

enum T3Metrics {
static let minimumTapTarget: CGFloat = 44
static let maximumToolFailureMessageHeight: CGFloat = 144
static let sidebarWidth: CGFloat = 320
static let minimumSidebarWidth: CGFloat = 280
static let maximumSidebarWidth: CGFloat = 380
Expand Down
6 changes: 4 additions & 2 deletions apps/swift-ios/Features/Shared/FeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,13 @@ public protocol FeatureClient: AnyObject {

func sourceControlStatus(threadID: String) async throws -> FeatureSourceControlStatus
func sourceControlStatusEvents(threadID: String) -> AsyncStream<FeatureSourceControlStatus>
/// Completes at the mutation boundary. Callers refresh status separately so a refresh
/// failure cannot make an already-completed non-idempotent action retryable.
func performSourceControlAction(
threadID: String,
action: FeatureSourceControlAction,
message: String?
) async throws -> FeatureSourceControlStatus
) async throws

func terminalSnapshot(threadID: String, terminalID: String) async throws -> FeatureTerminalSnapshot
func terminalEvents(threadID: String, terminalID: String) -> AsyncStream<FeatureTerminalSnapshot>
Expand Down Expand Up @@ -468,7 +470,7 @@ public extension FeatureClient {
threadID: String,
action: FeatureSourceControlAction,
message: String?
) async throws -> FeatureSourceControlStatus {
) async throws {
throw FeatureCapabilityUnavailable("Source control actions")
}

Expand Down
214 changes: 214 additions & 0 deletions apps/swift-ios/Features/Shared/FeatureToolRecovery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import Foundation

/// An operation on a tool surface that can fail recoverably and be retried unchanged.
public protocol FeatureRecoverableOperation: Equatable, Sendable {
/// Headline shown on the retained failure banner, e.g. "Push failed".
var failureTitle: String { get }
/// Stable accessibility label for the Retry control, e.g. "Retry push".
var retryAccessibilityLabel: String { get }
/// Spoken confirmation once the same operation succeeds.
var recoveryAnnouncement: String { get }
}

/// Failure content retained for a tool surface. It survives the retry it triggers so the
/// useful output is never blanked while recovery is in flight.
public struct FeatureToolFailure: Identifiable, Sendable, Equatable, Hashable {
/// Distinct per presented failure so a repeat failure can move accessibility focus again.
public let id: Int
public var title: String
public var message: String
public var retryAccessibilityLabel: String
public var isRetrying: Bool

public init(
id: Int,
title: String,
message: String,
retryAccessibilityLabel: String,
isRetrying: Bool = false
) {
self.id = id
self.title = title
self.message = message
self.retryAccessibilityLabel = retryAccessibilityLabel
self.isRetrying = isRetrying
}

/// Single spoken string so VoiceOver reads the retained content when focus lands on it.
public var accessibilityLabel: String {
isRetrying ? "\(title). \(message). Retrying." : "\(title). \(message)"
}
}

/// Where accessibility focus belongs after a tool surface changes recovery state.
public enum FeatureToolRecoveryFocus: Hashable, Sendable {
/// The retained failure summary, read together with its content.
case failure
/// The first element of the recovered content.
case recoveredContent
}

/// Recovery state for one tool surface: keeps failure content visible across retries, refuses
/// to report cancellation as a failure, and names a predictable accessibility focus target.
public struct FeatureToolFailureState<Operation: FeatureRecoverableOperation>: Sendable, Equatable {
public private(set) var failure: FeatureToolFailure?
/// The exact operation to run again, including any input the failed attempt carried.
public private(set) var retryOperation: Operation?
/// Set once when an operation recovers, so the surface can announce it exactly once.
public private(set) var recoveryAnnouncement: String?
private var presentedFailureCount = 0

public init() {}

/// Marks an attempt as started. Retrying the failed operation keeps its content on screen
/// instead of blanking it; unrelated work leaves the retained failure untouched.
public mutating func begin(_ operation: Operation) {
recoveryAnnouncement = nil
guard failure != nil else { return }
failure?.isRetrying = retryOperation == operation
}

/// Records the outcome of a failed attempt. Cancellation is not a failure: it never creates
/// one and never overwrites content already on screen.
public mutating func recordFailure(_ operation: Operation, error: Error) {
guard !Self.isCancellation(error) else {
failure?.isRetrying = false
return
}
presentedFailureCount += 1
failure = FeatureToolFailure(
id: presentedFailureCount,
title: operation.failureTitle,
message: Self.message(for: error),
retryAccessibilityLabel: operation.retryAccessibilityLabel
)
retryOperation = operation
}

/// Records successful work. One completion can satisfy related retained operations, such as
/// an action whose explicit follow-up refresh also recovers an earlier load failure.
public mutating func recordSuccess(_ operations: Operation...) {
guard
failure != nil,
let recoveredOperation = retryOperation,
operations.contains(recoveredOperation)
else {
recoveryAnnouncement = nil
return
}
failure = nil
retryOperation = nil
recoveryAnnouncement = recoveredOperation.recoveryAnnouncement
}
Comment thread
saphid marked this conversation as resolved.

/// Records a follow-up failure after an operation already completed. A real failure becomes
/// the only retryable operation; cancellation stays silent and removes the completed work.
public mutating func recordFollowUpFailure(
_ followUpOperation: Operation,
afterCompletionOf completedOperation: Operation,
error: Error
) {
guard Self.isCancellation(error) else {
recordFailure(followUpOperation, error: error)
return
}
guard retryOperation == completedOperation else { return }
failure = nil
retryOperation = nil
recoveryAnnouncement = nil
}

/// Consumes the pending announcement so recovery is never spoken twice.
public mutating func takeRecoveryAnnouncement() -> String? {
defer { recoveryAnnouncement = nil }
return recoveryAnnouncement
}

public var focusTarget: FeatureToolRecoveryFocus {
failure == nil ? .recoveredContent : .failure
}

/// A dismissed sheet, a cancelled refresh, or a superseded request must not read as a failure.
public static func isCancellation(_ error: Error) -> Bool {
if error is CancellationError { return true }
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return true }
if nsError.domain == NSCocoaErrorDomain, nsError.code == NSUserCancelledError { return true }
return false
}

private static func message(for error: Error) -> String {
let described = error.localizedDescription
.trimmingCharacters(in: .whitespacesAndNewlines)
return described.isEmpty ? "The operation could not be completed." : described
}
}

/// Single-flight ownership for a tool surface. A second request cannot mutate recovery state
/// while the operation that acquired the surface is still running.
public struct FeatureToolRunState<Operation: Equatable & Sendable>: Sendable, Equatable {
public private(set) var operation: Operation?

public init() {}

public var isBusy: Bool {
operation != nil
}

public mutating func begin(_ operation: Operation) -> Bool {
guard self.operation == nil else { return false }
self.operation = operation
return true
}

public mutating func finish(_ operation: Operation) {
guard self.operation == operation else { return }
self.operation = nil
}
}

/// The retryable work of the source control surface. `action` carries the commit message so a
/// retry never asks for it again.
public enum FeatureSourceControlOperation: FeatureRecoverableOperation {
case load
case action(FeatureSourceControlAction, message: String?)

public var isLoad: Bool {
if case .load = self { return true }
return false
}

public var failureTitle: String {
switch self {
case .load: "Repository status failed to load"
case .action(let action, _): "\(action.title) failed"
}
}

public var retryAccessibilityLabel: String {
switch self {
case .load: "Retry loading repository status"
case .action(let action, _): "Retry \(action.title.lowercased())"
}
}

public var recoveryAnnouncement: String {
switch self {
case .load: "Repository status loaded."
case .action(let action, _): "\(action.title) succeeded. Repository status updated."
}
}
}

public extension FeatureSourceControlAction {
var title: String {
switch self {
case .commit: "Commit changes"
case .push: "Push"
case .pull: "Pull latest"
case .createPullRequest: "Create pull request"
case .commitAndPush: "Commit and push"
case .commitPushAndCreatePullRequest: "Commit, push, and create PR"
}
}
}
Loading
Loading