Skip to content
28 changes: 17 additions & 11 deletions macgit/App/CreatePullRequestWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ struct CreatePullRequestWindowContent: View {
let onCancel: () -> Void
let onCreate: (PullRequestDraft) async -> Void
@State private var isSubmitting = false
@State private var sourceBranchForChanges: String?
@State private var targetBranchForChanges: String?

var body: some View {
CreatePullRequestView(
Expand All @@ -109,17 +111,8 @@ struct CreatePullRequestWindowContent: View {
onGenerateText: onGenerateText,
onCancel: onCancel,
onBranchesChanged: { sourceBranch, targetBranch in
Task {
guard await authorizeAction() else { return }
if let targetBranch {
await controller.loadCreateDraftChanges(
sourceBranch: sourceBranch,
targetBranch: targetBranch
)
} else {
controller.resetCreateDraftChanges()
}
}
sourceBranchForChanges = sourceBranch
targetBranchForChanges = targetBranch
},
onCreate: { draft in
guard !isSubmitting else { return }
Expand All @@ -132,6 +125,19 @@ struct CreatePullRequestWindowContent: View {
isPresentedInWindow: true,
cancelRequestID: windowController.cancelRequestID
)
.task(id: [sourceBranchForChanges, targetBranchForChanges]) {
guard let sourceBranch = sourceBranchForChanges else { return }
guard await authorizeAction(), !Task.isCancelled else { return }
if let targetBranch = targetBranchForChanges {
await controller.loadCreateDraftChanges(sourceBranch: sourceBranch, targetBranch: targetBranch)
} else {
controller.resetCreateDraftChanges()
}
}
.task {
guard await authorizeAction(), !Task.isCancelled else { return }
await controller.loadCreateDraftParticipants()
}
.overlay {
if isSubmitting || controller.isPerformingAction {
ZStack {
Expand Down
24 changes: 15 additions & 9 deletions macgit/App/PullRequestController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,14 @@ final class PullRequestController: ObservableObject {
} else {
await remoteNameProvider(repositoryURL)
}
guard let remoteName,
let remoteURLString = await remoteURLProvider(repositoryURL, remoteName) else {
guard !Task.isCancelled else { return }
guard let remoteName else {
detailErrorMessage = "No remotes configured."
return
}
let resolvedRemoteURL = await remoteURLProvider(repositoryURL, remoteName)
guard !Task.isCancelled else { return }
guard let remoteURLString = resolvedRemoteURL else {
detailErrorMessage = "No remotes configured."
return
}
Expand Down Expand Up @@ -339,7 +345,7 @@ final class PullRequestController: ObservableObject {
activeToken = apiCredential.token
selectedProviderAccountID = apiCredential.account.id
accountConnectionHost = nil
await presentCreatePullRequest(sourceBranch: sourceBranch)
await presentCreatePullRequest(sourceBranch: sourceBranch, loadParticipants: false)
}

func loadPullRequests(
Expand Down Expand Up @@ -672,7 +678,7 @@ final class PullRequestController: ObservableObject {
_ = openURL(detail.changesURL)
}

func presentCreatePullRequest(sourceBranch requestedSourceBranch: String? = nil) async {
func presentCreatePullRequest(sourceBranch requestedSourceBranch: String? = nil, loadParticipants: Bool = true) async {
guard let repository = activeRepository,
let repositoryURL = activeRepositoryURL else {
detailErrorMessage = "Pull request creation is unavailable."
Expand All @@ -681,6 +687,7 @@ final class PullRequestController: ObservableObject {

let currentBranch = await currentBranchProvider(repositoryURL)
let defaultBranch = await defaultBranchProvider(repositoryURL)
guard !Task.isCancelled else { return }
let normalizedRequestedSource = requestedSourceBranch?
.trimmingCharacters(in: .whitespacesAndNewlines)
let sourceBranch = (normalizedRequestedSource?.isEmpty == false ? normalizedRequestedSource : nil)
Expand All @@ -698,8 +705,7 @@ final class PullRequestController: ObservableObject {
targetBranch: defaultBranch,
suggestedTitle: suggestedTitle(for: sourceBranch)
)
async let participantsLoad: Void = loadCreateDraftParticipants()
_ = await participantsLoad
if loadParticipants { await loadCreateDraftParticipants() }
}

func loadCreateDraftParticipants() async {
Expand All @@ -720,16 +726,16 @@ final class PullRequestController: ObservableObject {
repository: repository,
token: token
)
guard createDraftParticipantsLoadID == loadID else { return }
guard !Task.isCancelled, createDraftParticipantsLoadID == loadID else { return }
createDraftParticipants = participants.sorted {
$0.username.localizedCaseInsensitiveCompare($1.username) == .orderedAscending
}
isLoadingCreateDraftParticipants = false
} catch is CancellationError {
guard createDraftParticipantsLoadID == loadID else { return }
guard !Task.isCancelled, createDraftParticipantsLoadID == loadID else { return }
isLoadingCreateDraftParticipants = false
} catch {
guard createDraftParticipantsLoadID == loadID else { return }
guard !Task.isCancelled, createDraftParticipantsLoadID == loadID else { return }
createDraftParticipantsErrorMessage = error.localizedDescription
isLoadingCreateDraftParticipants = false
}
Expand Down
7 changes: 6 additions & 1 deletion macgit/Models/GitFlowCloudConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import Foundation
struct GitFlowCloudConfiguration: Equatable {
static let schemaVersion = 1

var roleDisplayMode: GitFlowRoleDisplayMode
var canonicalKey: String
var isEnabled: Bool
var mainBranch: String
Expand All @@ -35,6 +36,7 @@ struct GitFlowCloudConfiguration: Equatable {

init(configuration: GitFlowConfiguration, canonicalKey: String) {
let normalized = configuration.normalized()
roleDisplayMode = normalized.roleDisplayMode
self.canonicalKey = canonicalKey
isEnabled = normalized.isEnabled
mainBranch = normalized.mainBranch
Expand All @@ -59,8 +61,10 @@ struct GitFlowCloudConfiguration: Equatable {
hotfixPrefix: String,
topicFinishStrategy: GitFlowTopicFinishStrategy,
createReleaseTagOnFinish: Bool,
createHotfixTagOnFinish: Bool
createHotfixTagOnFinish: Bool,
roleDisplayMode: GitFlowRoleDisplayMode = .full
) {
self.roleDisplayMode = roleDisplayMode
self.canonicalKey = canonicalKey
self.isEnabled = isEnabled
self.mainBranch = mainBranch
Expand All @@ -76,6 +80,7 @@ struct GitFlowCloudConfiguration: Equatable {

func applying(to localConfiguration: GitFlowConfiguration) -> GitFlowConfiguration {
GitFlowConfiguration(
roleDisplayMode: roleDisplayMode,
isEnabled: isEnabled,
mainBranch: mainBranch,
developBranch: developBranch,
Expand Down
5 changes: 5 additions & 0 deletions macgit/Models/GitFlowConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import Foundation
struct GitFlowConfiguration: Codable, Equatable {
static let supportedSchemaVersion = 1

var roleDisplayMode: GitFlowRoleDisplayMode
var schemaVersion: Int
var isEnabled: Bool
var mainBranch: String
Expand All @@ -37,6 +38,7 @@ struct GitFlowConfiguration: Codable, Equatable {
var createHotfixTagOnFinish: Bool

init(
roleDisplayMode: GitFlowRoleDisplayMode = .full,
schemaVersion: Int = GitFlowConfiguration.supportedSchemaVersion,
isEnabled: Bool = false,
mainBranch: String = "main",
Expand All @@ -54,6 +56,7 @@ struct GitFlowConfiguration: Codable, Equatable {
) {
self.deleteLocalBranchOnFinish = deleteLocalBranchOnFinish
self.removeLocalWorktreeOnFinish = removeLocalWorktreeOnFinish
self.roleDisplayMode = roleDisplayMode
self.schemaVersion = schemaVersion
self.isEnabled = isEnabled
self.mainBranch = mainBranch
Expand All @@ -72,6 +75,7 @@ struct GitFlowConfiguration: Codable, Equatable {
let container = try decoder.container(keyedBy: CodingKeys.self)
deleteLocalBranchOnFinish = try container.decodeIfPresent(Bool.self, forKey: .deleteLocalBranchOnFinish) ?? true
removeLocalWorktreeOnFinish = try container.decodeIfPresent(Bool.self, forKey: .removeLocalWorktreeOnFinish) ?? false
roleDisplayMode = try container.decodeIfPresent(GitFlowRoleDisplayMode.self, forKey: .roleDisplayMode) ?? .full
schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false
mainBranch = try container.decodeIfPresent(String.self, forKey: .mainBranch) ?? "main"
Expand Down Expand Up @@ -125,6 +129,7 @@ struct GitFlowConfiguration: Codable, Equatable {

func normalized() -> GitFlowConfiguration {
GitFlowConfiguration(
roleDisplayMode: roleDisplayMode,
schemaVersion: Self.supportedSchemaVersion,
isEnabled: isEnabled,
mainBranch: mainBranch.trimmingCharacters(in: .whitespacesAndNewlines),
Expand Down
17 changes: 17 additions & 0 deletions macgit/Models/GitFlowRoleDisplayMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

enum GitFlowRoleDisplayMode: String, Codable, CaseIterable, Identifiable {
case full
case initial
case hidden

var id: String { rawValue }
var displayName: String {
switch self {
case .full: "Full name"
case .initial: "First letter"
case .hidden: "Hidden"
}
}
}
8 changes: 8 additions & 0 deletions macgit/Models/RepositorySheetRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation

/// Stores user intent before any asynchronous preparation starts.
struct RepositorySheetRequest<Input>: Identifiable {
let id = UUID()
let input: Input
}
18 changes: 16 additions & 2 deletions macgit/Services/FirestoreGitFlowConfigurationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ enum GitFlowCloudConfigurationDocumentError: LocalizedError {

enum GitFlowCloudConfigurationDocument {
private static let allowedKeys: Set<String> = [
"roleDisplayMode",
"schemaVersion",
"canonicalKey",
"isEnabled",
Expand All @@ -49,6 +50,7 @@ enum GitFlowCloudConfigurationDocument {
updatedAt: Any
) -> [String: Any] {
[
"roleDisplayMode": configuration.roleDisplayMode.rawValue,
"schemaVersion": GitFlowCloudConfiguration.schemaVersion,
"canonicalKey": configuration.canonicalKey,
"isEnabled": configuration.isEnabled,
Expand All @@ -67,7 +69,8 @@ enum GitFlowCloudConfigurationDocument {

static func decode(_ data: [String: Any]?) throws -> GitFlowCloudConfiguration {
guard let data,
Set(data.keys) == allowedKeys,
Set(data.keys).isSubset(of: allowedKeys),
Set(data.keys).isSuperset(of: allowedKeys.subtracting(["roleDisplayMode"])),
let schemaVersion = data["schemaVersion"] as? Int,
schemaVersion == GitFlowCloudConfiguration.schemaVersion,
let canonicalKey = data["canonicalKey"] as? String,
Expand All @@ -89,6 +92,16 @@ enum GitFlowCloudConfigurationDocument {
throw GitFlowCloudConfigurationDocumentError.invalidDocument
}

let roleDisplayMode: GitFlowRoleDisplayMode
if let rawValue = data["roleDisplayMode"] {
guard let value = rawValue as? String, let mode = GitFlowRoleDisplayMode(rawValue: value) else {
throw GitFlowCloudConfigurationDocumentError.invalidDocument
}
roleDisplayMode = mode
} else {
roleDisplayMode = .full
}

return GitFlowCloudConfiguration(
canonicalKey: canonicalKey,
isEnabled: isEnabled,
Expand All @@ -100,7 +113,8 @@ enum GitFlowCloudConfigurationDocument {
hotfixPrefix: hotfixPrefix,
topicFinishStrategy: topicFinishStrategy,
createReleaseTagOnFinish: createReleaseTagOnFinish,
createHotfixTagOnFinish: createHotfixTagOnFinish
createHotfixTagOnFinish: createHotfixTagOnFinish,
roleDisplayMode: roleDisplayMode
)
}
}
Expand Down
11 changes: 8 additions & 3 deletions macgit/Services/SyncState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -810,14 +810,16 @@ class SyncState: ObservableObject {
}
}

@discardableResult
func performCommit(
message: String,
repositoryURL: URL,
undoManager: GitUndoManager? = nil,
noVerify: Bool = false,
signOff: Bool = false,
commitAllChanges: Bool = false
) async {
commitAllChanges: Bool = false,
onError: (@MainActor (String) -> Void)? = nil
) async -> Bool {
await MainActor.run { isCommitting = true }
defer { Task { @MainActor in isCommitting = false } }
do {
Expand Down Expand Up @@ -848,8 +850,11 @@ class SyncState: ObservableObject {
}
await refresh(repositoryURL: repositoryURL)
notifyRepositoryChanged(repositoryURL)
return true
} catch {
showError(error.localizedDescription)
if let onError { onError(error.localizedDescription) }
else { showError(error.localizedDescription) }
return false
}
}

Expand Down
6 changes: 4 additions & 2 deletions macgit/ViewModels/HistoryListModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,15 @@ final class HistoryListModel {
} else {
hash = await GitStatusService.shared.tipHash(for: reference, in: repositoryURL)
}
guard generation == requestedGeneration, loadKey == requestedKey,
guard !Task.isCancelled, generation == requestedGeneration, loadKey == requestedKey,
selectedBranch == reference, let hash, let tip = commit(hash) else { return false }
select(tip, focus: true)
select(tip, focus: false)
storeSnapshot()
return true
}

func suspendLoading() { invalidateLoad() }

func clearCache() { cache.removeAll() }

func setPageSize(_ size: Int) {
Expand Down
37 changes: 36 additions & 1 deletion macgit/ViewModels/RepositoryLFSController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,44 @@ final class RepositoryLFSController {
} catch { self.error = error.localizedDescription }
}

func prepareRuleRemoval(pattern: String) async throws -> GitLFSTrackingReview {
guard await authorizeAction() else {
throw GitError.commandFailed("Access to Git LFS is unavailable.")
}
try Task.checkCancellation()
let review = try await GitStatusService.shared.reviewLFSTracking(
pattern: pattern, literal: false, removing: true, in: repository
)
try Task.checkCancellation()
return review
}

func removeRule(review: GitLFSTrackingReview, refresh: @escaping @MainActor () async -> Void) async throws {
guard operation == nil, operationLabel == nil else {
throw GitError.commandFailed("Wait for the current Git LFS operation to finish.")
}
operationLabel = "Removing tracking rule…"
defer { operationLabel = nil }
guard await authorizeAction() else {
throw GitError.commandFailed("Access to Git LFS is unavailable.")
}
try Task.checkCancellation()
do {
try await GitStatusService.shared.applyLFSTracking(review, in: repository)
notice = "Completed. Review any changes in File Status before committing."
error = nil
} catch {
await refresh()
await load()
throw error
}
await refresh()
await load()
}

func perform(_ label: String, refresh: @escaping @MainActor () async -> Void,
action: @escaping @Sendable () async throws -> Void) {
guard operation == nil else { return }
guard operation == nil, operationLabel == nil else { return }
operationLabel = label
transferProgress = nil
error = nil
Expand Down
Loading
Loading