diff --git a/supacode/Clients/Github/GithubPullRequest.swift b/supacode/Clients/Github/GithubPullRequest.swift index c06e0b97..ff3bd51b 100644 --- a/supacode/Clients/Github/GithubPullRequest.swift +++ b/supacode/Clients/Github/GithubPullRequest.swift @@ -1,6 +1,6 @@ import Foundation -nonisolated struct GithubPullRequest: Decodable, Equatable { +nonisolated struct GithubPullRequest: Decodable, Equatable, Hashable { let number: Int let title: String let state: String diff --git a/supacode/Clients/Github/GithubPullRequestStatusCheck.swift b/supacode/Clients/Github/GithubPullRequestStatusCheck.swift index 23b08d30..f6ef5d0f 100644 --- a/supacode/Clients/Github/GithubPullRequestStatusCheck.swift +++ b/supacode/Clients/Github/GithubPullRequestStatusCheck.swift @@ -41,7 +41,7 @@ nonisolated struct PullRequestCheckSummary: Equatable { } } -nonisolated struct GithubPullRequestStatusCheck: Decodable, Equatable { +nonisolated struct GithubPullRequestStatusCheck: Decodable, Equatable, Hashable { let status: String? let conclusion: String? let state: String? @@ -78,7 +78,7 @@ nonisolated struct GithubPullRequestStatusCheck: Decodable, Equatable { } } -nonisolated struct GithubPullRequestStatusCheckRollup: Decodable, Equatable { +nonisolated struct GithubPullRequestStatusCheckRollup: Decodable, Equatable, Hashable { let checks: [GithubPullRequestStatusCheck] init(from decoder: Decoder) throws { diff --git a/supacode/Domain/WorktreeInfoEntry.swift b/supacode/Domain/WorktreeInfoEntry.swift index 538a99be..68914332 100644 --- a/supacode/Domain/WorktreeInfoEntry.swift +++ b/supacode/Domain/WorktreeInfoEntry.swift @@ -3,9 +3,9 @@ import Foundation struct WorktreeInfoEntry: Equatable, Hashable { var addedLines: Int? var removedLines: Int? - var pullRequestNumber: Int? + var pullRequest: GithubPullRequest? var isEmpty: Bool { - addedLines == nil && removedLines == nil && pullRequestNumber == nil + addedLines == nil && removedLines == nil && pullRequest == nil } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 46530ca3..9c13ab08 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -113,7 +113,7 @@ struct AppFeature { state.openActionSelection = .finder state.selectedRunScript = "" return .merge( - .send(.worktreeInfo(.worktreeChanged(nil))), + .send(.worktreeInfo(.worktreeChanged(nil, cachedPullRequest: nil))), .run { _ in await terminalClient.send(.setSelectedWorktreeID(nil)) }, @@ -125,8 +125,9 @@ struct AppFeature { let settings = repositorySettingsClient.load(worktree.repositoryRootURL) state.openActionSelection = OpenWorktreeAction.fromSettingsID(settings.openActionID) state.selectedRunScript = settings.runScript + let cachedPullRequest = state.repositories.worktreeInfoByID[worktree.id]?.pullRequest return .merge( - .send(.worktreeInfo(.worktreeChanged(worktree))), + .send(.worktreeInfo(.worktreeChanged(worktree, cachedPullRequest: cachedPullRequest))), .run { _ in await terminalClient.send(.setSelectedWorktreeID(worktree.id)) await terminalClient.send(.clearNotificationIndicator(worktree)) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index bf7dcb9b..dad20fd6 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -68,7 +68,7 @@ struct RepositoriesFeature { case worktreeInfoEvent(WorktreeInfoWatcherClient.Event) case worktreeBranchNameLoaded(worktreeID: Worktree.ID, name: String) case worktreeLineChangesLoaded(worktreeID: Worktree.ID, added: Int, removed: Int) - case worktreePullRequestLoaded(worktreeID: Worktree.ID, number: Int?) + case worktreePullRequestLoaded(worktreeID: Worktree.ID, pullRequest: GithubPullRequest?) case alert(PresentationAction) case delegate(Delegate) } @@ -549,7 +549,7 @@ struct RepositoriesFeature { switch result { case .success(let pullRequest): await send( - .worktreePullRequestLoaded(worktreeID: worktreeID, number: pullRequest?.number) + .worktreePullRequestLoaded(worktreeID: worktreeID, pullRequest: pullRequest) ) case .failure: return @@ -570,10 +570,10 @@ struct RepositoriesFeature { ) return .none - case .worktreePullRequestLoaded(let worktreeID, let number): + case .worktreePullRequestLoaded(let worktreeID, let pullRequest): updateWorktreePullRequest( worktreeID: worktreeID, - number: number, + pullRequest: pullRequest, state: &state ) return .none @@ -1005,11 +1005,11 @@ private func updateWorktreeLineChanges( private func updateWorktreePullRequest( worktreeID: Worktree.ID, - number: Int?, + pullRequest: GithubPullRequest?, state: inout RepositoriesFeature.State ) { var entry = state.worktreeInfoByID[worktreeID] ?? WorktreeInfoEntry() - entry.pullRequestNumber = number + entry.pullRequest = pullRequest if entry.isEmpty { state.worktreeInfoByID.removeValue(forKey: worktreeID) } else { diff --git a/supacode/Features/Repositories/Views/WorktreeRow.swift b/supacode/Features/Repositories/Views/WorktreeRow.swift index dc0d04ed..f72c26f8 100644 --- a/supacode/Features/Repositories/Views/WorktreeRow.swift +++ b/supacode/Features/Repositories/Views/WorktreeRow.swift @@ -13,7 +13,7 @@ struct WorktreeRow: View { var body: some View { let showsSpinner = isLoading || taskStatus == .running let branchIconName = isMainWorktree ? "star.fill" : (isPinned ? "pin.fill" : "arrow.triangle.branch") - let hasInfo = info?.addedLines != nil || info?.removedLines != nil || info?.pullRequestNumber != nil + let hasInfo = info?.addedLines != nil || info?.removedLines != nil || info?.pullRequest != nil HStack(alignment: .center) { ZStack { if showsNotificationIndicator { @@ -76,7 +76,7 @@ private struct WorktreeRowInfoView: View { .foregroundStyle(.red) } } - if let info, let pullRequestNumber = info.pullRequestNumber { + if let info, let pullRequestNumber = info.pullRequest?.number { Text("PR: \(pullRequestNumber)") .foregroundStyle(.secondary) } diff --git a/supacode/Features/WorktreeInfo/Reducer/WorktreeInfoFeature.swift b/supacode/Features/WorktreeInfo/Reducer/WorktreeInfoFeature.swift index c5e9d5d7..4b4a510b 100644 --- a/supacode/Features/WorktreeInfo/Reducer/WorktreeInfoFeature.swift +++ b/supacode/Features/WorktreeInfo/Reducer/WorktreeInfoFeature.swift @@ -11,11 +11,12 @@ struct WorktreeInfoFeature { var nextRefresh: Date? var cachedSnapshots: [Worktree.ID: WorktreeInfoSnapshot] = [:] var cachedNextRefreshDates: [Worktree.ID: Date] = [:] + var cachedPullRequest: GithubPullRequest? } enum Action: Equatable { case task - case worktreeChanged(Worktree?) + case worktreeChanged(Worktree?, cachedPullRequest: GithubPullRequest?) case refresh case refreshFinished(Result) case timerTick @@ -31,10 +32,27 @@ struct WorktreeInfoFeature { case .task: return .none - case .worktreeChanged(let worktree): + case .worktreeChanged(let worktree, let cachedPullRequest): state.worktree = worktree + state.cachedPullRequest = cachedPullRequest if let worktree { - state.snapshot = state.cachedSnapshots[worktree.id] + if let cachedSnapshot = state.cachedSnapshots[worktree.id] { + if let cachedPullRequest { + state.snapshot = snapshotByReplacingPullRequest( + snapshot: cachedSnapshot, + pullRequest: cachedPullRequest + ) + } else { + state.snapshot = cachedSnapshot + } + } else if let cachedPullRequest { + state.snapshot = snapshotFromCachedPullRequest( + worktree: worktree, + pullRequest: cachedPullRequest + ) + } else { + state.snapshot = nil + } state.nextRefresh = state.cachedNextRefreshDates[worktree.id] state.status = state.snapshot == nil ? .loading : .idle } else { @@ -58,10 +76,12 @@ struct WorktreeInfoFeature { guard let worktree = state.worktree else { return .none } state.status = .loading let githubCLI = githubCLI + let cachedPullRequest = state.cachedPullRequest return .run { send in let result: Result = await Result { try await loadWorktreeInfoSnapshot( worktree: worktree, + cachedPullRequest: cachedPullRequest, githubCLI: githubCLI ) }.mapError { error in @@ -113,6 +133,7 @@ struct WorktreeInfoFeature { nonisolated private func loadWorktreeInfoSnapshot( worktree: Worktree, + cachedPullRequest: GithubPullRequest?, githubCLI: GithubCLIClient ) async throws -> WorktreeInfoSnapshot { let repoRoot = worktree.repositoryRootURL @@ -141,26 +162,28 @@ nonisolated private func loadWorktreeInfoSnapshot( var workflowConclusion: String? var workflowUpdatedAt: Date? - var pullRequestNumber: Int? - var pullRequestTitle: String? - var pullRequestURL: String? - var pullRequestState: String? - var pullRequestIsDraft = false - var pullRequestReviewDecision: String? - var pullRequestUpdatedAt: Date? - var pullRequestStatusChecks: [GithubPullRequestStatusCheck] = [] + let cachedPullRequestInfo = pullRequestDetails(cachedPullRequest) + var pullRequestNumber = cachedPullRequestInfo.number + var pullRequestTitle = cachedPullRequestInfo.title + var pullRequestURL = cachedPullRequestInfo.url + var pullRequestState = cachedPullRequestInfo.state + var pullRequestIsDraft = cachedPullRequestInfo.isDraft + var pullRequestReviewDecision = cachedPullRequestInfo.reviewDecision + var pullRequestUpdatedAt = cachedPullRequestInfo.updatedAt + var pullRequestStatusChecks = cachedPullRequestInfo.statusChecks - if githubAvailable { + if cachedPullRequest == nil, githubAvailable { do { if let pullRequest = try await githubCLI.currentPullRequest(worktreeRoot) { - pullRequestNumber = pullRequest.number - pullRequestTitle = pullRequest.title - pullRequestURL = pullRequest.url - pullRequestState = pullRequest.state - pullRequestIsDraft = pullRequest.isDraft - pullRequestReviewDecision = pullRequest.reviewDecision - pullRequestUpdatedAt = pullRequest.updatedAt - pullRequestStatusChecks = pullRequest.statusCheckRollup?.checks ?? [] + let pullRequestInfo = pullRequestDetails(pullRequest) + pullRequestNumber = pullRequestInfo.number + pullRequestTitle = pullRequestInfo.title + pullRequestURL = pullRequestInfo.url + pullRequestState = pullRequestInfo.state + pullRequestIsDraft = pullRequestInfo.isDraft + pullRequestReviewDecision = pullRequestInfo.reviewDecision + pullRequestUpdatedAt = pullRequestInfo.updatedAt + pullRequestStatusChecks = pullRequestInfo.statusChecks } } catch { githubError = githubError ?? error.localizedDescription @@ -201,3 +224,102 @@ nonisolated private func loadWorktreeInfoSnapshot( ciError: ciError ) } + +nonisolated private func snapshotFromCachedPullRequest( + worktree: Worktree, + pullRequest: GithubPullRequest +) -> WorktreeInfoSnapshot { + let repoRoot = worktree.repositoryRootURL + let repositoryName = repoRoot.lastPathComponent + let repositoryPath = repoRoot.path(percentEncoded: false) + let worktreePath = worktree.workingDirectory.path(percentEncoded: false) + let pullRequestInfo = pullRequestDetails(pullRequest) + + return WorktreeInfoSnapshot( + repositoryName: repositoryName, + repositoryPath: repositoryPath, + worktreePath: worktreePath, + defaultBranchName: nil, + pullRequestNumber: pullRequestInfo.number, + pullRequestTitle: pullRequestInfo.title, + pullRequestURL: pullRequestInfo.url, + pullRequestState: pullRequestInfo.state, + pullRequestIsDraft: pullRequestInfo.isDraft, + pullRequestReviewDecision: pullRequestInfo.reviewDecision, + pullRequestUpdatedAt: pullRequestInfo.updatedAt, + pullRequestStatusChecks: pullRequestInfo.statusChecks, + workflowName: nil, + workflowStatus: nil, + workflowConclusion: nil, + workflowUpdatedAt: nil, + githubError: nil, + ciError: nil + ) +} + +nonisolated private func snapshotByReplacingPullRequest( + snapshot: WorktreeInfoSnapshot, + pullRequest: GithubPullRequest +) -> WorktreeInfoSnapshot { + let pullRequestInfo = pullRequestDetails(pullRequest) + + return WorktreeInfoSnapshot( + repositoryName: snapshot.repositoryName, + repositoryPath: snapshot.repositoryPath, + worktreePath: snapshot.worktreePath, + defaultBranchName: snapshot.defaultBranchName, + pullRequestNumber: pullRequestInfo.number, + pullRequestTitle: pullRequestInfo.title, + pullRequestURL: pullRequestInfo.url, + pullRequestState: pullRequestInfo.state, + pullRequestIsDraft: pullRequestInfo.isDraft, + pullRequestReviewDecision: pullRequestInfo.reviewDecision, + pullRequestUpdatedAt: pullRequestInfo.updatedAt, + pullRequestStatusChecks: pullRequestInfo.statusChecks, + workflowName: snapshot.workflowName, + workflowStatus: snapshot.workflowStatus, + workflowConclusion: snapshot.workflowConclusion, + workflowUpdatedAt: snapshot.workflowUpdatedAt, + githubError: snapshot.githubError, + ciError: snapshot.ciError + ) +} + +private struct PullRequestDetails: Equatable { + let number: Int? + let title: String? + let url: String? + let state: String? + let isDraft: Bool + let reviewDecision: String? + let updatedAt: Date? + let statusChecks: [GithubPullRequestStatusCheck] +} + +nonisolated private func pullRequestDetails( + _ pullRequest: GithubPullRequest? +) -> PullRequestDetails { + guard let pullRequest else { + return PullRequestDetails( + number: nil, + title: nil, + url: nil, + state: nil, + isDraft: false, + reviewDecision: nil, + updatedAt: nil, + statusChecks: [] + ) + } + + return PullRequestDetails( + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + reviewDecision: pullRequest.reviewDecision, + updatedAt: pullRequest.updatedAt, + statusChecks: pullRequest.statusCheckRollup?.checks ?? [] + ) +}