Skip to content
Merged
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
50 changes: 44 additions & 6 deletions Sources/Lithe/Application/Features/GitHubFeatureModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ final class GitHubFeatureModel: ObservableObject {
static let branchCacheLifetime: TimeInterval = 60
}

private struct PullRequestDetails {
let request: GitHubPullRequest
let files: [GitHubPullRequestFile]
let comments: [GitHubComment]
}

enum ConnectionState: Equatable {
case disconnected
case restoring
Expand Down Expand Up @@ -56,6 +62,8 @@ final class GitHubFeatureModel: ObservableObject {
private var authorizationTask: Task<Void, Never>?
private var branchLoadTask: (id: UUID, task: Task<[GitHubBranch], Error>)?
private var branchesLoadedAt: Date?
private var refreshGeneration = UUID()
private var pullRequestSelectionGeneration = UUID()

init(
service: GitHubService,
Expand Down Expand Up @@ -123,6 +131,8 @@ final class GitHubFeatureModel: ObservableObject {

func disconnect() async {
authorizationTask?.cancel()
refreshGeneration = UUID()
pullRequestSelectionGeneration = UUID()
do {
try await service.disconnect()
connectionState = .disconnected
Expand All @@ -147,14 +157,20 @@ final class GitHubFeatureModel: ObservableObject {

func refresh(workspaceURL: URL?) async {
guard case .connected = connectionState else { return }
let generation = UUID()
refreshGeneration = generation
pullRequestSelectionGeneration = UUID()
contentState = .loading
do {
let repository = try await service.resolveRepository(at: workspaceURL)
guard !Task.isCancelled, refreshGeneration == generation else { return }
let branchDefaults = try await service.resolvePullRequestBranchDefaults(at: workspaceURL)
guard !Task.isCancelled, refreshGeneration == generation else { return }
let pullRequests = try await service.listPullRequests(
repository: repository,
state: listState
)
guard !Task.isCancelled, refreshGeneration == generation else { return }
if self.repository != repository {
branches = []
branchContentState = .idle
Expand All @@ -166,13 +182,15 @@ final class GitHubFeatureModel: ObservableObject {
if let selectedNumber = selectedPullRequest?.number,
pullRequests.contains(where: { $0.number == selectedNumber }) {
await selectPullRequest(number: selectedNumber)
return
} else {
selectedPullRequest = nil
files = []
comments = []
}
contentState = .ready
} catch {
guard !Task.isCancelled, refreshGeneration == generation else { return }
contentState = .failed(error.localizedDescription)
}
}
Expand Down Expand Up @@ -284,21 +302,41 @@ final class GitHubFeatureModel: ObservableObject {

func selectPullRequest(number: UInt64) async {
guard let repository else { return }
let generation = UUID()
pullRequestSelectionGeneration = generation
isCreatingPullRequest = false
contentState = .loading
do {
async let request = service.pullRequest(repository: repository, number: number)
async let files = service.files(repository: repository, number: number)
async let comments = service.comments(repository: repository, number: number)
selectedPullRequest = try await request
self.files = try await files
self.comments = try await comments
let details = try await pullRequestDetails(repository: repository, number: number)
guard !Task.isCancelled,
pullRequestSelectionGeneration == generation,
self.repository == repository else { return }
selectedPullRequest = details.request
files = details.files
comments = details.comments
contentState = .ready
} catch {
guard !Task.isCancelled,
pullRequestSelectionGeneration == generation,
self.repository == repository else { return }
contentState = .failed(error.localizedDescription)
}
}

private func pullRequestDetails(
repository: GitHubRepository,
number: UInt64
) async throws -> PullRequestDetails {
async let request = service.pullRequest(repository: repository, number: number)
async let files = service.files(repository: repository, number: number)
async let comments = service.comments(repository: repository, number: number)
return try await PullRequestDetails(
request: request,
files: files,
comments: comments
)
}

func createPullRequest(
title: String,
body: String,
Expand Down
22 changes: 16 additions & 6 deletions Sources/Lithe/Models/AppModel/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,21 @@ final class AppModel: ObservableObject, Identifiable {
@Published private(set) var standaloneFileURL: URL?
@Published var selectedSidebar: SidebarDestination = .project {
didSet {
if selectedSidebar == .changes, oldValue != .changes {
Task { [weak self] in await self?.refreshGit() }
}
if selectedSidebar == .pullRequests, oldValue != .pullRequests {
Task { [weak self] in
guard let self else { return }
guard oldValue != selectedSidebar else { return }
sidebarRefreshTask?.cancel()
sidebarRefreshTask = nil
// Sidebar changes can happen faster than Git or GitHub can respond.
// Keep only the refresh associated with the currently visible pane.
sidebarRefreshTask = Task { @MainActor [weak self] in
guard let self else { return }
guard !Task.isCancelled else { return }
switch self.selectedSidebar {
case .changes:
await self.refreshGit()
case .pullRequests:
await self.githubFeature.refresh(workspaceURL: self.workspaceURL)
default:
break
}
}
}
Expand Down Expand Up @@ -136,6 +144,7 @@ final class AppModel: ObservableObject, Identifiable {
@Published var blameVisibleURL: URL?
@Published var gitLogSearchQuery = ""
private var shortcutDetector: (any ShortcutDetector)?
private var sidebarRefreshTask: Task<Void, Never>?
private var shortcutSettingsObservation: AnyCancellable?
private var shortcutRecordingObservation: AnyCancellable?
private var isProjectSessionActive = true
Expand Down Expand Up @@ -670,6 +679,7 @@ final class AppModel: ObservableObject, Identifiable {

deinit {
shortcutDetector?.stop()
sidebarRefreshTask?.cancel()
}

func configureProjectSession(
Expand Down
71 changes: 66 additions & 5 deletions Sources/LitheGitModule/Application/GitFeatureModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ package final class GitFeatureModel: ObservableObject {
private var acquireModuleLease: (@MainActor (String) -> ModuleLease)?
private var gitHistoryLimit = 300
private var deferredSavedChanges: GitDeferredSavedChanges?
private var refreshRequestedWhileRunning = false
private var nextRefreshRequestID: UInt64 = 0
private var activeRefreshRequestIDs: Set<UInt64> = []
private var completedRefreshRequestID: UInt64 = 0
private var refreshCompletionWaiters: [UUID: CheckedContinuation<Void, Never>] = [:]
private var loadingLineChangeURLs: Set<URL> = []
private var lineChangeHunks: [URL: [String: DiffHunk]] = [:]

Expand Down Expand Up @@ -169,7 +172,6 @@ package final class GitFeatureModel: ObservableObject {
gitDiffWhitespaceMode = .doNotIgnore
isLoadingDiff = false
isRefreshingGit = false
refreshRequestedWhileRunning = false
pendingDiscardChange = nil
pendingDiscardHunk = nil
isCommitting = false
Expand Down Expand Up @@ -204,26 +206,79 @@ package final class GitFeatureModel: ObservableObject {

package func refreshGit() async {
guard let workspaceURLProvider else { return }
guard !Task.isCancelled else { return }
nextRefreshRequestID &+= 1
let requestID = nextRefreshRequestID
activeRefreshRequestIDs.insert(requestID)
defer { activeRefreshRequestIDs.remove(requestID) }
await fulfillGitRefreshRequest(requestID, workspaceURLProvider: workspaceURLProvider)
}

private func fulfillGitRefreshRequest(
_ requestID: UInt64,
workspaceURLProvider: @MainActor () -> URL?
) async {
if isRefreshingGit {
refreshRequestedWhileRunning = true
await waitForCurrentGitRefresh()
guard !Task.isCancelled else { return }
if completedRefreshRequestID < requestID {
await fulfillGitRefreshRequest(requestID, workspaceURLProvider: workspaceURLProvider)
}
return
}
guard let workspaceURL = workspaceURLProvider() else {
reset()
completedRefreshRequestID = max(completedRefreshRequestID, requestID)
return
}

isRefreshingGit = true
defer { finishCurrentGitRefresh() }
repeat {
refreshRequestedWhileRunning = false
guard !Task.isCancelled else { return }
let processingRequestID = activeRefreshRequestIDs.max() ?? requestID
await refreshGitState(at: workspaceURL)
} while refreshRequestedWhileRunning && workspaceURLProvider() == workspaceURL
guard !Task.isCancelled else { return }
completedRefreshRequestID = max(completedRefreshRequestID, processingRequestID)
} while activeRefreshRequestIDs.contains(where: { $0 > completedRefreshRequestID })
&& workspaceURLProvider() == workspaceURL
}

private func waitForCurrentGitRefresh() async {
let waiterID = UUID()
await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
guard isRefreshingGit, !Task.isCancelled else {
continuation.resume()
return
}
refreshCompletionWaiters[waiterID] = continuation
}
} onCancel: {
Task { @MainActor [weak self] in
self?.resumeGitRefreshWaiter(waiterID)
}
}
}

private func finishCurrentGitRefresh() {
isRefreshingGit = false
let waiters = Array(refreshCompletionWaiters.values)
refreshCompletionWaiters.removeAll()
for waiter in waiters {
waiter.resume()
}
}

private func resumeGitRefreshWaiter(_ waiterID: UUID) {
refreshCompletionWaiters.removeValue(forKey: waiterID)?.resume()
}

private func refreshGitState(at workspaceURL: URL) async {
guard !Task.isCancelled else { return }
var didChange = false
if let snapshot = await snapshotProvider(workspaceURL) {
guard !Task.isCancelled else { return }
let changesChanged = gitChanges != snapshot.changes
if gitRepositoryRoot != snapshot.repositoryRoot {
gitRepositoryRoot = snapshot.repositoryRoot
Expand All @@ -246,16 +301,19 @@ package final class GitFeatureModel: ObservableObject {
didChange = didChange || previousFilter != gitConflictFilterPaths
}
let stashes = await stashesProvider(snapshot.repositoryRoot)
guard !Task.isCancelled else { return }
if gitStashes != stashes {
gitStashes = stashes
didChange = true
}
let shelves = await shelveService?.entries(for: snapshot.repositoryRoot) ?? []
guard !Task.isCancelled else { return }
if gitShelves != shelves {
gitShelves = shelves
didChange = true
}
let operationState = await operationStateProvider(snapshot.repositoryRoot)
guard !Task.isCancelled else { return }
if gitOperationState != operationState {
gitOperationState = operationState
didChange = true
Expand All @@ -275,6 +333,7 @@ package final class GitFeatureModel: ObservableObject {
didChange = true
}
let document = await diffDocumentProvider(updated, gitDiffWhitespaceMode)
guard !Task.isCancelled else { return }
if selectedDiffPatch != document.patch {
selectedDiffPatch = document.patch
diffRows = document.rows
Expand Down Expand Up @@ -304,9 +363,11 @@ package final class GitFeatureModel: ObservableObject {
}

if didChange && isGitLogVisibleProvider?() == true {
guard !Task.isCancelled else { return }
await refreshGitHistory()
}
if didChange {
guard !Task.isCancelled else { return }
await onStateRefreshed?()
}
}
Expand Down
Loading
Loading