diff --git a/macgit/App/CreatePullRequestWindowController.swift b/macgit/App/CreatePullRequestWindowController.swift
index c3241261..9e430998 100644
--- a/macgit/App/CreatePullRequestWindowController.swift
+++ b/macgit/App/CreatePullRequestWindowController.swift
@@ -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(
@@ -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 }
@@ -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 {
diff --git a/macgit/App/PullRequestController.swift b/macgit/App/PullRequestController.swift
index f952f314..c5b77e99 100644
--- a/macgit/App/PullRequestController.swift
+++ b/macgit/App/PullRequestController.swift
@@ -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
}
@@ -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(
@@ -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."
@@ -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)
@@ -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 {
@@ -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
}
diff --git a/macgit/Models/GitFlowCloudConfiguration.swift b/macgit/Models/GitFlowCloudConfiguration.swift
index 02d28cd4..80f1ed1c 100644
--- a/macgit/Models/GitFlowCloudConfiguration.swift
+++ b/macgit/Models/GitFlowCloudConfiguration.swift
@@ -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
@@ -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
@@ -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
@@ -76,6 +80,7 @@ struct GitFlowCloudConfiguration: Equatable {
func applying(to localConfiguration: GitFlowConfiguration) -> GitFlowConfiguration {
GitFlowConfiguration(
+ roleDisplayMode: roleDisplayMode,
isEnabled: isEnabled,
mainBranch: mainBranch,
developBranch: developBranch,
diff --git a/macgit/Models/GitFlowConfiguration.swift b/macgit/Models/GitFlowConfiguration.swift
index 9ff11d2f..478d8a3b 100644
--- a/macgit/Models/GitFlowConfiguration.swift
+++ b/macgit/Models/GitFlowConfiguration.swift
@@ -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
@@ -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",
@@ -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
@@ -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"
@@ -125,6 +129,7 @@ struct GitFlowConfiguration: Codable, Equatable {
func normalized() -> GitFlowConfiguration {
GitFlowConfiguration(
+ roleDisplayMode: roleDisplayMode,
schemaVersion: Self.supportedSchemaVersion,
isEnabled: isEnabled,
mainBranch: mainBranch.trimmingCharacters(in: .whitespacesAndNewlines),
diff --git a/macgit/Models/GitFlowRoleDisplayMode.swift b/macgit/Models/GitFlowRoleDisplayMode.swift
new file mode 100644
index 00000000..3dc2e3a3
--- /dev/null
+++ b/macgit/Models/GitFlowRoleDisplayMode.swift
@@ -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"
+ }
+ }
+}
diff --git a/macgit/Models/RepositorySheetRequest.swift b/macgit/Models/RepositorySheetRequest.swift
new file mode 100644
index 00000000..f3637de2
--- /dev/null
+++ b/macgit/Models/RepositorySheetRequest.swift
@@ -0,0 +1,8 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import Foundation
+
+/// Stores user intent before any asynchronous preparation starts.
+struct RepositorySheetRequest: Identifiable {
+ let id = UUID()
+ let input: Input
+}
diff --git a/macgit/Services/FirestoreGitFlowConfigurationStore.swift b/macgit/Services/FirestoreGitFlowConfigurationStore.swift
index e78f7fad..2f5d934b 100644
--- a/macgit/Services/FirestoreGitFlowConfigurationStore.swift
+++ b/macgit/Services/FirestoreGitFlowConfigurationStore.swift
@@ -29,6 +29,7 @@ enum GitFlowCloudConfigurationDocumentError: LocalizedError {
enum GitFlowCloudConfigurationDocument {
private static let allowedKeys: Set = [
+ "roleDisplayMode",
"schemaVersion",
"canonicalKey",
"isEnabled",
@@ -49,6 +50,7 @@ enum GitFlowCloudConfigurationDocument {
updatedAt: Any
) -> [String: Any] {
[
+ "roleDisplayMode": configuration.roleDisplayMode.rawValue,
"schemaVersion": GitFlowCloudConfiguration.schemaVersion,
"canonicalKey": configuration.canonicalKey,
"isEnabled": configuration.isEnabled,
@@ -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,
@@ -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,
@@ -100,7 +113,8 @@ enum GitFlowCloudConfigurationDocument {
hotfixPrefix: hotfixPrefix,
topicFinishStrategy: topicFinishStrategy,
createReleaseTagOnFinish: createReleaseTagOnFinish,
- createHotfixTagOnFinish: createHotfixTagOnFinish
+ createHotfixTagOnFinish: createHotfixTagOnFinish,
+ roleDisplayMode: roleDisplayMode
)
}
}
diff --git a/macgit/Services/SyncState.swift b/macgit/Services/SyncState.swift
index 1aae20fb..eaa3f86a 100644
--- a/macgit/Services/SyncState.swift
+++ b/macgit/Services/SyncState.swift
@@ -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 {
@@ -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
}
}
diff --git a/macgit/ViewModels/HistoryListModel.swift b/macgit/ViewModels/HistoryListModel.swift
index 7d688d9d..19203c16 100644
--- a/macgit/ViewModels/HistoryListModel.swift
+++ b/macgit/ViewModels/HistoryListModel.swift
@@ -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) {
diff --git a/macgit/ViewModels/RepositoryLFSController.swift b/macgit/ViewModels/RepositoryLFSController.swift
index ee2aa87e..2710eef5 100644
--- a/macgit/ViewModels/RepositoryLFSController.swift
+++ b/macgit/ViewModels/RepositoryLFSController.swift
@@ -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
diff --git a/macgit/Views/Common/CreateTagAtReferenceSheet.swift b/macgit/Views/Common/CreateTagAtReferenceSheet.swift
new file mode 100644
index 00000000..4048afc8
--- /dev/null
+++ b/macgit/Views/Common/CreateTagAtReferenceSheet.swift
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import SwiftUI
+
+struct CreateTagAtReferenceSheet: View {
+ let startPoint: TagStartPoint
+ @Binding var name: String
+ let onCancel: () -> Void
+ let onCreate: @MainActor (String) async throws -> Void
+ let onCompleted: () -> Void
+ @State private var isCreating = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Create Tag").font(.title2).bold()
+ TextField("Enter tag name…", text: $name).textFieldStyle(.roundedBorder)
+ .disabled(isCreating)
+ Text(startPoint.isBranch
+ ? "Starting branch: \(startPoint.message)"
+ : "Create tag from commit: \(startPoint.message) - \(startPoint.shortHash)")
+ .font(.caption).foregroundStyle(.secondary).lineLimit(2)
+ if let errorMessage {
+ Label(errorMessage, systemImage: "exclamationmark.triangle")
+ .foregroundStyle(.red).textSelection(.enabled)
+ }
+ HStack {
+ Button("Cancel", role: .cancel, action: onCancel)
+ .keyboardShortcut(.cancelAction).disabled(isCreating)
+ Spacer()
+ if isCreating { ProgressView().controlSize(.small) }
+ Button("Create Tag") {
+ guard !isCreating else { return }
+ let tagName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !tagName.isEmpty, !tagName.hasPrefix("-") else {
+ errorMessage = "Enter a tag name that does not begin with a hyphen."
+ return
+ }
+ isCreating = true
+ errorMessage = nil
+ Task {
+ defer { isCreating = false }
+ do { try await onCreate(tagName); onCompleted() }
+ catch { errorMessage = error.localizedDescription }
+ }
+ }
+ .keyboardShortcut(.defaultAction)
+ .disabled(isCreating || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ }
+ .padding(24).frame(minWidth: 360, idealWidth: 420)
+ .interactiveDismissDisabled(isCreating)
+ }
+}
diff --git a/macgit/Views/Common/RepositorySettingsSheetView.swift b/macgit/Views/Common/RepositorySettingsSheetView.swift
index ca56ed17..e0254703 100644
--- a/macgit/Views/Common/RepositorySettingsSheetView.swift
+++ b/macgit/Views/Common/RepositorySettingsSheetView.swift
@@ -392,6 +392,12 @@ struct RepositorySettingsSheetView: View {
Group {
Section("Git Flow") {
Toggle("Enable Git Flow", isOn: $gitFlowConfiguration.isEnabled)
+ Picker("Sidebar branch labels", selection: $gitFlowConfiguration.roleDisplayMode) {
+ ForEach(GitFlowRoleDisplayMode.allCases) { mode in
+ Text(mode.displayName).tag(mode)
+ }
+ }
+ .pickerStyle(.menu)
}
Section("Branches") {
diff --git a/macgit/Views/Common/RepositorySheetLoadingView.swift b/macgit/Views/Common/RepositorySheetLoadingView.swift
new file mode 100644
index 00000000..f123c263
--- /dev/null
+++ b/macgit/Views/Common/RepositorySheetLoadingView.swift
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import SwiftUI
+
+/// The presentation owns preparation: dismissal cancels it and discards late results.
+struct RepositorySheetLoadingView: View {
+ let title: String
+ let load: @MainActor () async throws -> Value
+ let onCancel: () -> Void
+ var cancelRequestID: UUID? = nil
+ @ViewBuilder let content: (Value) -> Content
+ @State private var value: Value?
+ @State private var errorMessage: String?
+ @State private var attempt = 0
+
+ var body: some View {
+ Group {
+ if let value {
+ content(value)
+ } else {
+ VStack(alignment: .leading, spacing: 16) {
+ Text(title).font(.title2).bold()
+ if let errorMessage {
+ Label(errorMessage, systemImage: "exclamationmark.triangle")
+ .foregroundStyle(.red).textSelection(.enabled)
+ } else {
+ ProgressView("Loading…")
+ }
+ HStack {
+ Button("Cancel", role: .cancel, action: onCancel)
+ .keyboardShortcut(.cancelAction)
+ Spacer()
+ if errorMessage != nil {
+ Button("Retry") { attempt += 1 }
+ }
+ }
+ }
+ .padding(24)
+ .frame(minWidth: 480, minHeight: 180)
+ }
+ }
+ .task(id: attempt) {
+ guard value == nil else { return }
+ errorMessage = nil
+ do {
+ let result = try await load()
+ try Task.checkCancellation()
+ value = result
+ } catch {
+ guard !Task.isCancelled else { return }
+ errorMessage = error.localizedDescription
+ }
+ }
+ .onChange(of: cancelRequestID) { _, _ in
+ if value == nil { onCancel() }
+ }
+ }
+}
diff --git a/macgit/Views/FileStatus/CommitSheetView.swift b/macgit/Views/FileStatus/CommitSheetView.swift
index 591ab22f..07bde95c 100644
--- a/macgit/Views/FileStatus/CommitSheetView.swift
+++ b/macgit/Views/FileStatus/CommitSheetView.swift
@@ -34,7 +34,9 @@ struct CommitSheetView: View {
@State private var isAIGenerationRequested = false
let repositoryURL: URL
let hasStagedChanges: Bool
- let onCommit: (String, Bool) -> Void
+ let onCommit: @MainActor (String, Bool) async throws -> Bool
+ @State private var isCommitting = false
+ @State private var commitErrorMessage: String?
var body: some View {
VStack(spacing: 20) {
@@ -59,7 +61,7 @@ struct CommitSheetView: View {
.textFieldStyle(.roundedBorder)
.frame(width: 400)
.lineLimit(3...6)
- .disabled(aiProviderController.isGenerating)
+ .disabled(aiProviderController.isGenerating || isCommitting)
.onKeyPress(.return, phases: .down) { keyPress in
guard keyPress.modifiers == .shift else { return .ignored }
insertNewlineInMessage()
@@ -84,24 +86,41 @@ struct CommitSheetView: View {
}
}
+ if let commitErrorMessage {
+ Label(commitErrorMessage, systemImage: "exclamationmark.triangle")
+ .foregroundStyle(.red).textSelection(.enabled).frame(width: 400, alignment: .leading)
+ }
+
HStack(spacing: 12) {
Button("Cancel", role: .cancel) {
dismiss()
}
.font(.body.scaled(by: textScale))
.keyboardShortcut(.cancelAction)
+ .disabled(isCommitting)
+ if isCommitting { ProgressView().controlSize(.small) }
Button(message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Commit Without Message" : "Commit") {
- onCommit(message, !hasStagedChanges && commitAllChanges)
- dismiss()
+ guard !isCommitting else { return }
+ let requestedMessage = message
+ let requestedAllChanges = !hasStagedChanges && commitAllChanges
+ isCommitting = true
+ commitErrorMessage = nil
+ Task {
+ defer { isCommitting = false }
+ do {
+ if try await onCommit(requestedMessage, requestedAllChanges) { dismiss() }
+ } catch { commitErrorMessage = error.localizedDescription }
+ }
}
.font(.body.scaled(by: textScale))
.keyboardShortcut(.defaultAction)
- .disabled(!hasStagedChanges && !commitAllChanges)
+ .disabled(isCommitting || (!hasStagedChanges && !commitAllChanges))
}
}
.padding(30)
.frame(minWidth: 480)
+ .interactiveDismissDisabled(isCommitting)
.task {
await aiProviderController.refreshAvailability(selectedOnly: true)
}
@@ -138,7 +157,7 @@ struct CommitSheetView: View {
.frame(width: 14, height: 14)
}
.buttonStyle(GlassButtonStyle(tint: .accentColor, fontSize: 11))
- .disabled(aiProviderController.isGenerating)
+ .disabled(aiProviderController.isGenerating || isCommitting)
.help(generateCommitMessageHelp)
.accessibilityLabel(aiProviderController.isGenerating
? "Generating commit message"
diff --git a/macgit/Views/History/HistoryCommitRowView.swift b/macgit/Views/History/HistoryCommitRowView.swift
new file mode 100644
index 00000000..52ea50e8
--- /dev/null
+++ b/macgit/Views/History/HistoryCommitRowView.swift
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import AppKit
+
+/// Keep the selected commit emphasized while its window is active, even when
+/// the Sidebar owns keyboard focus.
+final class HistoryCommitRowView: NSTableRowView {
+ override var isEmphasized: Bool {
+ get { super.isEmphasized }
+ set { super.isEmphasized = window?.isKeyWindow == true }
+ }
+
+ override var interiorBackgroundStyle: NSView.BackgroundStyle {
+ isSelected && isEmphasized ? .emphasized : super.interiorBackgroundStyle
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ isEmphasized = window?.isKeyWindow == true
+ }
+}
diff --git a/macgit/Views/History/HistoryCommitScrollView.swift b/macgit/Views/History/HistoryCommitScrollView.swift
new file mode 100644
index 00000000..d8f65a5e
--- /dev/null
+++ b/macgit/Views/History/HistoryCommitScrollView.swift
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import AppKit
+
+final class HistoryCommitScrollView: NSScrollView {
+ var onLayout: (() -> Void)?
+
+ override func layout() {
+ super.layout()
+ onLayout?()
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ if window != nil { needsLayout = true }
+ }
+}
diff --git a/macgit/Views/History/HistoryCommitTableCells.swift b/macgit/Views/History/HistoryCommitTableCells.swift
index aa351a5b..6b182f54 100644
--- a/macgit/Views/History/HistoryCommitTableCells.swift
+++ b/macgit/Views/History/HistoryCommitTableCells.swift
@@ -39,7 +39,11 @@ final class HistoryMessageCellView: NSTableCellView {
private let overflow = NSTextField(labelWithString: "")
private let message = NSTextField(labelWithString: "")
override var backgroundStyle: NSView.BackgroundStyle {
- didSet { badges.forEach { $0.emphasized = backgroundStyle == .emphasized } }
+ didSet {
+ let emphasized = backgroundStyle == .emphasized
+ message.textColor = emphasized ? .alternateSelectedControlTextColor : .labelColor
+ overflow.textColor = emphasized ? .alternateSelectedControlTextColor : .secondaryLabelColor
+ }
}
override init(frame: NSRect) {
super.init(frame: frame)
@@ -64,7 +68,6 @@ final class HistoryMessageCellView: NSTableCellView {
for (index, badge) in badges.enumerated() {
badge.isHidden = index >= commit.refs.count
if !badge.isHidden { badge.configure(text: commit.refs[index], graphColorIndex: colorIndex, textScale: textScale) }
- badge.emphasized = backgroundStyle == .emphasized
}
overflow.isHidden = commit.refs.count <= 3
overflow.stringValue = "+\(max(0, commit.refs.count - 3))"
@@ -78,6 +81,15 @@ final class HistoryMessageCellView: NSTableCellView {
final class HistoryTextCellView: NSTableCellView {
private let label = NSTextField(labelWithString: "")
+ private var unselectedTextColor = NSColor.secondaryLabelColor
+ override var backgroundStyle: NSView.BackgroundStyle {
+ didSet { updateTextColor() }
+ }
+
+ private func updateTextColor() {
+ label.textColor = backgroundStyle == .emphasized ? .alternateSelectedControlTextColor : unselectedTextColor
+ }
+
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.setLocalizedDateFormatFromTemplate("HHmmddMMMyyyy")
@@ -100,7 +112,7 @@ final class HistoryTextCellView: NSTableCellView {
func configure(commit: Commit, column: String, textScale: CGFloat) {
let font = historyCalloutFont(textScale)
label.font = font
- label.textColor = .secondaryLabelColor
+ unselectedTextColor = .secondaryLabelColor
switch column {
case "author": label.stringValue = "\(commit.author) <\(commit.email)>"
case "date":
@@ -109,8 +121,9 @@ final class HistoryTextCellView: NSTableCellView {
default:
label.stringValue = commit.shortHash
label.font = .monospacedSystemFont(ofSize: font.pointSize, weight: .regular)
- label.textColor = .tertiaryLabelColor
+ unselectedTextColor = .tertiaryLabelColor
}
+ updateTextColor()
label.toolTip = column == "commit" ? commit.hash : label.stringValue
}
}
diff --git a/macgit/Views/History/HistoryCommitTableController.swift b/macgit/Views/History/HistoryCommitTableController.swift
index 50f57f26..c2914848 100644
--- a/macgit/Views/History/HistoryCommitTableController.swift
+++ b/macgit/Views/History/HistoryCommitTableController.swift
@@ -17,6 +17,7 @@ final class HistoryCommitTableController: NSObject, NSTableViewDataSource, NSTab
private var appliedTextScale: CGFloat = 1
private var appliedDragActive: Set = []
private var lastScrollToken: UUID?
+ private var pendingScrollRequest: HistoryScrollRequest?
private var isApplyingModelSelection = false
private var boundsObserver: NSObjectProtocol?
private var hostedMenu: NSMenu?
@@ -24,7 +25,8 @@ final class HistoryCommitTableController: NSObject, NSTableViewDataSource, NSTab
private var draggedHashes: Set = []
func makeScrollView() -> NSScrollView {
- let scroll = NSScrollView(frame: NSRect(x: 0, y: 0, width: 1000, height: 300))
+ let scroll = HistoryCommitScrollView(frame: NSRect(x: 0, y: 0, width: 1000, height: 300))
+ scroll.onLayout = { [weak self] in self?.applyPendingScrollRequest() }
scroll.hasVerticalScroller = true
scroll.hasHorizontalScroller = true
scroll.borderType = .bezelBorder
@@ -87,6 +89,8 @@ final class HistoryCommitTableController: NSObject, NSTableViewDataSource, NSTab
if let boundsObserver { NotificationCenter.default.removeObserver(boundsObserver) }
boundsObserver = nil
tableView.onReturn = nil
+ (tableView.enclosingScrollView as? HistoryCommitScrollView)?.onLayout = nil
+ pendingScrollRequest = nil
}
func apply(listModel: HistoryListModel, actions: HistoryCommitActionController,
@@ -130,13 +134,10 @@ final class HistoryCommitTableController: NSObject, NSTableViewDataSource, NSTab
if tableView.selectedRowIndexes != selected { tableView.selectRowIndexes(selected, byExtendingSelection: false) }
isApplyingModelSelection = false
if let request = scrollRequest, request.token != lastScrollToken {
- lastScrollToken = request.token
- if let row = rows.indexByHash[request.hash], let clip = tableView.enclosingScrollView?.contentView {
- let rect = tableView.rect(ofRow: row)
- scroll(to: rect.midY - clip.bounds.height / 2)
- if request.focus { tableView.window?.makeFirstResponder(tableView) }
- }
- listModel.consumeScrollRequest(request.token)
+ pendingScrollRequest = request
+ tableView.enclosingScrollView?.needsLayout = true
+ } else if scrollRequest == nil {
+ pendingScrollRequest = nil
}
if appliedTextScale != textScale {
let anchor = viewportAnchor()
@@ -158,6 +159,22 @@ final class HistoryCommitTableController: NSObject, NSTableViewDataSource, NSTab
}
if changed { loadMoreIfNeeded() }
}
+ private func applyPendingScrollRequest() {
+ guard let request = pendingScrollRequest,
+ let scrollView = tableView.enclosingScrollView,
+ scrollView.window != nil,
+ scrollView.contentView.bounds.height > 0,
+ let row = rows.indexByHash[request.hash] else { return }
+ // The scroll view has now tiled its header and sized the document.
+ // Consume only after layout, so the first SwiftUI update cannot lose it.
+ pendingScrollRequest = nil
+ lastScrollToken = request.token
+ let rect = tableView.rect(ofRow: row)
+ scroll(to: rect.midY - scrollView.contentView.bounds.height / 2)
+ if request.focus { tableView.window?.makeFirstResponder(tableView) }
+ listModel?.consumeScrollRequest(request.token)
+ }
+
private func viewportAnchor() -> (String, CGFloat)? {
let row = tableView.rows(in: tableView.visibleRect).location
guard rows.commits.indices.contains(row) else { return nil }
@@ -170,8 +187,10 @@ final class HistoryCommitTableController: NSObject, NSTableViewDataSource, NSTab
private func scroll(to y: CGFloat) {
guard let scroll = tableView.enclosingScrollView else { return }
let clip = scroll.contentView
- let maximum = max(0, tableView.bounds.height - clip.bounds.height)
- clip.scroll(to: NSPoint(x: clip.bounds.minX, y: min(maximum, max(0, y))))
+ var proposedBounds = clip.bounds
+ proposedBounds.origin.y = y
+ let constrainedBounds = clip.constrainBoundsRect(proposedBounds)
+ clip.scroll(to: constrainedBounds.origin)
scroll.reflectScrolledClipView(clip)
}
func loadMoreIfNeeded() {
@@ -211,6 +230,14 @@ final class HistoryCommitTableController: NSObject, NSTableViewDataSource, NSTab
return cell
}
}
+ func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? {
+ let identifier = NSUserInterfaceItemIdentifier("HistoryCommitRow")
+ let view = tableView.makeView(withIdentifier: identifier, owner: self) as? HistoryCommitRowView
+ ?? HistoryCommitRowView()
+ view.identifier = identifier
+ return view
+ }
+
func tableView(_ tableView: NSTableView, didAdd rowView: NSTableRowView, forRow row: Int) {
rowView.alphaValue = rows.commits.indices.contains(row) && appliedDragActive.contains(rows.commits[row].hash) ? 0.4 : 1
}
diff --git a/macgit/Views/History/HistoryRefBadgeView.swift b/macgit/Views/History/HistoryRefBadgeView.swift
index 50bb5736..163e213e 100644
--- a/macgit/Views/History/HistoryRefBadgeView.swift
+++ b/macgit/Views/History/HistoryRefBadgeView.swift
@@ -5,7 +5,6 @@ final class HistoryRefBadgeView: NSView {
private let icon = NSImageView()
private let label = NSTextField(labelWithString: "")
private var style = RefLabelStyle(text: "", graphColorIndex: nil)
- var emphasized = false { didSet { updateColors() } }
override init(frame: NSRect) {
super.init(frame: frame)
@@ -41,11 +40,11 @@ final class HistoryRefBadgeView: NSView {
override func layout() { super.layout(); layer?.cornerRadius = bounds.height / 2 }
override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance(); updateColors() }
private func updateColors() {
- let color: NSColor = emphasized ? .labelColor : style.foreground
+ let color = style.foreground
label.textColor = color
icon.contentTintColor = color
effectiveAppearance.performAsCurrentDrawingAppearance {
- layer?.backgroundColor = (emphasized ? NSColor.labelColor.withAlphaComponent(0.12) : style.background).cgColor
+ layer?.backgroundColor = style.background.cgColor
}
}
}
diff --git a/macgit/Views/History/HistoryScreen.swift b/macgit/Views/History/HistoryScreen.swift
index c7920078..68c57df5 100644
--- a/macgit/Views/History/HistoryScreen.swift
+++ b/macgit/Views/History/HistoryScreen.swift
@@ -5,6 +5,7 @@ import Combine
struct HistoryScreen: View {
let repositoryURL: URL
let isActive: Bool
+ let isWorkSuspended: Bool
let selectedBranch: String?
@Binding var branchFilter: HistoryBranchFilter
@Binding var includeRemotes: Bool
@@ -22,13 +23,16 @@ struct HistoryScreen: View {
@State private var searchText = ""
@State private var needsRefresh = true
@State private var historyCommitSetFingerprint: String?
+ @State private var resolvedReference: String?
- init(repositoryURL: URL, isActive: Bool, selectedBranch: String?, branchFilter: Binding,
+ init(repositoryURL: URL, isActive: Bool, isWorkSuspended: Bool = false, selectedBranch: String?, branchFilter: Binding,
includeRemotes: Binding, dependencies: HistoryCommitActionController.Dependencies,
selectionSink: HistoryCommitSelectionSink, onOpenFile: @escaping (String) -> Void) {
self.repositoryURL = repositoryURL
self.isActive = isActive
+ self.isWorkSuspended = isWorkSuspended
self.selectedBranch = selectedBranch
+ _resolvedReference = State(initialValue: selectedBranch)
_branchFilter = branchFilter
_includeRemotes = includeRemotes
self.dependencies = dependencies
@@ -105,36 +109,42 @@ struct HistoryScreen: View {
.onChange(of: branchFilter) { _, value in listModel.branchFilter = value }
.onChange(of: onlyThisBranch) { _, value in listModel.onlyThisBranch = value }
.onChange(of: baseBranch) { _, value in listModel.baseBranch = value }
- .onChange(of: selectedBranch) { _, value in
- listModel.selectedBranch = value
- guard let value else { return }
- Task {
- let selectedLoadedTip = await listModel.selectReferenceTipIfLoaded(value)
- guard !Task.isCancelled, !selectedLoadedTip,
- selectedBranch == value, branchFilter == .all else { return }
- branchFilter = .branch(value)
+ .task(id: ReferenceSelectionRequest(reference: selectedBranch, active: isActive, suspended: isWorkSuspended)) {
+ guard isActive, !isWorkSuspended else { return }
+ listModel.selectedBranch = selectedBranch
+ guard selectedBranch != resolvedReference else { return }
+ guard let reference = selectedBranch else {
+ resolvedReference = nil
+ return
}
+ let selectedLoadedTip = await listModel.selectReferenceTipIfLoaded(reference)
+ guard !Task.isCancelled else { return }
+ resolvedReference = reference
+ if !selectedLoadedTip, branchFilter == .all { branchFilter = .branch(reference) }
}
.onChange(of: searchText) { _, value in listModel.setSearchText(value) }
.onChange(of: historyLoadSizeRaw) { _, value in
listModel.setPageSize(HistoryLoadSize(rawValue: value)?.rawValue ?? HistoryLoadSize.balanced.rawValue)
}
.onChange(of: listModel.selectedCommit?.hash) { _, _ in
+ guard !isWorkSuspended else { return }
detailModel.show(
listModel.selectedCommit,
debounce: listModel.shouldDebounceDetailSelection
)
}
.onChange(of: listModel.selectedHashesInDisplayOrder) { _, hashes in selectionSink.update(hashes) }
- .task(id: listModel.loadKey) {
- guard isActive else {
+ .task(id: HistoryLoadRequest(key: listModel.loadKey, suspended: isWorkSuspended)) {
+ guard isActive, !isWorkSuspended else {
needsRefresh = true
return
}
await listModel.load()
+ guard !Task.isCancelled else { return }
historyCommitSetFingerprint = await GitStatusService.shared.historyCommitSetFingerprint(
in: repositoryURL
)
+ guard !Task.isCancelled else { return }
needsRefresh = false
}
.onReceive(NotificationCenter.default.publisher(for: .repositoryDidChange)) { notification in
@@ -149,8 +159,17 @@ struct HistoryScreen: View {
listModel.clearCache()
requestRefresh()
}
+ .onChange(of: isWorkSuspended) { _, suspended in
+ if suspended {
+ listModel.suspendLoading()
+ detailModel.suspend()
+ } else if isActive {
+ detailModel.show(listModel.selectedCommit, debounce: false)
+ detailModel.resume()
+ }
+ }
.onChange(of: isActive) { _, active in
- if active {
+ if active && !isWorkSuspended {
detailModel.resume()
selectionSink.update(listModel.selectedHashesInDisplayOrder)
if needsRefresh { requestRefresh() }
@@ -161,7 +180,7 @@ struct HistoryScreen: View {
}
}
.onAppear {
- if isActive {
+ if isActive && !isWorkSuspended {
detailModel.resume()
selectionSink.update(listModel.selectedHashesInDisplayOrder)
} else {
@@ -183,9 +202,20 @@ struct HistoryScreen: View {
} message: { Text(listModel.errorMessage ?? "") }
}
+ private struct ReferenceSelectionRequest: Equatable {
+ let reference: String?
+ let active: Bool
+ let suspended: Bool
+ }
+
+ private struct HistoryLoadRequest: Equatable {
+ let key: String
+ let suspended: Bool
+ }
+
private func requestRefresh() {
needsRefresh = true
- guard isActive else { return }
+ guard isActive, !isWorkSuspended else { return }
Task {
await listModel.refresh()
guard !Task.isCancelled else { return }
diff --git a/macgit/Views/LFS/GitLFSRemoveRuleSheet.swift b/macgit/Views/LFS/GitLFSRemoveRuleSheet.swift
new file mode 100644
index 00000000..1c38eb4f
--- /dev/null
+++ b/macgit/Views/LFS/GitLFSRemoveRuleSheet.swift
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import SwiftUI
+
+struct GitLFSRemoveRuleSheet: View {
+ let rule: GitLFSTrackingRule
+ let onCancel: () -> Void
+ let onRemove: @MainActor () async throws -> Void
+ @State private var isRemoving = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Remove Tracking Rule").font(.title2)
+ Text(rule.pattern).font(.headline).textSelection(.enabled)
+ Text("Remove this rule from .gitattributes. Existing committed pointers and history will not be converted. Review and stage the change in File Status.")
+ .foregroundStyle(.secondary)
+ if let errorMessage {
+ Label(errorMessage, systemImage: "exclamationmark.triangle")
+ .foregroundStyle(.red).textSelection(.enabled)
+ }
+ HStack {
+ Button("Cancel", role: .cancel, action: onCancel)
+ .disabled(isRemoving)
+ Spacer()
+ if isRemoving { ProgressView().controlSize(.small) }
+ Button(role: .destructive) {
+ guard !isRemoving else { return }
+ isRemoving = true
+ errorMessage = nil
+ Task {
+ defer { isRemoving = false }
+ do { try await onRemove() }
+ catch { errorMessage = error.localizedDescription }
+ }
+ } label: {
+ Text("Remove Rule").foregroundStyle(.red)
+ }
+ .disabled(isRemoving)
+ }
+ }
+ .padding(24)
+ .frame(width: 540)
+ .interactiveDismissDisabled(isRemoving)
+ }
+}
diff --git a/macgit/Views/LFS/GitLFSView.swift b/macgit/Views/LFS/GitLFSView.swift
index f781eca5..2c029c37 100644
--- a/macgit/Views/LFS/GitLFSView.swift
+++ b/macgit/Views/LFS/GitLFSView.swift
@@ -4,6 +4,7 @@ import SwiftUI
struct GitLFSView: View {
let repositoryURL: URL
+ let initialPath: String?
let credentialResolver: @MainActor (String) async -> GitProviderCredentialResolver?
let refreshRepository: @MainActor @Sendable () async -> Void
@State private var controller: RepositoryLFSController
@@ -20,6 +21,7 @@ struct GitLFSView: View {
@State private var pattern = ""
@State private var literal = false
@State private var minimumMiB = 50
+ @State private var pendingRuleDeletion: GitLFSTrackingRule?
@State private var showingSetup = false
@State private var showingFileDetails = false
@State private var isInitialLoadPending = true
@@ -28,6 +30,7 @@ struct GitLFSView: View {
refreshRepository: @escaping @MainActor @Sendable () async -> Void,
authorizeAction: @escaping @MainActor () async -> Bool) {
self.repositoryURL = repositoryURL
+ self.initialPath = initialPath
self.credentialResolver = credentialResolver
self.refreshRepository = refreshRepository
_pattern = State(initialValue: initialPath ?? "")
@@ -99,6 +102,12 @@ struct GitLFSView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { contentWidth = $0 }
+ .onChange(of: initialPath) { _, path in
+ guard let path else { return }
+ pattern = path
+ literal = true
+ tab = "Tracking Rules"
+ }
.task {
await controller.load(promptForRuntime: true)
isInitialLoadPending = false
@@ -150,6 +159,22 @@ struct GitLFSView: View {
}
}.padding(24).frame(width: 500)
}
+ .sheet(item: $pendingRuleDeletion) { rule in
+ RepositorySheetLoadingView(
+ title: "Remove Tracking Rule",
+ load: { try await controller.prepareRuleRemoval(pattern: rule.pattern) },
+ onCancel: { pendingRuleDeletion = nil }
+ ) { review in
+ GitLFSRemoveRuleSheet(
+ rule: rule,
+ onCancel: { pendingRuleDeletion = nil },
+ onRemove: {
+ try await controller.removeRule(review: review, refresh: refreshRepository)
+ pendingRuleDeletion = nil
+ }
+ )
+ }
+ }
.sheet(item: $controller.review) { review in
VStack(alignment: .leading, spacing: 16) {
Text(review.removing ? "Remove Tracking Rule" : "Review Tracking Rule").font(.title2)
@@ -492,7 +517,7 @@ struct GitLFSView: View {
TableColumn("Action") { rule in
if rule.canRemove {
Button {
- Task { await controller.prepareRule(pattern: rule.pattern, literal: false, removing: true) }
+ pendingRuleDeletion = rule
} label: {
Image(systemName: "trash").foregroundStyle(.red)
}
diff --git a/macgit/Views/MainWindow/BranchForcePushConfirmationSheet.swift b/macgit/Views/MainWindow/BranchForcePushConfirmationSheet.swift
index fec61378..87f41d42 100644
--- a/macgit/Views/MainWindow/BranchForcePushConfirmationSheet.swift
+++ b/macgit/Views/MainWindow/BranchForcePushConfirmationSheet.swift
@@ -20,7 +20,9 @@ import SwiftUI
struct BranchForcePushConfirmationSheet: View {
@Environment(\.dismiss) private var dismiss
let plan: BranchForcePushPlan
- let onConfirm: () -> Void
+ let onConfirm: @MainActor () async throws -> Void
+ @State private var isPushing = false
+ @State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -34,18 +36,32 @@ struct BranchForcePushConfirmationSheet: View {
}
Text("If the remote changes before the push completes, the push will be rejected. Server branch protection still applies.")
.foregroundStyle(.secondary)
+ if let errorMessage {
+ Label(errorMessage, systemImage: "exclamationmark.triangle")
+ .foregroundStyle(.red).textSelection(.enabled)
+ }
HStack {
Spacer()
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
+ .disabled(isPushing)
+ if isPushing { ProgressView().controlSize(.small) }
Button("Force Push", role: .destructive) {
- dismiss()
- onConfirm()
+ guard !isPushing else { return }
+ isPushing = true
+ errorMessage = nil
+ Task {
+ defer { isPushing = false }
+ do { try await onConfirm(); dismiss() }
+ catch { errorMessage = error.localizedDescription }
+ }
}
+ .disabled(isPushing)
}
}
.padding(24)
.frame(width: 480)
.fixedSize(horizontal: false, vertical: true)
+ .interactiveDismissDisabled(isPushing)
}
}
diff --git a/macgit/Views/MainWindow/MainWindowView+BranchForcePush.swift b/macgit/Views/MainWindow/MainWindowView+BranchForcePush.swift
index 6713609e..b7ad06f0 100644
--- a/macgit/Views/MainWindow/MainWindowView+BranchForcePush.swift
+++ b/macgit/Views/MainWindow/MainWindowView+BranchForcePush.swift
@@ -19,28 +19,68 @@ import SwiftUI
extension MainWindowView {
func requestTrackedBranchForcePush(_ branch: String) {
- Task {
- guard let upstream = await GitStatusService.shared.upstreamBranch(for: branch, in: repositoryURL) else {
- syncState.showError("This branch has no upstream. Choose a remote from Push to → Force Push to Other Remote.")
- return
- }
- let parts = upstream.split(separator: "/", maxSplits: 1).map(String.init)
- guard parts.count == 2 else { return }
- requestBranchForcePush(branch: branch, remote: parts[0], remoteBranch: parts[1])
- }
+ guard !syncState.isAnySyncing, operationProgress.activeOperation == nil else { return }
+ pendingBranchForcePush = RepositorySheetRequest(input: (branch: branch, remote: nil, remoteBranch: nil))
}
func requestBranchForcePush(branch: String, remote: String, remoteBranch: String) {
guard !syncState.isAnySyncing, operationProgress.activeOperation == nil else { return }
- runRemoteOperation("Preparing Force Push...", remotes: [remote]) { resolver in
- do {
- pendingBranchForcePush = try await GitStatusService.shared.prepareBranchForcePush(
- remote: remote, localBranch: branch, remoteBranch: remoteBranch,
- in: repositoryURL, credentialResolver: resolver
- )
- } catch {
- syncState.showError(error.localizedDescription)
+ pendingBranchForcePush = RepositorySheetRequest(input: (branch: branch, remote: remote, remoteBranch: remoteBranch))
+ }
+
+ func prepareForcePush(_ request: RepositorySheetRequest<(branch: String, remote: String?, remoteBranch: String?)>) async throws -> BranchForcePushPlan {
+ let input = request.input
+ let remote: String
+ let remoteBranch: String
+ if let requestedRemote = input.remote, let requestedBranch = input.remoteBranch {
+ remote = requestedRemote
+ remoteBranch = requestedBranch
+ } else {
+ guard let upstream = await GitStatusService.shared.upstreamBranch(for: input.branch, in: repositoryURL) else {
+ throw GitError.commandFailed("This branch has no upstream. Choose a remote from Push to → Force Push to Other Remote.")
}
+ let parts = upstream.split(separator: "/", maxSplits: 1).map(String.init)
+ guard parts.count == 2 else { throw GitError.commandFailed("Could not parse upstream branch.") }
+ remote = parts[0]
+ remoteBranch = parts[1]
+ }
+ try Task.checkCancellation()
+ guard let resolver = await credentialResolverForRemoteOperation(remotes: [remote]) else {
+ throw GitError.commandFailed("Account selection was cancelled.")
+ }
+ try Task.checkCancellation()
+ return try await GitStatusService.shared.prepareBranchForcePush(
+ remote: remote, localBranch: input.branch, remoteBranch: remoteBranch,
+ in: repositoryURL, credentialResolver: resolver
+ )
+ }
+
+ var providerAccountSelectionOutsideOperationSheet: Binding {
+ Binding(
+ get: { pendingBranchForcePush == nil && pendingTagDeletion == nil ? pendingProviderAccountSelection : nil },
+ set: { pendingProviderAccountSelection = $0 }
+ )
+ }
+
+ func forcePushPreparationSheet(for request: RepositorySheetRequest<(branch: String, remote: String?, remoteBranch: String?)>) -> some View {
+ RepositorySheetLoadingView(title: "Force Push \(request.input.branch)", load: {
+ try await prepareForcePush(request)
+ }, onCancel: {
+ if pendingProviderAccountSelection != nil { completeProviderAccountSelection(with: nil) }
+ pendingBranchForcePush = nil
+ }) { plan in
+ BranchForcePushConfirmationSheet(plan: plan) { try await confirmBranchForcePush(plan) }
+ }
+ .id(request.id)
+ .replacingSheet(item: $pendingProviderAccountSelection) { selection in
+ GitProviderAccountSelectionSheet(
+ selection: selection,
+ onSelect: { completeProviderAccountSelection(with: $0) },
+ onCancel: { completeProviderAccountSelection(with: nil) }
+ )
+ }
+ .onDisappear {
+ if pendingProviderAccountSelection != nil { completeProviderAccountSelection(with: nil) }
}
}
@@ -50,27 +90,31 @@ extension MainWindowView {
requestBranchForcePush(branch: request.branch, remote: request.remote, remoteBranch: request.remoteBranch)
}
- func confirmBranchForcePush(_ plan: BranchForcePushPlan) {
+ func confirmBranchForcePush(_ plan: BranchForcePushPlan) async throws {
+ guard !syncState.isAnySyncing else {
+ throw GitError.commandFailed("Wait for the current Git operation to finish before force-pushing.")
+ }
+ guard let resolver = await credentialResolverForRemoteOperation(remotes: [plan.remote]) else {
+ throw GitError.commandFailed("Account selection was cancelled.")
+ }
+ guard !syncState.isAnySyncing else {
+ throw GitError.commandFailed("Wait for the current Git operation to finish before force-pushing.")
+ }
+ syncState.isPushing = true
+ defer { syncState.isPushing = false }
+ try await GitStatusService.shared.forcePushBranch(plan, in: repositoryURL, credentialResolver: resolver)
+ undoManager.register(GitUndoEntry(
+ repositoryURL: repositoryURL,
+ label: "Force Push \(plan.destination)",
+ undoOperation: .replaceRemoteBranch(plan, restoring: true),
+ redoOperation: .replaceRemoteBranch(plan, restoring: false),
+ confirmationMessage: "Rewrite '\(plan.destination)' to the saved commit? This is allowed only if the remote still matches the previous operation."
+ ))
pendingBranchForcePush = nil
- runRemoteOperation("Force-pushing \(plan.destination)...", remotes: [plan.remote]) { resolver in
- guard !syncState.isAnySyncing else { return }
- syncState.isPushing = true
- defer { syncState.isPushing = false }
- do {
- try await GitStatusService.shared.forcePushBranch(plan, in: repositoryURL, credentialResolver: resolver)
- undoManager.register(GitUndoEntry(
- repositoryURL: repositoryURL,
- label: "Force Push \(plan.destination)",
- undoOperation: .replaceRemoteBranch(plan, restoring: true),
- redoOperation: .replaceRemoteBranch(plan, restoring: false),
- confirmationMessage: "Rewrite '\(plan.destination)' to the saved commit? This is allowed only if the remote still matches the previous operation."
- ))
- await syncState.refresh(repositoryURL: repositoryURL)
- NotificationCenter.default.post(name: .repositoryDidChange, object: nil,
- userInfo: ["repositoryURL": repositoryURL])
- } catch {
- syncState.showError(error.localizedDescription)
- }
+ Task {
+ await syncState.refresh(repositoryURL: repositoryURL)
+ NotificationCenter.default.post(name: .repositoryDidChange, object: nil,
+ userInfo: ["repositoryURL": repositoryURL])
}
}
}
diff --git a/macgit/Views/MainWindow/MainWindowView+CheckoutActions.swift b/macgit/Views/MainWindow/MainWindowView+CheckoutActions.swift
index 333c14f6..72f3d73f 100644
--- a/macgit/Views/MainWindow/MainWindowView+CheckoutActions.swift
+++ b/macgit/Views/MainWindow/MainWindowView+CheckoutActions.swift
@@ -42,6 +42,15 @@ extension MainWindowView {
)
}
await syncState.refresh(repositoryURL: repositoryURL)
+ if await GitStatusService.shared.currentBranch(in: repositoryURL) == ref {
+ selectedItem = .branch(ref)
+ selectedBranchName = ref
+ appState.historyBranchFilter = SidebarView.historyFilter(
+ afterSelecting: ref,
+ currentBranch: ref,
+ currentFilter: appState.historyBranchFilter
+ )
+ }
NotificationCenter.default.post(
name: .repositoryDidChange,
object: nil,
@@ -53,6 +62,8 @@ extension MainWindowView {
}
func performTagCheckout(tag: String) async {
+ activeTagCheckoutCount += 1
+ defer { activeTagCheckoutCount -= 1 }
await performCheckout(ref: tag, stash: false)
}
@@ -84,6 +95,12 @@ extension MainWindowView {
await syncState.refresh(repositoryURL: repositoryURL)
await MainActor.run {
selectedItem = .branch(checkedOutBranch)
+ selectedBranchName = checkedOutBranch
+ appState.historyBranchFilter = SidebarView.historyFilter(
+ afterSelecting: checkedOutBranch,
+ currentBranch: checkedOutBranch,
+ currentFilter: appState.historyBranchFilter
+ )
}
NotificationCenter.default.post(
name: .repositoryDidChange,
diff --git a/macgit/Views/MainWindow/MainWindowView+DragDropActions.swift b/macgit/Views/MainWindow/MainWindowView+DragDropActions.swift
index 0816f691..627e3ea8 100644
--- a/macgit/Views/MainWindow/MainWindowView+DragDropActions.swift
+++ b/macgit/Views/MainWindow/MainWindowView+DragDropActions.swift
@@ -41,19 +41,13 @@ extension MainWindowView {
await checkoutRemoteBranchFromDrop(fullPath)
}
case .createTagFromBranch(let sourceBranch):
- Task {
- await presentTagSheetFromBranchTip(sourceBranch)
- }
+ presentTagSheetFromBranchTip(sourceBranch)
case .createTagFromCommit(let commit):
presentTagSheetFromCommit(commit)
case .moveTag(let tagName, let commit):
- Task {
- await presentTagMoveConfirmation(tagName: tagName, commit: commit)
- }
+ presentTagMoveConfirmation(tagName: tagName, commit: commit)
case .pushBranchToRemote(let branch):
- Task {
- await presentPushBranchDropConfirmation(branch)
- }
+ presentPushBranchDropConfirmation(branch)
case .stashFiles(let paths):
handleStashFilesDrop(paths: paths)
case .applyStash(let ref):
@@ -115,28 +109,12 @@ extension MainWindowView {
case .commit:
presentBranchSheet(startPoint: startPoint)
case .branch(let sourceBranch):
- Task {
- await presentBranchSheetFromBranchTip(sourceBranch)
- }
+ presentBranchSheetFromBranchTip(sourceBranch)
}
}
- func presentBranchSheetFromBranchTip(_ sourceBranch: String) async {
- let commits = await GitStatusService.shared.commitHistory(
- branch: sourceBranch,
- limit: 1,
- in: repositoryURL
- )
-
- await MainActor.run {
- if let commit = commits.first {
- presentBranchSheet(
- startPoint: .commit(hash: commit.hash, message: commit.message)
- )
- } else {
- syncState.showError("Could not find the last commit for \(sourceBranch).")
- }
- }
+ func presentBranchSheetFromBranchTip(_ sourceBranch: String) {
+ presentBranchSheet(startPoint: .branch(sourceBranch))
}
func initializeSubmodule(at path: String) async {
@@ -181,19 +159,17 @@ extension MainWindowView {
}
}
- func presentPushBranchDropConfirmation(_ branch: String) async {
- let remotes = await GitStatusService.shared.remotes(in: repositoryURL)
- await MainActor.run {
- guard let remote = remotes.first(where: { $0 == "origin" }) ?? remotes.first else {
- syncState.showError("No remotes configured.")
- return
- }
+ func presentPushBranchDropConfirmation(_ branch: String) {
+ pendingPushBranchDropConfirmation = RepositorySheetRequest(input: branch)
+ }
- pendingPushBranchDropConfirmation = PendingPushBranchDropConfirmation(
- branch: branch,
- remote: remote
- )
+ func loadPushBranchDropConfirmation(_ branch: String) async throws -> PendingPushBranchDropConfirmation {
+ let remotes = await GitStatusService.shared.remotes(in: repositoryURL)
+ try Task.checkCancellation()
+ guard let remote = remotes.first(where: { $0 == "origin" }) ?? remotes.first else {
+ throw GitError.commandFailed("No remotes configured.")
}
+ return PendingPushBranchDropConfirmation(branch: branch, remote: remote)
}
func performConfirmedBranchPush(_ confirmation: PendingPushBranchDropConfirmation) async {
diff --git a/macgit/Views/MainWindow/MainWindowView+GitFlow.swift b/macgit/Views/MainWindow/MainWindowView+GitFlow.swift
index 91be3905..2be812d9 100644
--- a/macgit/Views/MainWindow/MainWindowView+GitFlow.swift
+++ b/macgit/Views/MainWindow/MainWindowView+GitFlow.swift
@@ -76,31 +76,22 @@ extension MainWindowView {
}
func requestPresentGitFlowSettings() {
- Task {
- guard await authorizeGitFlowAccess() else { return }
- await MainActor.run {
- initiallySelectGitFlowSettings = true
- presentRepositorySettingsWindow()
- }
- }
+ initiallySelectGitFlowSettings = true
+ presentRepositorySettingsWindow()
}
func requestStartGitFlow(_ kind: GitFlowTopicKind) {
- Task {
- guard await authorizeGitFlowAccess() else { return }
- await MainActor.run {
- if gitFlowConfiguration.isEnabled {
- pendingGitFlowTopicKind = kind
- } else {
- initiallySelectGitFlowSettings = true
- presentRepositorySettingsWindow()
- }
- }
+ if gitFlowConfiguration.isEnabled {
+ pendingGitFlowTopicKind = kind
+ } else {
+ requestPresentGitFlowSettings()
}
}
func startGitFlow(_ plan: GitFlowStartPlan, openAfterCreate: Bool) async -> Bool {
- guard await authorizeGitFlowAccess() else { return false }
+ guard await authorizeGitFlowAccess(presentNotice: true) else {
+ return false
+ }
do {
let result = try await GitFlowService().start(plan, in: repositoryURL)
await MainActor.run {
@@ -179,32 +170,26 @@ extension MainWindowView {
}
func requestFinishGitFlow(_ kind: GitFlowTopicKind) {
- Task {
- guard await authorizeGitFlowAccess() else { return }
- let currentBranch = await GitStatusService.shared.currentBranch(in: repositoryURL) ?? ""
- do {
- let worktrees = await GitStatusService.shared.worktrees(in: repositoryURL)
- let localWorktree = worktrees.dropFirst().first {
- $0.path.standardizedFileURL == repositoryURL.standardizedFileURL && $0.branch == currentBranch
- }
- let plan = try GitFlowPlanner().finishPlan(
- kind: kind,
- currentBranch: currentBranch,
- configuration: gitFlowConfiguration,
- worktreePath: localWorktree?.path
- )
- await MainActor.run {
- gitFlowCurrentBranch = currentBranch
- pendingGitFlowFinishPlan = plan
- }
- } catch {
- await MainActor.run { syncState.showError(error.localizedDescription) }
- }
+ pendingGitFlowFinishPlan = RepositorySheetRequest(input: kind)
+ }
+
+ func loadGitFlowFinishPlan(_ kind: GitFlowTopicKind) async throws -> GitFlowFinishPlan {
+ let currentBranch = await GitStatusService.shared.currentBranch(in: repositoryURL) ?? ""
+ let worktrees = await GitStatusService.shared.worktrees(in: repositoryURL)
+ try Task.checkCancellation()
+ let localWorktree = worktrees.dropFirst().first {
+ $0.path.standardizedFileURL == repositoryURL.standardizedFileURL && $0.branch == currentBranch
}
+ return try GitFlowPlanner().finishPlan(
+ kind: kind, currentBranch: currentBranch, configuration: gitFlowConfiguration,
+ worktreePath: localWorktree?.path
+ )
}
func finishGitFlow(_ plan: GitFlowFinishPlan) async -> Bool {
- guard await authorizeGitFlowAccess() else { return false }
+ guard await authorizeGitFlowAccess(presentNotice: true) else {
+ return false
+ }
do {
let result = try await GitFlowService().finish(plan, in: repositoryURL)
if await cleanUpFinishedGitFlowWorktree(result) { return true }
diff --git a/macgit/Views/MainWindow/MainWindowView+ProtectedBranchCommit.swift b/macgit/Views/MainWindow/MainWindowView+ProtectedBranchCommit.swift
index 30262892..1987e69d 100644
--- a/macgit/Views/MainWindow/MainWindowView+ProtectedBranchCommit.swift
+++ b/macgit/Views/MainWindow/MainWindowView+ProtectedBranchCommit.swift
@@ -41,11 +41,10 @@ extension MainWindowView {
)
}
- func performPendingToolbarCommit() {
- guard let pending = pendingToolbarCommit else { return }
- pendingToolbarCommit = nil
- runRepositoryOperation("Committing changes...") {
- await commitFromToolbar(message: pending.message, commitAllChanges: pending.commitAllChanges)
- }
+ var protectedBranchWarningOutsideCommit: Binding {
+ Binding(
+ get: { showingCommitSheet ? nil : protectedBranchCommitController.warning },
+ set: { protectedBranchCommitController.warning = $0 }
+ )
}
}
diff --git a/macgit/Views/MainWindow/MainWindowView+ProviderAccountPreferences.swift b/macgit/Views/MainWindow/MainWindowView+ProviderAccountPreferences.swift
index c728f03e..89a12a87 100644
--- a/macgit/Views/MainWindow/MainWindowView+ProviderAccountPreferences.swift
+++ b/macgit/Views/MainWindow/MainWindowView+ProviderAccountPreferences.swift
@@ -61,6 +61,7 @@ extension MainWindowView {
for remote in Set(remotes.filter { !$0.isEmpty }) {
let remoteURL = await GitStatusService.shared.remoteURL(remote: remote, in: repositoryURL)
+ guard !Task.isCancelled else { return nil }
let matchingAccounts = resolver.matchingAccounts(for: remoteURL)
guard matchingAccounts.count > 1,
resolver.preferredAccountID(for: remoteURL) == nil,
@@ -76,6 +77,7 @@ extension MainWindowView {
continue
}
+ guard !Task.isCancelled else { return nil }
do {
try await providerAccountPreferenceStore.update(accountID: selectedAccountID, for: identity)
} catch {
@@ -103,7 +105,8 @@ extension MainWindowView {
identity: GitRemoteIdentity,
accounts: [GitProviderAccount]
) async -> String? {
- await withCheckedContinuation { continuation in
+ guard !Task.isCancelled else { return nil }
+ return await withCheckedContinuation { continuation in
providerAccountSelectionContinuation = continuation
pendingProviderAccountSelection = PendingGitProviderAccountSelection(
remoteName: remoteName,
diff --git a/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift b/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift
index 5cee3b75..0646ca8c 100644
--- a/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift
+++ b/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift
@@ -72,49 +72,19 @@ extension MainWindowView {
}
}
- func prepareCreatePullRequest(branch: String) async {
- guard await authorizePullRequestAccess() else { return }
- guard !createPullRequestWindow.activateIfPresented() else { return }
- do {
- let remote = try await prepareRemoteBranchForPullRequest(branch: branch)
- await pullRequestController.prepareCreatePullRequest(
- repositoryURL: repositoryURL,
- remoteName: remote,
- sourceBranch: branch
- )
- if pullRequestController.createDraftSeed == nil,
- let detailErrorMessage = pullRequestController.detailErrorMessage {
- await MainActor.run {
- syncState.showError(detailErrorMessage)
- pullRequestController.detailErrorMessage = nil
- }
- } else {
- presentCreatePullRequestWindow()
- }
- } catch {
- await MainActor.run {
- syncState.showError(error.localizedDescription)
- }
- }
+ func prepareCreatePullRequest(branch: String) {
+ presentCreatePullRequestWindow(sourceBranch: branch)
}
- func prepareCreatePullRequest() async {
- guard await authorizePullRequestAccess() else { return }
- guard !createPullRequestWindow.activateIfPresented() else { return }
- await pullRequestController.prepareCreatePullRequest(repositoryURL: repositoryURL)
- if pullRequestController.createDraftSeed == nil,
- let detailErrorMessage = pullRequestController.detailErrorMessage {
- await MainActor.run {
- syncState.showError(detailErrorMessage)
- pullRequestController.detailErrorMessage = nil
- }
- } else {
- presentCreatePullRequestWindow()
- }
+ func prepareCreatePullRequest() {
+ presentCreatePullRequestWindow(sourceBranch: nil)
}
func submitCreatePullRequest(_ draft: PullRequestDraft) async {
- guard await authorizePullRequestAccess() else { return }
+ guard await authorizePullRequestAccess(presentNotice: false) else {
+ pullRequestController.detailErrorMessage = "Pull request access is unavailable for this repository."
+ return
+ }
do {
_ = try await prepareRemoteBranchForPullRequest(branch: draft.sourceBranch)
await pullRequestController.createPullRequest(draft)
@@ -129,41 +99,57 @@ extension MainWindowView {
}
} catch {
await MainActor.run {
- syncState.showError(error.localizedDescription)
+ pullRequestController.detailErrorMessage = error.localizedDescription
}
}
}
@MainActor
- private func presentCreatePullRequestWindow() {
- guard let seed = pullRequestController.createDraftSeed else { return }
+ private func presentCreatePullRequestWindow(sourceBranch: String?) {
+ guard !createPullRequestWindow.activateIfPresented() else { return }
+ pullRequestController.dismissCreatePullRequest()
createPullRequestWindow.show(
repositoryName: repositoryURL.lastPathComponent,
- content: CreatePullRequestWindowContent(
+ content: CreatePullRequestPreparationView(
controller: pullRequestController,
windowController: createPullRequestWindow,
- seed: seed,
repositoryURL: repositoryURL,
- authorizeAction: { await authorizePullRequestAccess() },
+ loadDraft: { try await loadPullRequestDraft(sourceBranch: sourceBranch) },
+ authorizeAction: { await authorizePullRequestAccess(presentNotice: false) },
onGenerateText: { field, sourceBranch, targetBranch in
- try await generatePullRequestText(
- field: field,
- sourceBranch: sourceBranch,
- targetBranch: targetBranch
- )
+ try await generatePullRequestText(field: field, sourceBranch: sourceBranch, targetBranch: targetBranch)
},
onCancel: {
pullRequestController.dismissCreatePullRequest()
createPullRequestWindow.close()
},
- onCreate: { draft in
- await submitCreatePullRequest(draft)
- }
+ onCreate: { await submitCreatePullRequest($0) }
),
- onClose: {
- pullRequestController.dismissCreatePullRequest()
+ onClose: { pullRequestController.dismissCreatePullRequest() }
+ )
+ }
+
+ @MainActor
+ private func loadPullRequestDraft(sourceBranch: String?) async throws -> PullRequestDraftSeed {
+ let remote: String?
+ if let sourceBranch {
+ if let upstream = await GitStatusService.shared.upstreamBranch(for: sourceBranch, in: repositoryURL) {
+ remote = try parseRemoteBranch(upstream).remote
+ } else {
+ remote = try await defaultPullRequestRemote()
}
+ } else {
+ remote = nil
+ }
+ try Task.checkCancellation()
+ await pullRequestController.prepareCreatePullRequest(
+ repositoryURL: repositoryURL, remoteName: remote, sourceBranch: sourceBranch
)
+ try Task.checkCancellation()
+ guard let seed = pullRequestController.createDraftSeed else {
+ throw GitError.commandFailed(pullRequestController.detailErrorMessage ?? "Pull request creation is unavailable.")
+ }
+ return seed
}
@MainActor
diff --git a/macgit/Views/MainWindow/MainWindowView+Sheets.swift b/macgit/Views/MainWindow/MainWindowView+Sheets.swift
index bdee4a14..4588d893 100644
--- a/macgit/Views/MainWindow/MainWindowView+Sheets.swift
+++ b/macgit/Views/MainWindow/MainWindowView+Sheets.swift
@@ -25,7 +25,14 @@ extension MainWindowView {
repositoryURL: repositoryURL,
hasStagedChanges: syncState.stagedBadgeCount > 0
) { message, commitAllChanges in
- pendingToolbarCommit = (message, commitAllChanges)
+ try await commitFromToolbar(message: message, commitAllChanges: commitAllChanges)
+ }
+ .replacingSheet(item: $protectedBranchCommitController.warning,
+ onDismiss: { protectedBranchCommitController.finish(.cancel) }) { warning in
+ ProtectedBranchCommitSheet(warning: warning, skipWarnings: $repoSettings.skipProtectedBranchCommitWarnings) { decision in
+ protectedBranchCommitController.finish(decision)
+ }
+ .onChange(of: repoSettings.skipProtectedBranchCommitWarnings) { _, _ in commitRulePreferenceChanged() }
}
}
@@ -145,42 +152,23 @@ extension MainWindowView {
@ViewBuilder
var tagSheet: some View {
- VStack(alignment: .leading, spacing: 16) {
- Text("Create Tag")
- .font(.title2)
- .fontWeight(.semibold)
-
- VStack(alignment: .leading, spacing: 4) {
- Text("Tag name:")
- .font(.system(size: 13))
- TextField("Enter tag name...", text: $tagNameInput)
- .textFieldStyle(.roundedBorder)
- }
-
- if let startPoint = tagStartPoint {
- Text("Create tag from commit: \(startPoint.message) - \(startPoint.shortHash)")
- .font(.system(size: 12))
- .foregroundStyle(.secondary)
- .lineLimit(2)
- .truncationMode(.tail)
- }
-
- HStack(spacing: 12) {
- Spacer()
- Button("Cancel", role: .cancel) {
+ if let startPoint = tagStartPoint {
+ CreateTagAtReferenceSheet(
+ startPoint: startPoint, name: $tagNameInput,
+ onCancel: { showingTagSheet = false },
+ onCreate: { name in
+ try await createTagAtReference(name: name, startPoint: startPoint)
+ },
+ onCompleted: {
showingTagSheet = false
+ Task {
+ await syncState.refresh(repositoryURL: repositoryURL)
+ NotificationCenter.default.post(name: .repositoryDidChange, object: nil,
+ userInfo: ["repositoryURL": repositoryURL])
+ }
}
- .keyboardShortcut(.cancelAction)
-
- Button("Create Tag") {
- Task { await createTagFromBranch() }
- }
- .keyboardShortcut(.defaultAction)
- .disabled(tagNameInput.trimmingCharacters(in: .whitespaces).isEmpty || tagStartPoint == nil)
- }
+ )
}
- .padding(24)
- .frame(minWidth: 360, idealWidth: 420)
}
@ViewBuilder
@@ -428,46 +416,59 @@ extension MainWindowView {
)
}
- func tagMoveConfirmationSheet(for confirmation: PendingTagMoveConfirmation) -> some View {
- MoveTagConfirmationSheet(
- confirmation: confirmation,
- onConfirm: { remote in
- pendingTagMoveConfirmation = nil
- runRepositoryOperation("Moving tag \(confirmation.tagName)...") {
- await performTagMove(confirmation, forcePushRemote: remote)
- }
- },
- onCancel: {
- pendingTagMoveConfirmation = nil
- }
- )
+ func tagMoveConfirmationSheet(for request: RepositorySheetRequest<(tag: String, commit: GitDraggedCommit)>) -> some View {
+ RepositorySheetLoadingView(title: "Move Tag \(request.input.tag)", load: {
+ try await loadTagMoveConfirmation(tagName: request.input.tag, commit: request.input.commit)
+ }, onCancel: { pendingTagMoveConfirmation = nil }) { confirmation in
+ MoveTagConfirmationSheet(
+ confirmation: confirmation,
+ onConfirm: { remote in
+ try await performTagMove(confirmation, forcePushRemote: remote)
+ pendingTagMoveConfirmation = nil
+ refreshAfterTagAction()
+ },
+ onCancel: { pendingTagMoveConfirmation = nil }
+ )
+ }
+ .id(request.id)
}
func deleteTagConfirmationSheet(for confirmation: PendingTagDeletion) -> some View {
DeleteTagConfirmationSheet(
confirmation: confirmation,
+ loadRemotes: { await GitStatusService.shared.remotes(in: repositoryURL).sorted() },
onConfirm: { remote in
+ try await deleteTag(confirmation.tag, remote: remote)
pendingTagDeletion = nil
- let message = remote.map { "Deleting \(confirmation.tag) on \($0)..." }
- ?? "Deleting \(confirmation.tag)..."
- runRepositoryOperation(message) {
- await deleteTag(confirmation.tag, remote: remote)
- }
+ refreshAfterTagAction()
},
onCancel: {
+ if pendingProviderAccountSelection != nil { completeProviderAccountSelection(with: nil) }
pendingTagDeletion = nil
}
)
+ .replacingSheet(item: $pendingProviderAccountSelection) { selection in
+ GitProviderAccountSelectionSheet(
+ selection: selection,
+ onSelect: { completeProviderAccountSelection(with: $0) },
+ onCancel: { completeProviderAccountSelection(with: nil) }
+ )
+ }
+ .onDisappear {
+ if pendingProviderAccountSelection != nil { completeProviderAccountSelection(with: nil) }
+ }
}
}
struct MoveTagConfirmationSheet: View {
let confirmation: PendingTagMoveConfirmation
- let onConfirm: (String?) -> Void
+ let onConfirm: @MainActor (String?) async throws -> Void
let onCancel: () -> Void
@State private var forcePush = false
@State private var selectedRemote = ""
+ @State private var isPerforming = false
+ @State private var errorMessage: String?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -504,20 +505,35 @@ struct MoveTagConfirmationSheet: View {
}
}
+ if let errorMessage {
+ Label(errorMessage, systemImage: "exclamationmark.triangle")
+ .foregroundStyle(.red).textSelection(.enabled)
+ }
HStack(spacing: 12) {
Spacer()
Button("Cancel", role: .cancel, action: onCancel)
.keyboardShortcut(.cancelAction)
+ .disabled(isPerforming)
+ if isPerforming { ProgressView().controlSize(.small) }
Button("Move Tag") {
- onConfirm(forcePush ? selectedRemote : nil)
+ guard !isPerforming else { return }
+ let remote = forcePush ? selectedRemote : nil
+ isPerforming = true
+ errorMessage = nil
+ Task {
+ defer { isPerforming = false }
+ do { try await onConfirm(remote) }
+ catch { errorMessage = error.localizedDescription }
+ }
}
.keyboardShortcut(.defaultAction)
.buttonStyle(GlassProminentButtonStyle(tint: .accentColor, fontSize: 13))
- .disabled(forcePush && selectedRemote.isEmpty)
+ .disabled(isPerforming || (forcePush && selectedRemote.isEmpty))
}
}
.padding(24)
.frame(minWidth: 440, idealWidth: 500, maxWidth: 560)
+ .interactiveDismissDisabled(isPerforming)
.task {
selectedRemote = confirmation.remotes.first ?? ""
}
@@ -542,11 +558,16 @@ struct MoveTagConfirmationSheet: View {
struct DeleteTagConfirmationSheet: View {
let confirmation: PendingTagDeletion
- let onConfirm: (String?) -> Void
+ let loadRemotes: @MainActor () async -> [String]
+ let onConfirm: @MainActor (String?) async throws -> Void
let onCancel: () -> Void
@State private var deleteOnRemote = false
@State private var selectedRemote = ""
+ @State private var isPerforming = false
+ @State private var errorMessage: String?
+ @State private var remotes: [String] = []
+ @State private var isLoadingRemotes = true
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -561,11 +582,14 @@ struct DeleteTagConfirmationSheet: View {
VStack(alignment: .leading, spacing: 8) {
Toggle("Also delete on remote", isOn: $deleteOnRemote)
.toggleStyle(.checkbox)
- .disabled(confirmation.remotes.isEmpty)
+ .disabled(isLoadingRemotes || remotes.isEmpty)
+ if isLoadingRemotes {
+ ProgressView("Loading remotes…").controlSize(.small)
+ }
if deleteOnRemote {
Picker("Remote", selection: $selectedRemote) {
- ForEach(confirmation.remotes, id: \.self) { remote in
+ ForEach(remotes, id: \.self) { remote in
Text(remote).tag(remote)
}
}
@@ -573,22 +597,41 @@ struct DeleteTagConfirmationSheet: View {
}
}
+ if let errorMessage {
+ Label(errorMessage, systemImage: "exclamationmark.triangle")
+ .foregroundStyle(.red).textSelection(.enabled)
+ }
HStack(spacing: 12) {
Spacer()
Button("Cancel", role: .cancel, action: onCancel)
.keyboardShortcut(.cancelAction)
+ .disabled(isPerforming)
+ if isPerforming { ProgressView().controlSize(.small) }
Button("Delete", role: .destructive) {
- onConfirm(deleteOnRemote ? selectedRemote : nil)
+ guard !isPerforming else { return }
+ let remote = deleteOnRemote ? selectedRemote : nil
+ isPerforming = true
+ errorMessage = nil
+ Task {
+ defer { isPerforming = false }
+ do { try await onConfirm(remote) }
+ catch { errorMessage = error.localizedDescription }
+ }
}
.keyboardShortcut(.defaultAction)
- .disabled(deleteOnRemote && selectedRemote.isEmpty)
+ .disabled(isPerforming || (deleteOnRemote && selectedRemote.isEmpty))
}
}
.padding(24)
.frame(minWidth: 420, idealWidth: 480)
+ .interactiveDismissDisabled(isPerforming)
.task {
- selectedRemote = confirmation.remotes.first ?? ""
+ let loaded = await loadRemotes()
+ guard !Task.isCancelled else { return }
+ remotes = loaded
+ selectedRemote = loaded.first ?? ""
+ isLoadingRemotes = false
}
}
}
diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift
index 551a2971..5d4a5f63 100644
--- a/macgit/Views/MainWindow/MainWindowView.swift
+++ b/macgit/Views/MainWindow/MainWindowView.swift
@@ -93,6 +93,7 @@ struct PendingSubtreeOperation: Identifiable, Equatable {
struct TagStartPoint: Equatable {
let hash: String
let message: String
+ var isBranch = false
var shortHash: String {
String(hash.prefix(7))
@@ -122,10 +123,10 @@ struct MainWindowView: View {
let initialShowsHistory: Bool?
@State var selectedItem: SidebarSelection? = .item(.fileStatus)
@State private var windowWidth: CGFloat = 0
- @State private var showingCommitSheet = false
+ @State var showingCommitSheet = false
@State var showingPullSheet = false
@State var showingPushSheet = false
- @State var pendingBranchForcePush: BranchForcePushPlan?
+ @State var pendingBranchForcePush: RepositorySheetRequest<(branch: String, remote: String?, remoteBranch: String?)>?
@State var pendingSheetForcePush: (branch: String, remote: String, remoteBranch: String)?
@State var showingFetchSheet = false
@State var showingAddSubmoduleSheet = false
@@ -139,19 +140,24 @@ struct MainWindowView: View {
@State var showingStashSheet = false
@State var showingCheckoutConfirmation = false
@State var branchToCheckout: String = ""
+ @State private var pendingHistoryCheckout: RepositorySheetRequest?
@State private var pendingRemoteBranchCheckout: RemoteBranchCheckoutTarget?
@State private var showingRenameBranchSheet = false
@State var branchToRename: String = ""
@State var showingDetachedHeadConfirmation = false
@State var tagToCheckout: String = ""
- @State private var displayedTagDetails: GitTagDetails?
+ @State var activeTagCheckoutCount = 0
+ @State private var displayedTagDetails: RepositorySheetRequest?
@State var pendingTagDeletion: PendingTagDeletion?
@State var pendingStashRef: String?
@State var pendingStashAction: StashAction?
+ @State private var activeStashActionCount = 0
@State var pendingStashPaths: [String] = []
@State var pendingProviderAccountSelection: PendingGitProviderAccountSelection?
@State var providerAccountSelectionContinuation: CheckedContinuation?
@State private var lfsTrackingPath: String?
+ @State private var hasOpenedPullRequests = false
+ @State private var hasOpenedGitLFS = false
@StateObject var syncState = SyncState()
@StateObject var undoManager = GitUndoManager()
@StateObject var pullRequestController: PullRequestController
@@ -179,7 +185,6 @@ struct MainWindowView: View {
@StateObject var commitRuleSyncController = RepositoryCommitRuleSyncController()
@StateObject var protectedBranchCommitController = ProtectedBranchCommitController()
@State var toolbarCommitMessage = ""
- @State var pendingToolbarCommit: (message: String, commitAllChanges: Bool)?
@State var repoSettings = RepoSettings.defaults(currentBranch: nil, remotes: [])
@State var gitFlowConfiguration = GitFlowConfiguration()
@State var gitFlowFinishCheckpoint: GitFlowFinishCheckpoint?
@@ -187,14 +192,14 @@ struct MainWindowView: View {
@State var gitFlowConfigurationIssue: GitFlowLocalStateIssue?
@State var didPerformInitialLoad = false
@State var pendingGitFlowTopicKind: GitFlowTopicKind?
- @State var pendingGitFlowFinishPlan: GitFlowFinishPlan?
+ @State var pendingGitFlowFinishPlan: RepositorySheetRequest?
@State var gitFlowCurrentBranch = ""
@State var gitFlowWorktreeRootURL: URL?
@State var pendingConfirmedUndo: (entry: GitUndoEntry, action: GitUndoMenuAction)?
@State var pendingCommitDropConfirmation: PendingCommitDropConfirmation?
@State var pendingBranchDropConfirmation: PendingBranchDropConfirmation?
- @State var pendingPushBranchDropConfirmation: PendingPushBranchDropConfirmation?
- @State var pendingTagMoveConfirmation: PendingTagMoveConfirmation?
+ @State var pendingPushBranchDropConfirmation: RepositorySheetRequest?
+ @State var pendingTagMoveConfirmation: RepositorySheetRequest<(tag: String, commit: GitDraggedCommit)>?
@State private var pendingForcePushTagConfirmation: PendingForcePushTagConfirmation?
@State private var pendingSubtreeOperation: PendingSubtreeOperation?
@State private var isPerformingBranchDropOperation = false
@@ -391,7 +396,7 @@ struct MainWindowView: View {
Text("Replace tag '\(confirmation.tag)' on '\(confirmation.remote)' with the local tag target? This rewrites the published tag and may retrigger release automation.")
}
}
- .replacingSheet(isPresented: $showingCommitSheet, onDismiss: performPendingToolbarCommit) { commitSheet }
+ .replacingSheet(isPresented: $showingCommitSheet) { commitSheet }
.replacingSheet(item: $customActionOutput) { presentation in
CustomActionOutputSheet(
result: presentation.result,
@@ -406,7 +411,7 @@ struct MainWindowView: View {
onClose: { customActionOutput = nil }
)
}
- .replacingSheet(item: $protectedBranchCommitController.warning, onDismiss: { protectedBranchCommitController.finish(.cancel) }) { warning in
+ .replacingSheet(item: protectedBranchWarningOutsideCommit, onDismiss: { protectedBranchCommitController.finish(.cancel) }) { warning in
ProtectedBranchCommitSheet(warning: warning, skipWarnings: $repoSettings.skipProtectedBranchCommitWarnings) { decision in
protectedBranchCommitController.finish(decision)
}
@@ -416,10 +421,8 @@ struct MainWindowView: View {
}
.replacingSheet(isPresented: $showingPullSheet) { pullSheet }
.replacingSheet(isPresented: $showingPushSheet, onDismiss: forcePushSheetDismissed) { pushSheet }
- .replacingSheet(item: $pendingBranchForcePush) { plan in
- BranchForcePushConfirmationSheet(plan: plan) {
- confirmBranchForcePush(plan)
- }
+ .replacingSheet(item: $pendingBranchForcePush) { request in
+ forcePushPreparationSheet(for: request)
}
.replacingSheet(isPresented: $showingFetchSheet) { fetchSheet }
.replacingSheet(isPresented: $showingAddSubmoduleSheet) { addSubmoduleSheet }
@@ -430,10 +433,13 @@ struct MainWindowView: View {
.replacingSheet(isPresented: $showingTagSheet, onDismiss: resetTagSheet) { tagSheet }
.replacingSheet(isPresented: $showingNewTagSheet) { newTagSheet }
.replacingSheet(isPresented: tagDetailsSheetPresented) {
- if let details = displayedTagDetails {
- TagDetailsSheet(details: details) {
- displayedTagDetails = nil
+ if let request = displayedTagDetails {
+ RepositorySheetLoadingView(title: "Tag Details", load: {
+ try await GitStatusService.shared.tagDetails(name: request.input, in: repositoryURL)
+ }, onCancel: { displayedTagDetails = nil }) { details in
+ TagDetailsSheet(details: details) { displayedTagDetails = nil }
}
+ .id(request.id)
}
}
.replacingSheet(isPresented: $showingMergeSheet) { mergeSheet }
@@ -448,10 +454,15 @@ struct MainWindowView: View {
.replacingSheet(item: $pendingGitFlowTopicKind) { kind in
startGitFlowSheet(for: kind)
}
- .replacingSheet(item: $pendingGitFlowFinishPlan) { plan in
- finishGitFlowSheet(for: plan)
+ .replacingSheet(item: $pendingGitFlowFinishPlan) { request in
+ RepositorySheetLoadingView(title: "Finish \(request.input.displayName)", load: {
+ try await loadGitFlowFinishPlan(request.input)
+ }, onCancel: { pendingGitFlowFinishPlan = nil }) { plan in
+ finishGitFlowSheet(for: plan)
+ }
+ .id(request.id)
}
- .replacingSheet(item: $pendingProviderAccountSelection) { selection in
+ .replacingSheet(item: providerAccountSelectionOutsideOperationSheet) { selection in
GitProviderAccountSelectionSheet(
selection: selection,
onSelect: { accountID in
@@ -494,30 +505,25 @@ struct MainWindowView: View {
onRunRepositoryOperation: runRepositoryOperation
)
}
- .confirmationDialog(
- "Push Branch",
- isPresented: Binding(
- get: { pendingPushBranchDropConfirmation != nil },
- set: { isPresented in
- if !isPresented {
+ .replacingSheet(item: $pendingPushBranchDropConfirmation) { request in
+ RepositorySheetLoadingView(title: "Push Branch", load: {
+ try await loadPushBranchDropConfirmation(request.input)
+ }, onCancel: { pendingPushBranchDropConfirmation = nil }) { confirmation in
+ GitDragActionConfirmationSheet(
+ title: "Push Branch",
+ message: "Push \(confirmation.branch) to \(confirmation.remoteBranch)?",
+ targetBranchName: confirmation.remoteBranch,
+ commits: [], primaryActionTitle: "Push",
+ onConfirm: {
pendingPushBranchDropConfirmation = nil
- }
- }
- ),
- titleVisibility: .visible
- ) {
- Button("Push") {
- guard let confirmation = pendingPushBranchDropConfirmation else { return }
- pendingPushBranchDropConfirmation = nil
- runRepositoryOperation("Pushing \(confirmation.branch) to \(confirmation.remote)...") {
- await performConfirmedBranchPush(confirmation)
- }
- }
- Button("Cancel", role: .cancel) {}
- } message: {
- if let confirmation = pendingPushBranchDropConfirmation {
- Text("Push \"\(confirmation.branch)\" to remote branch \"\(confirmation.remoteBranch)\"?")
+ runRepositoryOperation("Pushing \(confirmation.branch) to \(confirmation.remote)...") {
+ await performConfirmedBranchPush(confirmation)
+ }
+ },
+ onCancel: { pendingPushBranchDropConfirmation = nil }
+ )
}
+ .id(request.id)
}
.replacingSheet(isPresented: $showingRenameBranchSheet) { renameSheet }
.replacingSheet(isPresented: $showingCheckoutConfirmation) {
@@ -527,6 +533,27 @@ struct MainWindowView: View {
}
}
}
+ .replacingSheet(item: $pendingHistoryCheckout) { request in
+ RepositorySheetLoadingView(title: "Checkout \(request.input)", load: {
+ let remotes = await GitStatusService.shared.remotes(in: repositoryURL)
+ return remoteBranchCheckoutTarget(for: request.input, remotes: remotes)
+ }, onCancel: { pendingHistoryCheckout = nil }) { target in
+ if let target {
+ RemoteBranchCheckoutSheet(target: target) { localBranch, trackRemote in
+ runRepositoryOperation("Checking out \(localBranch)...") {
+ await performRemoteBranchCheckout(target: target, localBranch: localBranch, trackRemote: trackRemote)
+ }
+ }
+ } else {
+ CheckoutConfirmationSheet(branchName: request.input) { stash in
+ runRepositoryOperation("Checking out \(request.input)...") {
+ await performCheckout(ref: request.input, stash: stash)
+ }
+ }
+ }
+ }
+ .id(request.id)
+ }
.replacingSheet(item: $pendingRemoteBranchCheckout) { target in
RemoteBranchCheckoutSheet(target: target) { localBranch, trackRemote in
runRepositoryOperation("Checking out \(localBranch)...") {
@@ -730,6 +757,7 @@ struct MainWindowView: View {
)
}
.onChange(of: selectedItem) { _, newItem in
+ if newItem != .item(.gitLFS) { lfsTrackingPath = nil }
if case .branch(let name) = newItem {
selectedBranchName = name
} else if case .tag(let name) = newItem {
@@ -910,17 +938,7 @@ struct MainWindowView: View {
Task { await performTagCheckout(tag: ref) }
}
} else {
- Task {
- let remotes = await GitStatusService.shared.remotes(in: repositoryURL)
- if let target = remoteBranchCheckoutTarget(for: ref, remotes: remotes) {
- await MainActor.run { pendingRemoteBranchCheckout = target }
- } else {
- await MainActor.run {
- branchToCheckout = ref
- showingCheckoutConfirmation = true
- }
- }
- }
+ pendingHistoryCheckout = RepositorySheetRequest(input: ref)
}
}
}
@@ -942,6 +960,7 @@ struct MainWindowView: View {
currentBranchFallbackSyncStatus: currentBranchFallbackSyncStatus,
defaultRemoteName: repoSettings.defaultRemoteName,
isAccountMenuDisabled: operationProgress.activeOperation != nil,
+ gitFlowRoleDisplayMode: gitFlowConfiguration.roleDisplayMode,
gitFlowConfiguration: gitFlowConfiguration,
gitFlowFinishCheckpoint: gitFlowFinishCheckpoint,
gitFlowRecoveryIssue: gitFlowRecoveryIssue,
@@ -1035,22 +1054,16 @@ struct MainWindowView: View {
showingRenameBranchSheet = true
},
onRequestCreatePullRequest: { branch in
- Task {
- await prepareCreatePullRequest(branch: branch)
- }
+ prepareCreatePullRequest(branch: branch)
},
onRequestCreateBranchFromBranch: { branch in
presentBranchSheet(startPoint: .branch(branch))
},
onRequestCreateTagFromBranch: { branch in
- runRepositoryOperation("Preparing tag for \(branch)...") {
- await presentTagSheetFromBranchTip(branch)
- }
+ presentTagSheetFromBranchTip(branch)
},
onRequestTagDetails: { tag in
- runRepositoryOperation("Loading details for \(tag)...") {
- await presentTagDetails(tag)
- }
+ presentTagDetails(tag)
},
onRequestDiffTagAgainstCurrent: { tag in
clearReferenceDiff()
@@ -1088,15 +1101,7 @@ struct MainWindowView: View {
)
},
onRequestDeleteTag: { tag in
- Task {
- let remotes = await GitStatusService.shared.remotes(in: repositoryURL)
- await MainActor.run {
- pendingTagDeletion = PendingTagDeletion(
- tag: tag,
- remotes: remotes.sorted()
- )
- }
- }
+ pendingTagDeletion = PendingTagDeletion(tag: tag, remotes: [])
},
onRequestRebaseOnto: { branch in
runRepositoryOperation("Rebasing onto \(branch)...") {
@@ -1179,9 +1184,7 @@ struct MainWindowView: View {
showingNewTagSheet = true
},
onRequestCreatePullRequestFromWorkspace: {
- Task {
- await prepareCreatePullRequest()
- }
+ prepareCreatePullRequest()
},
onRequestShowSubtreeInFinder: { path in
NSWorkspace.shared.activateFileViewerSelecting([path])
@@ -1366,6 +1369,46 @@ struct MainWindowView: View {
case .item(.reflog):
EmptyView()
case .item(.pullRequests):
+ EmptyView()
+ case .stash(let ref):
+ StashView(
+ repositoryURL: repositoryURL,
+ stashRef: ref,
+ isWorkSuspended: activeStashActionCount > 0
+ || (pendingStashRef != nil && pendingStashAction != nil)
+ )
+ case .submodule(let path):
+ SubmoduleDetailPlaceholderView {
+ openWorktreeInNewWindow(
+ at: repositoryURL.appendingPathComponent(path, isDirectory: true)
+ )
+ }
+ case .subtree:
+ EmptyStateView(message: "Select a subtree action from the sidebar")
+ case .item(.search):
+ SearchView(repositoryURL: repositoryURL)
+ case .item(.gitLFS):
+ EmptyView()
+ case .item(.gitFlow):
+ GitFlowDashboardView(
+ configuration: gitFlowConfiguration,
+ currentBranch: gitFlowCurrentBranch,
+ checkpoint: gitFlowFinishCheckpoint,
+ recoveryIssue: gitFlowRecoveryIssue,
+ commandState: gitFlowCommandState,
+ perform: handleGitFlowMenuAction
+ )
+ case .none:
+ EmptyStateView(message: "Select an item from the sidebar")
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var cachedRepositoryScreens: some View {
+ if selectedItem == .item(.pullRequests) || hasOpenedPullRequests {
+ Group {
switch pullRequestAccessDecision {
case .allowed:
PullRequestListView(
@@ -1374,9 +1417,7 @@ struct MainWindowView: View {
accountConnectionErrorMessage: providerAccountController.errorMessage,
onReconnectAccount: onOpenConnections,
onRequestCreatePullRequest: {
- Task {
- await prepareCreatePullRequest()
- }
+ prepareCreatePullRequest()
},
authorizeAction: { await authorizePullRequestAccess() }
)
@@ -1398,46 +1439,24 @@ struct MainWindowView: View {
ProgressView("Checking Pull Request access…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
- case .stash(let ref):
- StashView(repositoryURL: repositoryURL, stashRef: ref)
- case .submodule(let path):
- SubmoduleDetailPlaceholderView {
- openWorktreeInNewWindow(
- at: repositoryURL.appendingPathComponent(path, isDirectory: true)
- )
- }
- case .subtree:
- EmptyStateView(message: "Select a subtree action from the sidebar")
- case .item(.search):
- SearchView(repositoryURL: repositoryURL)
- case .item(.gitLFS):
- GitLFSAccessView(repositoryURL: repositoryURL) { authorize in
- GitLFSView(repositoryURL: repositoryURL, initialPath: lfsTrackingPath, credentialResolver: { @MainActor remote in
- await credentialResolverForRemoteOperation(remotes: [remote])
- }, refreshRepository: {
- await syncState.refresh(repositoryURL: repositoryURL, force: true)
- }, authorizeAction: authorize)
- .onDisappear { lfsTrackingPath = nil }
- }
- .id(repositoryURL)
- case .item(.gitFlow):
- GitFlowDashboardView(
- configuration: gitFlowConfiguration,
- currentBranch: gitFlowCurrentBranch,
- checkpoint: gitFlowFinishCheckpoint,
- recoveryIssue: gitFlowRecoveryIssue,
- commandState: gitFlowCommandState,
- perform: handleGitFlowMenuAction
- )
- case .none:
- EmptyStateView(message: "Select an item from the sidebar")
- }
}
+ .onAppear { hasOpenedPullRequests = true }
+ .repositoryScreenVisibility(isActive: selectedItem == .item(.pullRequests))
+ }
+
+ if selectedItem == .item(.gitLFS) || hasOpenedGitLFS {
+ GitLFSAccessView(repositoryURL: repositoryURL) { authorize in
+ GitLFSView(repositoryURL: repositoryURL, initialPath: lfsTrackingPath, credentialResolver: { @MainActor remote in
+ await credentialResolverForRemoteOperation(remotes: [remote])
+ }, refreshRepository: {
+ await syncState.refresh(repositoryURL: repositoryURL, force: true)
+ }, authorizeAction: authorize)
+ }
+ .id(repositoryURL)
+ .onAppear { hasOpenedGitLFS = true }
+ .repositoryScreenVisibility(isActive: selectedItem == .item(.gitLFS))
}
- }
- @ViewBuilder
- private var cachedRepositoryScreens: some View {
FileStatusView(
repositoryURL: repositoryURL,
isActive: isFileStatusActive,
@@ -1475,6 +1494,9 @@ struct MainWindowView: View {
HistoryScreen(
repositoryURL: repositoryURL,
isActive: isHistoryActive,
+ isWorkSuspended: showingCheckoutConfirmation || pendingRemoteBranchCheckout != nil
+ || showingDetachedHeadConfirmation || pendingHistoryCheckout != nil
+ || activeTagCheckoutCount > 0,
selectedBranch: selectedBranchName,
branchFilter: $appState.historyBranchFilter,
includeRemotes: $appState.historyIncludeRemotes,
@@ -1945,28 +1967,35 @@ struct MainWindowView: View {
}
func showCommitSheetIfNoConflicts() {
+ guard !syncState.isAnySyncing else {
+ syncState.showInfo("Wait for the current Git operation to finish before committing.")
+ return
+ }
Task {
- if syncState.isAnySyncing {
- syncState.showInfo("Wait for the current Git operation to finish before committing.")
- return
- }
if await syncState.checkConflicts(repositoryURL: repositoryURL) { return }
+ guard !Task.isCancelled, !syncState.isAnySyncing else { return }
showingCommitSheet = true
}
}
- func commitFromToolbar(message: String, commitAllChanges: Bool) async {
- guard await authorizeProtectedBranchCommit() else { return }
- let previousHead = await GitStatusService.shared.tipHash(for: "HEAD", in: repositoryURL)
- await syncState.performCommit(
- message: message,
- repositoryURL: repositoryURL,
- undoManager: undoManager,
- commitAllChanges: commitAllChanges
+ func commitFromToolbar(message: String, commitAllChanges: Bool) async throws -> Bool {
+ guard !syncState.isAnySyncing else {
+ throw GitError.commandFailed("Wait for the current Git operation to finish before committing.")
+ }
+ guard !(await GitStatusService.shared.hasConflicts(in: repositoryURL)) else {
+ throw GitError.commandFailed("There are unresolved merge conflicts. Please resolve them before committing.")
+ }
+ guard await authorizeProtectedBranchCommit() else { return false }
+ var failureMessage: String?
+ let succeeded = await syncState.performCommit(
+ message: message, repositoryURL: repositoryURL, undoManager: undoManager,
+ commitAllChanges: commitAllChanges, onError: { failureMessage = $0 }
)
- if await GitStatusService.shared.tipHash(for: "HEAD", in: repositoryURL) != previousHead {
- toolbarCommitMessage = ""
+ guard succeeded else {
+ throw GitError.commandFailed(failureMessage ?? "Could not commit changes.")
}
+ toolbarCommitMessage = ""
+ return true
}
func performCommitDropCherryPick(_ confirmation: PendingCommitDropConfirmation) async {
@@ -2175,6 +2204,8 @@ struct MainWindowView: View {
}
func performStashAction(ref: String, action: StashAction, deleteAfterApplying: Bool) async {
+ activeStashActionCount += 1
+ defer { activeStashActionCount -= 1 }
do {
switch action {
case .apply:
@@ -2398,25 +2429,10 @@ struct MainWindowView: View {
return repoSettings.defaultPullBranch
}
- func presentTagSheetFromBranchTip(_ sourceBranch: String) async {
- let commits = await GitStatusService.shared.commitHistory(
- branch: sourceBranch,
- limit: 1,
- in: repositoryURL
- )
-
- await MainActor.run {
- if let commit = commits.first {
- presentTagSheet(
- startPoint: TagStartPoint(
- hash: commit.hash,
- message: commit.message
- )
- )
- } else {
- syncState.showError("Could not find the last commit for \(sourceBranch).")
- }
- }
+ func presentTagSheetFromBranchTip(_ sourceBranch: String) {
+ presentTagSheet(startPoint: TagStartPoint(
+ hash: "refs/heads/" + sourceBranch, message: sourceBranch, isBranch: true
+ ))
}
func presentTagSheetFromCommit(_ commit: GitDraggedCommit) {
@@ -2430,51 +2446,35 @@ struct MainWindowView: View {
private func presentTagSheet(startPoint: TagStartPoint) {
tagStartPoint = startPoint
- Task { @MainActor in
- await Task.yield()
- showingTagSheet = true
- }
+ showingTagSheet = true
}
- func presentTagMoveConfirmation(tagName: String, commit: GitDraggedCommit) async {
+ func presentTagMoveConfirmation(tagName: String, commit: GitDraggedCommit) {
guard !syncState.isAnySyncing else {
- await MainActor.run {
- syncState.showInfo("Wait for the current Git operation to finish before moving a tag.")
- }
+ syncState.showInfo("Wait for the current Git operation to finish before moving a tag.")
return
}
+ pendingTagMoveConfirmation = RepositorySheetRequest(input: (tag: tagName, commit: commit))
+ }
- do {
- async let details = GitStatusService.shared.tagDetails(name: tagName, in: repositoryURL)
- async let remotes = GitStatusService.shared.remotes(in: repositoryURL)
- let (currentCommit, remoteNames) = try await (details, remotes)
-
- guard currentCommit.commitHash != commit.hash else {
- await MainActor.run {
- syncState.showInfo("Tag \(tagName) already points to this commit.")
- }
- return
- }
-
- await MainActor.run {
- pendingTagMoveConfirmation = PendingTagMoveConfirmation(
- tagName: tagName,
- currentCommit: currentCommit,
- newCommit: commit,
- remotes: remoteNames.sorted()
- )
- }
- } catch {
- await MainActor.run {
- syncState.showError(error.localizedDescription)
- }
+ func loadTagMoveConfirmation(tagName: String, commit: GitDraggedCommit) async throws -> PendingTagMoveConfirmation {
+ async let details = GitStatusService.shared.tagDetails(name: tagName, in: repositoryURL)
+ async let remotes = GitStatusService.shared.remotes(in: repositoryURL)
+ let (currentCommit, remoteNames) = try await (details, remotes)
+ try Task.checkCancellation()
+ guard currentCommit.commitHash != commit.hash else {
+ throw GitError.commandFailed("Tag \(tagName) already points to this commit.")
}
+ return PendingTagMoveConfirmation(
+ tagName: tagName, currentCommit: currentCommit,
+ newCommit: commit, remotes: remoteNames.sorted()
+ )
}
func performTagMove(
_ confirmation: PendingTagMoveConfirmation,
forcePushRemote: String?
- ) async {
+ ) async throws {
do {
try await GitStatusService.shared.moveTag(
name: confirmation.tagName,
@@ -2494,46 +2494,25 @@ struct MainWindowView: View {
)
}
- await syncState.refresh(repositoryURL: repositoryURL)
- NotificationCenter.default.post(
- name: .repositoryDidChange,
- object: nil,
- userInfo: ["repositoryURL": repositoryURL]
- )
} catch {
- await MainActor.run {
- syncState.showError(error.localizedDescription)
- }
+ refreshAfterTagAction()
+ throw error
}
}
- func createTagFromBranch() async {
- guard let startPoint = tagStartPoint else { return }
- let name = tagNameInput.trimmingCharacters(in: .whitespaces)
- guard !name.isEmpty else { return }
-
- do {
- try await GitStatusService.shared.createTag(
- name: name,
- commit: startPoint.hash,
- annotated: false,
- message: nil,
- in: repositoryURL
- )
- await MainActor.run {
- showingTagSheet = false
- }
- await syncState.refresh(repositoryURL: repositoryURL)
- NotificationCenter.default.post(
- name: .repositoryDidChange,
- object: nil,
- userInfo: ["repositoryURL": repositoryURL]
- )
- } catch {
- await MainActor.run {
- syncState.showError(error.localizedDescription)
+ func createTagAtReference(name: String, startPoint: TagStartPoint) async throws {
+ let reference: String
+ if startPoint.isBranch {
+ guard let hash = await GitStatusService.shared.tipHash(for: startPoint.hash, in: repositoryURL) else {
+ throw GitError.commandFailed("Could not find the last commit for \(startPoint.message).")
}
+ reference = hash
+ } else {
+ reference = startPoint.hash
}
+ try await GitStatusService.shared.createTag(
+ name: name, commit: reference, annotated: false, message: nil, in: repositoryURL
+ )
}
func createTag(from request: TagCreationRequest) async throws {
@@ -2561,49 +2540,39 @@ struct MainWindowView: View {
)
}
- private func presentTagDetails(_ tag: String) async {
- do {
- let details = try await GitStatusService.shared.tagDetails(
- name: tag,
- in: repositoryURL
- )
- await MainActor.run {
- displayedTagDetails = details
- }
- } catch {
- await MainActor.run {
- syncState.showError(error.localizedDescription)
- }
- }
+ private func presentTagDetails(_ tag: String) {
+ displayedTagDetails = RepositorySheetRequest(input: tag)
}
- func deleteTag(_ tag: String, remote: String? = nil) async {
+ func deleteTag(_ tag: String, remote: String? = nil) async throws {
+ let credentialResolver: GitProviderCredentialResolver?
+ if let remote, !remote.isEmpty {
+ guard let resolver = await credentialResolverForRemoteOperation(remotes: [remote]) else {
+ throw GitError.commandFailed("Account selection was cancelled.")
+ }
+ credentialResolver = resolver
+ } else {
+ credentialResolver = nil
+ }
do {
try await GitStatusService.shared.deleteTag(name: tag, in: repositoryURL)
if let remote, !remote.isEmpty {
- let credentialResolver = await credentialResolverForRemoteOperation(remotes: [remote])
try await GitStatusService.shared.deleteRemoteTag(
- name: tag,
- remote: remote,
- in: repositoryURL,
- credentialResolver: credentialResolver
+ name: tag, remote: remote, in: repositoryURL, credentialResolver: credentialResolver
)
}
- await MainActor.run {
- if selectedItem == .tag(tag) {
- selectedItem = .item(.history)
- }
- }
- await syncState.refresh(repositoryURL: repositoryURL)
- NotificationCenter.default.post(
- name: .repositoryDidChange,
- object: nil,
- userInfo: ["repositoryURL": repositoryURL]
- )
+ if selectedItem == .tag(tag) { selectedItem = .item(.history) }
} catch {
- await MainActor.run {
- syncState.showError(error.localizedDescription)
- }
+ refreshAfterTagAction()
+ throw error
+ }
+ }
+
+ func refreshAfterTagAction() {
+ Task {
+ await syncState.refresh(repositoryURL: repositoryURL)
+ NotificationCenter.default.post(name: .repositoryDidChange, object: nil,
+ userInfo: ["repositoryURL": repositoryURL])
}
}
diff --git a/macgit/Views/MainWindow/Sidebar/SidebarGitFlowRolePill.swift b/macgit/Views/MainWindow/Sidebar/SidebarGitFlowRolePill.swift
new file mode 100644
index 00000000..7143f520
--- /dev/null
+++ b/macgit/Views/MainWindow/Sidebar/SidebarGitFlowRolePill.swift
@@ -0,0 +1,61 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import AppKit
+import CoreText
+
+final class SidebarGitFlowRolePill: NSView {
+ private var text = ""
+ private var font = NSFont.systemFont(ofSize: 9, weight: .semibold)
+ private var role: GitFlowBranchRole?
+ private var scale: CGFloat = 1
+
+ func configure(role: GitFlowBranchRole?, mode: GitFlowRoleDisplayMode, scale: CGFloat) {
+ self.role = role
+ self.scale = scale
+ isHidden = role == nil || mode == .hidden
+ let name = role?.rawValue ?? ""
+ text = mode == .initial ? String(name.prefix(1)) : name
+ toolTip = name
+ font = .systemFont(ofSize: 9 * scale, weight: .semibold)
+ setAccessibilityLabel(role?.rawValue ?? "")
+ invalidateIntrinsicContentSize()
+ needsLayout = true
+ needsDisplay = true
+ }
+
+ override var intrinsicContentSize: NSSize {
+ NSSize(width: ceil((text as NSString).size(withAttributes: [.font: font]).width) + 12 * scale, height: 17 * scale)
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ color.withAlphaComponent(0.16).setFill()
+ NSBezierPath(roundedRect: bounds, xRadius: bounds.height / 2, yRadius: bounds.height / 2).fill()
+ guard let context = NSGraphicsContext.current?.cgContext else { return }
+ let line = CTLineCreateWithAttributedString(NSAttributedString(
+ string: text, attributes: [.font: font, .foregroundColor: color]
+ ))
+ let glyphBounds = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
+ context.saveGState()
+ context.textMatrix = .identity
+ context.textPosition = CGPoint(x: bounds.midX - glyphBounds.midX,
+ y: bounds.midY - glyphBounds.midY)
+ CTLineDraw(line, context)
+ context.restoreGState()
+ }
+
+ override func viewDidChangeEffectiveAppearance() {
+ super.viewDidChangeEffectiveAppearance()
+ needsDisplay = true
+ }
+
+ private var color: NSColor {
+ switch role {
+ case .main: .systemCyan
+ case .develop: .systemYellow
+ case .feature: .systemPurple
+ case .bugfix: .systemOrange
+ case .hotfix: .systemRed
+ case .release: .systemGreen
+ case nil: .secondaryLabelColor
+ }
+ }
+}
diff --git a/macgit/Views/MainWindow/Sidebar/SidebarNativeInput.swift b/macgit/Views/MainWindow/Sidebar/SidebarNativeInput.swift
index e8429d0d..1acc0b2f 100644
--- a/macgit/Views/MainWindow/Sidebar/SidebarNativeInput.swift
+++ b/macgit/Views/MainWindow/Sidebar/SidebarNativeInput.swift
@@ -22,6 +22,7 @@ struct SidebarNativeInput: Equatable {
let stashes: [StashEntry]
let submodules: [GitSubmoduleEntry]
let subtrees: [GitSubtreeEntry]
+ var gitFlowRoleDisplayMode: GitFlowRoleDisplayMode = .full
var dataRepositoryURL: URL? = nil
}
@@ -43,6 +44,7 @@ extension SidebarView {
remotes: remoteNames, remoteBranches: branchesByRemote, upstream: upstreamByBranch,
gitFlow: gitFlowConfiguration, gitFlowCommands: gitFlowCommandState,
worktrees: worktreeEntries, stashes: stashEntries, submodules: submoduleEntries, subtrees: subtreeEntries,
+ gitFlowRoleDisplayMode: gitFlowRoleDisplayMode,
dataRepositoryURL: sidebarDataRepositoryURL)
}
}
diff --git a/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift b/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift
index 8ac0b329..bacaa9db 100644
--- a/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift
+++ b/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift
@@ -15,6 +15,8 @@ struct SidebarNativeRow {
var subtitle = ""
var icon = ""
var badge = ""
+ var gitFlowRole: GitFlowBranchRole?
+ var gitFlowRoleDisplayMode: GitFlowRoleDisplayMode = .full
var indent = 0
var header = false
var emphasized = false
@@ -380,7 +382,7 @@ struct SidebarNativeList: NSViewRepresentable {
final class Table: NSTableView {
weak var owner: Coordinator?
override func menu(for event: NSEvent) -> NSMenu? {
- owner?.menu(row: row(at: convert(event.locationInWindow, from: nil)))
+ return owner?.menu(row: row(at: convert(event.locationInWindow, from: nil)))
}
override func draggingExited(_ sender: NSDraggingInfo?) {
super.draggingExited(sender)
@@ -499,6 +501,7 @@ struct SidebarNativeList: NSViewRepresentable {
let title = NSTextField(labelWithString: "")
let subtitle = NSTextField(labelWithString: "")
let badge = NSTextField(labelWithString: "")
+ let rolePill = SidebarGitFlowRolePill()
let button = NSButton()
let progress = NSProgressIndicator()
var onAccessory: (() -> Void)?
@@ -509,6 +512,7 @@ struct SidebarNativeList: NSViewRepresentable {
super.init(frame: frameRect)
SidebarSignpost.event("NativeCellCreate")
for view in [icon, title, subtitle, badge, button, progress] { addSubview(view) }
+ addSubview(rolePill)
progress.style = .spinning
progress.controlSize = .small
progress.isDisplayedWhenStopped = false
@@ -540,6 +544,7 @@ struct SidebarNativeList: NSViewRepresentable {
subtitle.stringValue = value.subtitle
subtitle.font = .systemFont(ofSize: 10 * scale)
subtitle.textColor = .secondaryLabelColor
+ rolePill.configure(role: value.gitFlowRole, mode: value.gitFlowRoleDisplayMode, scale: scale)
badge.stringValue = value.badge
badge.font = .systemFont(ofSize: 10 * scale)
badge.textColor = .secondaryLabelColor
@@ -550,7 +555,7 @@ struct SidebarNativeList: NSViewRepresentable {
button.isHidden = value.accessory.isEmpty
button.toolTip = value.accessoryLabel
button.setAccessibilityLabel(value.accessoryLabel)
- setAccessibilityLabel([value.title, value.subtitle, value.badge].filter { !$0.isEmpty }.joined(separator: ", "))
+ setAccessibilityLabel([value.title, value.subtitle, value.gitFlowRole?.rawValue ?? "", value.badge].filter { !$0.isEmpty }.joined(separator: ", "))
toolTip = value.subtitle.isEmpty ? value.title : "\(value.title)\n\(value.subtitle)"
needsLayout = true
}
@@ -570,9 +575,18 @@ struct SidebarNativeList: NSViewRepresentable {
let badgeSize = badge.intrinsicContentSize
let badgeWidth = appearance.badge.isEmpty ? 0 : min(ceil(badgeSize.width) + 2, floor(bounds.width * 0.45))
let badgeHeight = ceil(max(badgeSize.height, 16 * scale))
- badge.frame = NSRect(x: floor(bounds.width - 8 - accessoryWidth - badgeWidth), y: floor((bounds.height - badgeHeight) / 2), width: badgeWidth, height: badgeHeight)
+ let pillWidth = rolePill.isHidden ? 0 : rolePill.intrinsicContentSize.width
+ let pillGap: CGFloat = pillWidth > 0 ? 6 * scale : 0
+ let statusGap: CGFloat = badgeWidth > 0 && pillWidth > 0 ? 6 * scale : 0
+ let pillRight = bounds.width - 8 - accessoryWidth
+ badge.frame = NSRect(x: floor(pillRight - pillWidth - statusGap - badgeWidth),
+ y: floor((bounds.height - badgeHeight) / 2),
+ width: badgeWidth, height: badgeHeight)
+ rolePill.frame = NSRect(x: pillRight - pillWidth,
+ y: (bounds.height - rolePill.intrinsicContentSize.height) / 2,
+ width: pillWidth, height: rolePill.intrinsicContentSize.height)
let x = start + iconSize + 6
- let width = max(0, bounds.width - x - badgeWidth - accessoryWidth - 14)
+ let width = max(0, bounds.width - x - badgeWidth - accessoryWidth - pillWidth - pillGap - statusGap - 14)
title.frame = NSRect(x: x, y: appearance.subtitle.isEmpty ? (bounds.height - 18 * scale) / 2 : bounds.height / 2, width: width, height: 18 * scale)
subtitle.frame = NSRect(x: x, y: bounds.height / 2 - 15 * scale, width: width, height: 15 * scale)
}
diff --git a/macgit/Views/MainWindow/SidebarView+NativeRows.swift b/macgit/Views/MainWindow/SidebarView+NativeRows.swift
index 5f3c8bce..68a8632a 100644
--- a/macgit/Views/MainWindow/SidebarView+NativeRows.swift
+++ b/macgit/Views/MainWindow/SidebarView+NativeRows.swift
@@ -83,19 +83,20 @@ extension SidebarView {
let sync = SidebarBranchSyncBadgeResolver.status(for: item.fullPath, currentBranch: currentBranch,
branchSyncStatus: branchSyncStatus, currentBranchFallbackSyncStatus: currentBranchFallbackSyncStatus)
var badges: [String] = []
- if let role = GitFlowBranchRoleResolver().role(for: item.fullPath, configuration: gitFlowConfiguration) { badges.append(role.rawValue) }
+ let role = GitFlowBranchRoleResolver().role(for: item.fullPath, configuration: gitFlowConfiguration)
if let sync {
if sync.ahead > 0 { badges.append("↑\(sync.ahead)") }
if sync.behind > 0 { badges.append("↓\(sync.behind)") }
}
if current { badges.append("HEAD") }
var row = SidebarNativeRow(id: .init(section: .branches, kind: "ref", key: item.fullPath),
- appearance: .init(title: item.name, icon: current ? "circle.fill" : "", badge: badges.joined(separator: " "), indent: item.indent, emphasized: current),
+ appearance: .init(title: item.name, icon: current ? "circle.fill" : "", badge: badges.joined(separator: " "), gitFlowRole: role, indent: item.indent, emphasized: current),
selection: .branch(item.fullPath), select: { branchSectionActions.select(.branch(item.fullPath)) },
activate: { if !current { branchSectionActions.checkout(item.fullPath) } },
menu: { nativeBranchMenu(item.fullPath) },
payload: { GitDragPayload.branch(item.fullPath, repositoryURL: repositoryURL) },
dropTarget: .localBranch(name: item.fullPath, isCurrent: current))
+ row.appearance.gitFlowRoleDisplayMode = gitFlowRoleDisplayMode
if current, let status = currentBranchIntegrationStatus, status.predictsBaseConflict {
row.appearance.accessory = "exclamationmark.triangle.fill"
row.appearance.accessoryLabel = "Current branch integration status"
diff --git a/macgit/Views/MainWindow/SidebarView.swift b/macgit/Views/MainWindow/SidebarView.swift
index d5acda8c..0399a6e2 100644
--- a/macgit/Views/MainWindow/SidebarView.swift
+++ b/macgit/Views/MainWindow/SidebarView.swift
@@ -36,6 +36,7 @@ struct SidebarView: View {
let currentBranchFallbackSyncStatus: BranchSyncStatus?
let defaultRemoteName: String?
let isAccountMenuDisabled: Bool
+ let gitFlowRoleDisplayMode: GitFlowRoleDisplayMode
let gitFlowConfiguration: GitFlowConfiguration
let gitFlowFinishCheckpoint: GitFlowFinishCheckpoint?
let gitFlowRecoveryIssue: GitFlowLocalStateIssue?
@@ -210,6 +211,7 @@ struct SidebarView: View {
currentBranchFallbackSyncStatus: BranchSyncStatus? = nil,
defaultRemoteName: String? = nil,
isAccountMenuDisabled: Bool = false,
+ gitFlowRoleDisplayMode: GitFlowRoleDisplayMode = .full,
gitFlowConfiguration: GitFlowConfiguration = GitFlowConfiguration(),
gitFlowFinishCheckpoint: GitFlowFinishCheckpoint? = nil,
gitFlowRecoveryIssue: GitFlowLocalStateIssue? = nil,
@@ -283,6 +285,7 @@ struct SidebarView: View {
self.currentBranchFallbackSyncStatus = currentBranchFallbackSyncStatus
self.defaultRemoteName = defaultRemoteName
self.isAccountMenuDisabled = isAccountMenuDisabled
+ self.gitFlowRoleDisplayMode = gitFlowRoleDisplayMode
self.gitFlowConfiguration = gitFlowConfiguration
self.gitFlowFinishCheckpoint = gitFlowFinishCheckpoint
self.gitFlowRecoveryIssue = gitFlowRecoveryIssue
diff --git a/macgit/Views/PullRequests/CreatePullRequestPreparationView.swift b/macgit/Views/PullRequests/CreatePullRequestPreparationView.swift
new file mode 100644
index 00000000..e2ac76fa
--- /dev/null
+++ b/macgit/Views/PullRequests/CreatePullRequestPreparationView.swift
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import SwiftUI
+
+struct CreatePullRequestPreparationView: View {
+ @ObservedObject var controller: PullRequestController
+ @ObservedObject var windowController: CreatePullRequestWindowController
+ let repositoryURL: URL
+ let loadDraft: @MainActor () async throws -> PullRequestDraftSeed
+ let authorizeAction: () async -> Bool
+ let onGenerateText: PullRequestAIGenerationAction
+ let onCancel: () -> Void
+ let onCreate: (PullRequestDraft) async -> Void
+
+ var body: some View {
+ RepositorySheetLoadingView(title: "Create Pull Request", load: loadDraft,
+ onCancel: onCancel, cancelRequestID: windowController.cancelRequestID) { seed in
+ CreatePullRequestWindowContent(
+ controller: controller, windowController: windowController,
+ seed: seed, repositoryURL: repositoryURL,
+ authorizeAction: authorizeAction, onGenerateText: onGenerateText,
+ onCancel: onCancel, onCreate: onCreate
+ )
+ }
+ }
+}
diff --git a/macgit/Views/Stashes/StashView.swift b/macgit/Views/Stashes/StashView.swift
index 7c775888..1535cbc0 100644
--- a/macgit/Views/Stashes/StashView.swift
+++ b/macgit/Views/Stashes/StashView.swift
@@ -25,6 +25,7 @@ import SwiftUI
struct StashView: View {
let repositoryURL: URL
let stashRef: String
+ var isWorkSuspended = false
@State private var fileChanges: [CommitFileChange] = []
@State private var stashEntry: StashEntry?
@@ -34,6 +35,8 @@ struct StashView: View {
@State private var isLoading = true
@State private var errorMessage: String?
@State private var showingError = false
+ @State private var refreshVersion = 0
+ @State private var loadGeneration = UUID()
@State private var imageComparisonMode: ImageComparisonMode = .overlay
@@ -73,10 +76,12 @@ struct StashView: View {
.id(stashRef)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
- .task(id: stashRef) {
+ .task(id: StashLoadRequest(ref: stashRef, suspended: isWorkSuspended, version: refreshVersion)) {
+ guard !isWorkSuspended else { return }
await loadStash(for: stashRef)
}
- .task(id: selectedFile?.id) {
+ .task(id: StashDiffRequest(ref: stashRef, fileID: selectedFile?.id, suspended: isWorkSuspended)) {
+ guard !isWorkSuspended else { return }
guard let selectedFile else {
diffHunks = []
isDiffLoading = false
@@ -86,9 +91,7 @@ struct StashView: View {
}
.onReceive(NotificationCenter.default.publisher(for: .repositoryDidChange)) { notification in
if let url = notification.userInfo?["repositoryURL"] as? URL, url == repositoryURL {
- Task {
- await loadStash(for: stashRef)
- }
+ refreshVersion += 1
}
}
.alert("Error", isPresented: $showingError, actions: {
@@ -199,24 +202,38 @@ struct StashView: View {
}
}
+ private struct StashLoadRequest: Equatable {
+ let ref: String
+ let suspended: Bool
+ let version: Int
+ }
+
+ private struct StashDiffRequest: Equatable {
+ let ref: String
+ let fileID: UUID?
+ let suspended: Bool
+ }
+
private func loadStash(for ref: String) async {
+ guard !Task.isCancelled else { return }
+ let generation = UUID()
+ loadGeneration = generation
isLoading = true
- defer { isLoading = false }
-
- await MainActor.run {
- fileChanges = []
- selectedFile = nil
- diffHunks = []
+ defer {
+ if loadGeneration == generation, !Task.isCancelled { isLoading = false }
}
+ fileChanges = []
+ stashEntry = nil
+ selectedFile = nil
+ diffHunks = []
async let changesRequest = GitStatusService.shared.changedFiles(in: ref, in: repositoryURL)
async let stashesRequest = GitStatusService.shared.stashes(in: repositoryURL)
let (changes, stashes) = await (changesRequest, stashesRequest)
- await MainActor.run {
- fileChanges = changes
- stashEntry = stashes.first { $0.ref == ref }
- selectedFile = changes.first
- }
+ guard !Task.isCancelled, loadGeneration == generation, stashRef == ref else { return }
+ fileChanges = changes
+ stashEntry = stashes.first { $0.ref == ref }
+ selectedFile = changes.first
}
private func loadDiff(for file: CommitFileChange, stashRef: String) async {
diff --git a/macgit/Views/Workflow/FinishGitFlowSheet.swift b/macgit/Views/Workflow/FinishGitFlowSheet.swift
index 245b9b47..cf76a0b8 100644
--- a/macgit/Views/Workflow/FinishGitFlowSheet.swift
+++ b/macgit/Views/Workflow/FinishGitFlowSheet.swift
@@ -26,6 +26,7 @@ struct FinishGitFlowSheet: View {
let onValidateTag: @MainActor (String) async -> String?
let onFinish: @MainActor (GitFlowFinishPlan) async -> Bool
+ @State private var isFinishing = false
@State private var strategy: GitFlowTopicFinishStrategy
@State private var createTag: Bool
@State private var tagName: String
@@ -83,6 +84,8 @@ struct FinishGitFlowSheet: View {
Spacer()
Button("Cancel", role: .cancel, action: dismiss.callAsFunction)
.keyboardShortcut(.cancelAction)
+ .disabled(isFinishing)
+ if isFinishing { ProgressView().controlSize(.small) }
Button("Finish \(initialPlan.kind.displayName)", action: finish)
.keyboardShortcut(.defaultAction)
.buttonStyle(GlassProminentButtonStyle(tint: .accentColor, fontSize: 13))
@@ -91,6 +94,7 @@ struct FinishGitFlowSheet: View {
}
.padding(24)
.frame(minWidth: 500, idealWidth: 540)
+ .interactiveDismissDisabled(isFinishing)
.task(id: tagValidationKey) {
await validateTagIfNeeded()
}
@@ -187,9 +191,9 @@ struct FinishGitFlowSheet: View {
}
private var isFinishDisabled: Bool {
- initialPlan.kind.requiresReleaseTag
+ isFinishing || (initialPlan.kind.requiresReleaseTag
&& createTag
- && (isValidatingTag || tagValidationMessage != nil)
+ && (isValidatingTag || tagValidationMessage != nil))
}
private func validateTagIfNeeded() async {
@@ -199,11 +203,15 @@ struct FinishGitFlowSheet: View {
return
}
isValidatingTag = true
- tagValidationMessage = await onValidateTag(tagName)
+ let message = await onValidateTag(tagName)
+ guard !Task.isCancelled else { return }
+ tagValidationMessage = message
isValidatingTag = false
}
private func finish() {
+ guard !isFinishing else { return }
+ isFinishing = true
let shouldCreateTag = initialPlan.kind.requiresReleaseTag && createTag
let plan = GitFlowFinishPlan(
kind: initialPlan.kind,
@@ -218,8 +226,10 @@ struct FinishGitFlowSheet: View {
strategy: initialPlan.kind.requiresReleaseTag ? .mergeNoFastForward : strategy
)
onRunRepositoryOperation("Finishing \(plan.sourceBranch)…") {
- if await onFinish(plan) {
- await MainActor.run { dismiss() }
+ let succeeded = await onFinish(plan)
+ await MainActor.run {
+ isFinishing = false
+ if succeeded { dismiss() }
}
}
}
diff --git a/macgit/Views/Workflow/StartGitFlowSheet.swift b/macgit/Views/Workflow/StartGitFlowSheet.swift
index e34e4e15..a1347e4e 100644
--- a/macgit/Views/Workflow/StartGitFlowSheet.swift
+++ b/macgit/Views/Workflow/StartGitFlowSheet.swift
@@ -28,6 +28,7 @@ struct StartGitFlowSheet: View {
let onStart: @MainActor (GitFlowStartPlan, Bool) async -> Bool
@State private var topicName = ""
+ @State private var isStarting = false
@State private var destination: GitFlowStartDestination
@State private var worktreePath = ""
@State private var worktreeLabel = ""
@@ -205,24 +206,28 @@ struct StartGitFlowSheet: View {
dismiss()
}
.keyboardShortcut(.cancelAction)
+ .disabled(isStarting)
+ if isStarting { ProgressView().controlSize(.small) }
Button("Start \(kind.displayName)") {
- guard let plan else { return }
+ guard let plan, !isStarting else { return }
+ isStarting = true
onRunRepositoryOperation("Starting \(plan.branchName)...") {
- if await onStart(plan, destination == .newWorktree && openAfterCreate) {
- await MainActor.run {
- dismiss()
- }
+ let succeeded = await onStart(plan, destination == .newWorktree && openAfterCreate)
+ await MainActor.run {
+ isStarting = false
+ if succeeded { dismiss() }
}
}
}
.keyboardShortcut(.defaultAction)
.buttonStyle(GlassProminentButtonStyle(tint: .accentColor, fontSize: 13))
- .disabled(plan == nil)
+ .disabled(isStarting || plan == nil)
}
}
.padding(24)
.frame(minWidth: 480, idealWidth: 520)
+ .interactiveDismissDisabled(isStarting)
.onAppear {
isTopicNameFocused = true
if destination == .newWorktree, worktreePath.isEmpty {
diff --git a/macgitTests/DiffNativeTableTests.swift b/macgitTests/DiffNativeTableTests.swift
index faa735b4..10b77780 100644
--- a/macgitTests/DiffNativeTableTests.swift
+++ b/macgitTests/DiffNativeTableTests.swift
@@ -68,32 +68,6 @@ final class DiffNativeTableTests: XCTestCase {
coordinator.stop()
}
- func testResizeRelayoutsDocumentAndVisiblePanels() throws {
- let model = makeModel([
- DiffHunk(
- header: "@@",
- lines: [DiffLine(oldLineNumber: nil, newLineNumber: 1, text: "line", type: .added)]
- )
- ])
- let coordinator = model.makeCoordinator()
- let scroll = NSScrollView(frame: CGRect(x: 0, y: 0, width: 600, height: 400))
- scroll.documentView = coordinator.document
- coordinator.observe(scroll)
- coordinator.connectTextStore()
- coordinator.apply(model, scroll: scroll)
- defer { coordinator.stop() }
-
- scroll.setFrameSize(NSSize(width: 900, height: 400))
- scroll.layoutSubtreeIfNeeded()
-
- XCTAssertEqual(coordinator.document.frame.width, scroll.contentView.bounds.width)
- let panel = try XCTUnwrap(
- coordinator.document.subviews.compactMap { $0 as? DiffNativeHunkView }.first
- )
- XCTAssertEqual(panel.frame.width, scroll.contentView.bounds.width)
- XCTAssertEqual(panel.canvas.frame.width, scroll.contentView.bounds.width)
- }
-
@MainActor
private func makeModel(_ hunks: [DiffHunk]) -> DiffNativeTable {
DiffNativeTable(
diff --git a/macgitTests/GitFlowConfigurationSyncTests.swift b/macgitTests/GitFlowConfigurationSyncTests.swift
index b262ec09..4a8bc93b 100644
--- a/macgitTests/GitFlowConfigurationSyncTests.swift
+++ b/macgitTests/GitFlowConfigurationSyncTests.swift
@@ -50,6 +50,7 @@ final class GitFlowConfigurationSyncTests: XCTestCase {
XCTAssertEqual(
Set(document.keys),
[
+ "roleDisplayMode",
"schemaVersion",
"canonicalKey",
"isEnabled",
@@ -80,6 +81,20 @@ final class GitFlowConfigurationSyncTests: XCTestCase {
)
}
+ func testRoleDisplayModeRoundTripsAndAcceptsLegacyDocuments() throws {
+ for mode in GitFlowRoleDisplayMode.allCases {
+ let local = GitFlowConfiguration(roleDisplayMode: mode)
+ let cloud = GitFlowCloudConfiguration(configuration: local, canonicalKey: "github.com/example/repo")
+ var document = GitFlowCloudConfigurationDocument.encode(cloud, updatedAt: Timestamp(date: Date()))
+ let decoded = try GitFlowCloudConfigurationDocument.decode(document)
+ XCTAssertEqual(decoded.applying(to: GitFlowConfiguration()).roleDisplayMode, mode)
+ document.removeValue(forKey: "roleDisplayMode")
+ XCTAssertEqual(try GitFlowCloudConfigurationDocument.decode(document).roleDisplayMode, .full)
+ document["roleDisplayMode"] = "invalid"
+ XCTAssertThrowsError(try GitFlowCloudConfigurationDocument.decode(document))
+ }
+ }
+
func testApplyingCloudConfigurationPreservesMachineStartDestination() {
let local = GitFlowConfiguration(
isEnabled: false,