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
105 changes: 85 additions & 20 deletions apps/swift-ios/App/NativeFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
private static let olderThreadPageUserTurnLimit = 20
private static let projectFaviconRefreshInterval: TimeInterval = 15 * 60
private static let projectFaviconFallbackMarker = "project-favicon-missing"
private static let sourceControlStatusStreamTimeoutSeconds: TimeInterval = 30

private let runtime: EnvironmentRuntime
let t3ConnectController: T3ConnectController
Expand Down Expand Up @@ -2087,6 +2088,85 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
)
}

func sourceControlStatuses(
threadID: String
) async throws -> AsyncThrowingStream<FeatureSourceControlStatus, Error> {
let route = try threadRoute(for: threadID)
let context = try workspaceContext(route: route)
let client = route.client
let environmentID = route.environmentID
let generation = environmentGeneration
let events = await client.vcsStatusEvents(cwd: context.cwd)
// Each element is a whole status, so only the newest one is ever useful.
let (statuses, continuation) = AsyncThrowingStream.makeStream(
of: FeatureSourceControlStatus.self,
bufferingPolicy: .bufferingNewest(1)
)
let task = Task { [weak self] in
// The server publishes `remoteUpdated` only when the remote
// fingerprint changes, and backs off silently when a remote refresh
// fails, so the remote half may never arrive. Bound the wait rather
// than leaving the screen loading forever, and say so instead of
// leaving the status quietly half-known.
let deadline = Task {
try? await Task.sleep(
for: .seconds(Self.sourceControlStatusStreamTimeoutSeconds)
)
guard !Task.isCancelled else { return }
continuation.finish(throwing: NativeFeatureClientError.remoteStatusUnavailable)
}
defer { deadline.cancel() }

var accumulator = NativeSourceControlStatusAccumulator()
do {
for try await event in events {
// Superseded by cancellation or an environment switch: the
// stream is over, but nothing about it was malformed, so it
// must not run the end-of-stream validation below.
guard !Task.isCancelled else {
continuation.finish()
return
}
guard let self else {
continuation.finish()
return
}
guard self.isKnownClient(
client,
environmentID: environmentID,
generation: generation
) else {
continuation.finish()
return
}
if let status = accumulator.consume(event) {
continuation.yield(status)
}
if accumulator.isComplete {
continuation.finish()
return
}
}
if Task.isCancelled {
continuation.finish()
} else {
try accumulator.validateEnd()
continuation.finish()
}
} catch is CancellationError {
continuation.finish()
} catch {
if Task.isCancelled {
continuation.finish()
} else {
continuation.finish(throwing: error)
}
}
}
continuation.onTermination = { @Sendable _ in task.cancel() }
return statuses
}

func sourceControlStatusEvents(threadID: String) -> AsyncStream<FeatureSourceControlStatus> {
let stream = AsyncStream<FeatureSourceControlStatus>.makeStream(
bufferingPolicy: .bufferingNewest(1)
Expand Down Expand Up @@ -2142,8 +2222,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
monitorID: UUID
) async {
let events = await client.vcsStatusEvents(cwd: key.workingDirectory)
var local: VCSLocalStatus?
var remote: VCSRemoteStatus?
var accumulator = NativeSourceControlStatusAccumulator()

do {
for try await event in events {
Expand All @@ -2152,24 +2231,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging,
break
}

switch event {
case let .snapshot(nextLocal, nextRemote):
local = nextLocal
remote = nextRemote
case let .localUpdated(nextLocal):
if local?.refName != nextLocal.refName {
remote = nil
}
local = nextLocal
case let .remoteUpdated(nextRemote):
remote = nextRemote
}

guard let local else { continue }
let status = NativeWorkspaceMapper.sourceControl(
local: local,
remote: remote
)
guard let status = accumulator.consume(event) else { continue }
guard let monitor = sourceControlMonitors[key],
monitor.id == monitorID,
monitor.latestStatus != status else {
Expand Down Expand Up @@ -6372,6 +6434,7 @@ private enum NativeFeatureClientError: LocalizedError {
case currentDeviceUnknown
case missingScope(String)
case tooManyAttachments
case remoteStatusUnavailable

var errorDescription: String? {
switch self {
Expand All @@ -6388,6 +6451,8 @@ private enum NativeFeatureClientError: LocalizedError {
case .currentDeviceUnknown: "This installation has not registered for device access yet."
case .missingScope: "This connection does not have permission to manage devices."
case .tooManyAttachments: "You can attach up to 8 images per message."
case .remoteStatusUnavailable:
"Couldn't check the remote status. Try reloading."
}
}
}
73 changes: 72 additions & 1 deletion apps/swift-ios/App/NativeWorkspaceMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,15 @@ enum NativeWorkspaceMapper {
files: [VCSWorkingTreeFile],
aheadCount: Int,
behindCount: Int,
pullRequest: VCSChangeRequest?
pullRequest: VCSChangeRequest?,
isRemoteKnown: Bool = true
) -> FeatureSourceControlStatus {
FeatureSourceControlStatus(
isRepository: isRepository,
branch: branch,
aheadCount: aheadCount,
behindCount: behindCount,
isRemoteKnown: isRemoteKnown,
files: files.map {
FeatureSourceControlFile(
path: $0.path,
Expand All @@ -122,6 +124,24 @@ enum NativeWorkspaceMapper {
)
}

/// The streamed counterpart, where the remote half arrives separately and
/// may still be pending.
static func sourceControl(
local: VCSLocalStatus,
remote: VCSRemoteStatus?,
isRemoteKnown: Bool
) -> FeatureSourceControlStatus {
sourceControl(
isRepository: local.isRepo,
branch: local.refName,
files: local.workingTree.files,
aheadCount: remote?.aheadCount ?? 0,
behindCount: remote?.behindCount ?? 0,
pullRequest: remote?.pr,
isRemoteKnown: isRemoteKnown
)
}

static func gitAction(_ action: FeatureSourceControlAction) -> GitStackedAction {
switch action {
case .commit: .commit
Expand Down Expand Up @@ -374,3 +394,54 @@ enum NativeWorkspaceMapper {
)
}
}

/// Folds `vcs.subscribeStatus` events into successive UI statuses. Modelled on
/// `applyGitStatusStreamEvent` in packages/shared — a snapshot replaces both
/// halves, while the last known remote half is carried across same-branch local
/// updates and discarded when the branch changes. The explicit pending state is
/// needed because this client presents partial status.
struct NativeSourceControlStatusAccumulator {
private var local: VCSLocalStatus?
private var remote: VCSRemoteStatus?
/// Tracked separately from `remote` because the remote half can legitimately
/// resolve to nil — "known to be absent" is not the same as "still pending".
private var isRemoteResolved = false
private(set) var isComplete = false

mutating func consume(_ event: VCSStatusEvent) -> FeatureSourceControlStatus? {
switch event {
case let .snapshot(nextLocal, nextRemote):
local = nextLocal
remote = nextRemote
isRemoteResolved = nextRemote != nil
case let .localUpdated(nextLocal):
if let previousLocal = local, previousLocal.refName != nextLocal.refName {
remote = nil
isRemoteResolved = false
}
local = nextLocal
case let .remoteUpdated(nextRemote):
remote = nextRemote
isRemoteResolved = true
}

guard let local else { return nil }
// A workspace with no repository or no primary remote never receives a
// remote half, so nothing is pending in those cases.
let isRemoteKnown = isRemoteResolved || !local.isRepo || !local.hasPrimaryRemote
isComplete = isRemoteKnown
return NativeWorkspaceMapper.sourceControl(
local: local,
remote: remote,
isRemoteKnown: isRemoteKnown
)
}

func validateEnd() throws {
guard isComplete else {
throw RPCError.protocolViolation(
"The source-control status stream ended before completion."
)
}
}
}
15 changes: 15 additions & 0 deletions apps/swift-ios/Features/Shared/FeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ public protocol FeatureClient: AnyObject {
) async throws -> FeatureReviewFileContents?

func sourceControlStatus(threadID: String) async throws -> FeatureSourceControlStatus
func sourceControlStatuses(
threadID: String
) async throws -> AsyncThrowingStream<FeatureSourceControlStatus, Error>
func sourceControlStatusEvents(threadID: String) -> AsyncStream<FeatureSourceControlStatus>
func performSourceControlAction(
threadID: String,
Expand Down Expand Up @@ -460,6 +463,18 @@ public extension FeatureClient {
throw FeatureCapabilityUnavailable("Source control")
}

func sourceControlStatuses(
threadID: String
) async throws -> AsyncThrowingStream<FeatureSourceControlStatus, Error> {
let status = try await sourceControlStatus(threadID: threadID)
let (stream, continuation) = AsyncThrowingStream.makeStream(
of: FeatureSourceControlStatus.self
)
continuation.yield(status)
continuation.finish()
return stream
}

func sourceControlStatusEvents(threadID: String) -> AsyncStream<FeatureSourceControlStatus> {
AsyncStream { $0.finish() }
}
Expand Down
11 changes: 9 additions & 2 deletions apps/swift-ios/Features/Shared/FeatureToolModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,9 @@ public struct FeatureSourceControlStatus: Sendable, Equatable, Codable {
public var upstream: String?
public var aheadCount: Int
public var behindCount: Int
/// `false` while the remote half of a streamed status is still pending, so
/// ahead/behind/pull-request fields are "not yet known" rather than zero.
public var isRemoteKnown: Bool
public var files: [FeatureSourceControlFile]
public var pullRequest: FeaturePullRequest?
public var isBusy: Bool
Expand All @@ -857,6 +860,7 @@ public struct FeatureSourceControlStatus: Sendable, Equatable, Codable {
upstream: String? = nil,
aheadCount: Int = 0,
behindCount: Int = 0,
isRemoteKnown: Bool = true,
files: [FeatureSourceControlFile] = [],
pullRequest: FeaturePullRequest? = nil,
isBusy: Bool = false
Expand All @@ -866,6 +870,7 @@ public struct FeatureSourceControlStatus: Sendable, Equatable, Codable {
self.upstream = upstream
self.aheadCount = aheadCount
self.behindCount = behindCount
self.isRemoteKnown = isRemoteKnown
self.files = files
self.pullRequest = pullRequest
self.isBusy = isBusy
Expand All @@ -877,13 +882,15 @@ public struct FeatureSourceControlStatus: Sendable, Equatable, Codable {
if !files.isEmpty {
actions.append(.commit)
actions.append(.commitAndPush)
if pullRequest == nil {
if isRemoteKnown, pullRequest == nil {
actions.append(.commitPushAndCreatePullRequest)
}
}
if aheadCount > 0 { actions.append(.push) }
if behindCount > 0 { actions.append(.pull) }
if pullRequest == nil { actions.append(.createPullRequest) }
// Withheld until the remote half lands: offering it against an unknown
// remote can propose a second PR for a branch that already has one.
if isRemoteKnown, pullRequest == nil { actions.append(.createPullRequest) }
return actions
}
}
Expand Down
Loading
Loading