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,