From cf022b275ac375c7c7b5efeabde725ee4ef27b80 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 12:08:14 +0700 Subject: [PATCH 1/9] refactor: Extract repository sheets and async commit flow Add RepositorySheetRequest and loading/preparation views. Move commit handling to async result with error callback. Cancel in-flight pull request loads on task cancellation. --- .../CreatePullRequestWindowController.swift | 28 +- macgit/App/PullRequestController.swift | 24 +- macgit/Models/RepositorySheetRequest.swift | 8 + macgit/Services/SyncState.swift | 11 +- .../ViewModels/RepositoryLFSController.swift | 29 +- .../Common/CreateTagAtReferenceSheet.swift | 49 ++ .../Common/RepositorySheetLoadingView.swift | 57 +++ macgit/Views/FileStatus/CommitSheetView.swift | 31 +- macgit/Views/LFS/GitLFSRemoveRuleSheet.swift | 45 ++ macgit/Views/LFS/GitLFSView.swift | 21 +- .../BranchForcePushConfirmationSheet.swift | 22 +- .../MainWindowView+BranchForcePush.swift | 115 +++-- .../MainWindowView+DragDropActions.swift | 54 +-- .../MainWindow/MainWindowView+GitFlow.swift | 67 ++- ...MainWindowView+ProtectedBranchCommit.swift | 11 +- ...indowView+ProviderAccountPreferences.swift | 5 +- .../MainWindowView+PullRequestActions.swift | 94 ++-- .../MainWindow/MainWindowView+Sheets.swift | 165 ++++--- macgit/Views/MainWindow/MainWindowView.swift | 453 ++++++++---------- .../CreatePullRequestPreparationView.swift | 25 + .../Views/Workflow/FinishGitFlowSheet.swift | 20 +- macgit/Views/Workflow/StartGitFlowSheet.swift | 17 +- 22 files changed, 817 insertions(+), 534 deletions(-) create mode 100644 macgit/Models/RepositorySheetRequest.swift create mode 100644 macgit/Views/Common/CreateTagAtReferenceSheet.swift create mode 100644 macgit/Views/Common/RepositorySheetLoadingView.swift create mode 100644 macgit/Views/LFS/GitLFSRemoveRuleSheet.swift create mode 100644 macgit/Views/PullRequests/CreatePullRequestPreparationView.swift 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/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/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/RepositoryLFSController.swift b/macgit/ViewModels/RepositoryLFSController.swift index ee2aa87e..407358a3 100644 --- a/macgit/ViewModels/RepositoryLFSController.swift +++ b/macgit/ViewModels/RepositoryLFSController.swift @@ -53,9 +53,36 @@ final class RepositoryLFSController { } catch { self.error = error.localizedDescription } } + func removeRule(pattern: String, 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 { + let review = try await GitStatusService.shared.reviewLFSTracking( + pattern: pattern, literal: false, removing: true, in: repository + ) + try Task.checkCancellation() + 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..80e7fd91 --- /dev/null +++ b/macgit/Views/Common/CreateTagAtReferenceSheet.swift @@ -0,0 +1,49 @@ +// 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) + 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/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/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..7cd2cd5c 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,16 @@ struct GitLFSView: View { } }.padding(24).frame(width: 500) } + .sheet(item: $pendingRuleDeletion) { rule in + GitLFSRemoveRuleSheet( + rule: rule, + onCancel: { pendingRuleDeletion = nil }, + onRemove: { + try await controller.removeRule(pattern: rule.pattern, 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 +511,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..7244915a 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,28 @@ extension MainWindowView { requestBranchForcePush(branch: request.branch, remote: request.remote, remoteBranch: request.remoteBranch) } - func confirmBranchForcePush(_ plan: BranchForcePushPlan) { + func confirmBranchForcePush(_ plan: BranchForcePushPlan) async throws { + 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+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..329bb439 100644 --- a/macgit/Views/MainWindow/MainWindowView+GitFlow.swift +++ b/macgit/Views/MainWindow/MainWindowView+GitFlow.swift @@ -76,31 +76,23 @@ 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: false) else { + syncState.showError("Git Flow access is unavailable for this repository.") + return false + } do { let result = try await GitFlowService().start(plan, in: repositoryURL) await MainActor.run { @@ -179,32 +171,27 @@ 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: false) else { + syncState.showError("Git Flow access is unavailable for this repository.") + 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..ad226bba 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,12 +140,13 @@ 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 private var displayedTagDetails: RepositorySheetRequest? @State var pendingTagDeletion: PendingTagDeletion? @State var pendingStashRef: String? @State var pendingStashAction: StashAction? @@ -152,6 +154,8 @@ struct MainWindowView: View { @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 +183,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 +190,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 +394,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 +409,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 +419,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 +431,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 +452,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 +503,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 +531,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)...") { @@ -714,7 +739,6 @@ struct MainWindowView: View { } .task(id: pullRequestAccessTaskID) { guard selectedItem == .item(.pullRequests) else { return } - pullRequestAccessDecision = nil _ = await authorizePullRequestAccess(presentNotice: false) } .onChange(of: releaseNotesPresentation == nil) { _, dismissed in @@ -730,6 +754,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 +935,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) } } } @@ -1035,22 +1050,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 +1097,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 +1180,7 @@ struct MainWindowView: View { showingNewTagSheet = true }, onRequestCreatePullRequestFromWorkspace: { - Task { - await prepareCreatePullRequest() - } + prepareCreatePullRequest() }, onRequestShowSubtreeInFinder: { path in NSWorkspace.shared.activateFileViewerSelecting([path]) @@ -1366,6 +1365,41 @@ struct MainWindowView: View { case .item(.reflog): EmptyView() case .item(.pullRequests): + EmptyView() + 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): + 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 +1408,7 @@ struct MainWindowView: View { accountConnectionErrorMessage: providerAccountController.errorMessage, onReconnectAccount: onOpenConnections, onRequestCreatePullRequest: { - Task { - await prepareCreatePullRequest() - } + prepareCreatePullRequest() }, authorizeAction: { await authorizePullRequestAccess() } ) @@ -1398,46 +1430,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, @@ -1945,28 +1955,31 @@ struct MainWindowView: View { } func showCommitSheetIfNoConflicts() { - Task { - if syncState.isAnySyncing { - syncState.showInfo("Wait for the current Git operation to finish before committing.") - return - } - if await syncState.checkConflicts(repositoryURL: repositoryURL) { return } - showingCommitSheet = true + guard !syncState.isAnySyncing else { + syncState.showInfo("Wait for the current Git operation to finish before committing.") + 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 { @@ -2398,25 +2411,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 +2428,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 +2476,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 +2522,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/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/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 { From cc781be15ff62e51dfca61ba585fbd9a724cfb13 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 13:20:18 +0700 Subject: [PATCH 2/9] refactor: Improve modal flows and remove redundant diff test Move LFS rule removal review into a prepare step with loading sheet, validate tag names before creation, block force push while syncing, surface Git Flow access notices, check conflicts before opening commit sheet, and drop redundant diff resize test. --- .../ViewModels/RepositoryLFSController.swift | 18 +++++++++---- .../Common/CreateTagAtReferenceSheet.swift | 4 +++ macgit/Views/LFS/GitLFSView.swift | 22 ++++++++++------ .../MainWindowView+BranchForcePush.swift | 3 +++ .../MainWindow/MainWindowView+GitFlow.swift | 6 ++--- macgit/Views/MainWindow/MainWindowView.swift | 6 ++++- macgitTests/DiffNativeTableTests.swift | 26 ------------------- 7 files changed, 41 insertions(+), 44 deletions(-) diff --git a/macgit/ViewModels/RepositoryLFSController.swift b/macgit/ViewModels/RepositoryLFSController.swift index 407358a3..2710eef5 100644 --- a/macgit/ViewModels/RepositoryLFSController.swift +++ b/macgit/ViewModels/RepositoryLFSController.swift @@ -53,7 +53,19 @@ final class RepositoryLFSController { } catch { self.error = error.localizedDescription } } - func removeRule(pattern: String, refresh: @escaping @MainActor () async -> Void) async throws { + 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.") } @@ -64,10 +76,6 @@ final class RepositoryLFSController { } try Task.checkCancellation() do { - let review = try await GitStatusService.shared.reviewLFSTracking( - pattern: pattern, literal: false, removing: true, in: repository - ) - try Task.checkCancellation() try await GitStatusService.shared.applyLFSTracking(review, in: repository) notice = "Completed. Review any changes in File Status before committing." error = nil diff --git a/macgit/Views/Common/CreateTagAtReferenceSheet.swift b/macgit/Views/Common/CreateTagAtReferenceSheet.swift index 80e7fd91..4048afc8 100644 --- a/macgit/Views/Common/CreateTagAtReferenceSheet.swift +++ b/macgit/Views/Common/CreateTagAtReferenceSheet.swift @@ -31,6 +31,10 @@ struct CreateTagAtReferenceSheet: View { 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 { diff --git a/macgit/Views/LFS/GitLFSView.swift b/macgit/Views/LFS/GitLFSView.swift index 7cd2cd5c..2c029c37 100644 --- a/macgit/Views/LFS/GitLFSView.swift +++ b/macgit/Views/LFS/GitLFSView.swift @@ -160,14 +160,20 @@ struct GitLFSView: View { }.padding(24).frame(width: 500) } .sheet(item: $pendingRuleDeletion) { rule in - GitLFSRemoveRuleSheet( - rule: rule, - onCancel: { pendingRuleDeletion = nil }, - onRemove: { - try await controller.removeRule(pattern: rule.pattern, refresh: refreshRepository) - pendingRuleDeletion = nil - } - ) + 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) { diff --git a/macgit/Views/MainWindow/MainWindowView+BranchForcePush.swift b/macgit/Views/MainWindow/MainWindowView+BranchForcePush.swift index 7244915a..b7ad06f0 100644 --- a/macgit/Views/MainWindow/MainWindowView+BranchForcePush.swift +++ b/macgit/Views/MainWindow/MainWindowView+BranchForcePush.swift @@ -91,6 +91,9 @@ extension MainWindowView { } 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.") } diff --git a/macgit/Views/MainWindow/MainWindowView+GitFlow.swift b/macgit/Views/MainWindow/MainWindowView+GitFlow.swift index 329bb439..2be812d9 100644 --- a/macgit/Views/MainWindow/MainWindowView+GitFlow.swift +++ b/macgit/Views/MainWindow/MainWindowView+GitFlow.swift @@ -89,8 +89,7 @@ extension MainWindowView { } func startGitFlow(_ plan: GitFlowStartPlan, openAfterCreate: Bool) async -> Bool { - guard await authorizeGitFlowAccess(presentNotice: false) else { - syncState.showError("Git Flow access is unavailable for this repository.") + guard await authorizeGitFlowAccess(presentNotice: true) else { return false } do { @@ -188,8 +187,7 @@ extension MainWindowView { } func finishGitFlow(_ plan: GitFlowFinishPlan) async -> Bool { - guard await authorizeGitFlowAccess(presentNotice: false) else { - syncState.showError("Git Flow access is unavailable for this repository.") + guard await authorizeGitFlowAccess(presentNotice: true) else { return false } do { diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index ad226bba..cff5172c 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -1959,7 +1959,11 @@ struct MainWindowView: View { syncState.showInfo("Wait for the current Git operation to finish before committing.") return } - showingCommitSheet = true + Task { + if await syncState.checkConflicts(repositoryURL: repositoryURL) { return } + guard !Task.isCancelled, !syncState.isAnySyncing else { return } + showingCommitSheet = true + } } func commitFromToolbar(message: String, commitAllChanges: Bool) async throws -> Bool { 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( From 414acbc0a2ef1d0b4dc59497d3c2292c3dcacc6d Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 13:34:24 +0700 Subject: [PATCH 3/9] fix: Defer sidebar selection to avoid accidental branch checkouts Introduce defersPrimarySelection to delay single-click selection until the double-click interval elapses, cancel pending selections on menu, key, or mouse events, and sync selected branch and history filter after checkout. --- .../MainWindowView+CheckoutActions.swift | 15 ++++++++ .../Sidebar/SidebarNativeList.swift | 35 ++++++++++++++++++- .../MainWindow/SidebarView+NativeRows.swift | 2 ++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/macgit/Views/MainWindow/MainWindowView+CheckoutActions.swift b/macgit/Views/MainWindow/MainWindowView+CheckoutActions.swift index 333c14f6..79f54065 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, @@ -84,6 +93,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/Sidebar/SidebarNativeList.swift b/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift index 8ac0b329..63013a6f 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift @@ -30,6 +30,7 @@ struct SidebarNativeRow { var selection: SidebarSelection? var select: (() -> Void)? var activate: (() -> Void)? + var defersPrimarySelection = false var menu: (() -> NSMenu)? var accessory: ((NSView) -> Void)? var payload: (() -> GitDragPayload)? @@ -95,6 +96,7 @@ struct SidebarNativeList: NSViewRepresentable { if let payload = coordinator.dragPayload { GitDragPayloadStore.clear(ifMatching: payload) } coordinator.dragPayload = nil coordinator.incomingPayload = nil + coordinator.cancelPendingSelection() coordinator.table?.delegate = nil coordinator.table?.dataSource = nil coordinator.table?.owner = nil @@ -109,6 +111,7 @@ struct SidebarNativeList: NSViewRepresentable { var installed = false var updating = false var mouseSelection = false + private var pendingSelection: Task? var keyboardFocusID: SidebarNativeRow.ID? var dragPayload: GitDragPayload? var pasteboardKey: String? @@ -124,6 +127,7 @@ struct SidebarNativeList: NSViewRepresentable { guard let table else { return } if parent.selection != next.selection || parent.repositoryURL != next.repositoryURL { keyboardFocusID = nil } let repositoryChanged = parent.repositoryURL != next.repositoryURL + if repositoryChanged || parent.selection != next.selection { cancelPendingSelection() } let scaleChanged = parent.textScale != next.textScale let contentChanged = !installed || repositoryChanged || parent.input != next.input if !contentChanged && !scaleChanged { @@ -260,8 +264,34 @@ struct SidebarNativeList: NSViewRepresentable { rows[row].select?() } } + func cancelPendingSelection() { + pendingSelection?.cancel() + pendingSelection = nil + } func completePrimaryClick(row: Int, clickCount: Int) { guard rows.indices.contains(row) else { return } + cancelPendingSelection() + if rows[row].defersPrimarySelection { + if clickCount == 2 { + rows[row].activate?() + } else if clickCount == 1 { + let id = rows[row].id + let repository = parent.repositoryURL + let interval = NSEvent.doubleClickInterval + pendingSelection = Task { @MainActor [weak self] in + do { try await Task.sleep(for: .seconds(interval)) } + catch { return } + guard let self, !Task.isCancelled, + self.parent.repositoryURL == repository, + let index = self.indices[id] else { return } + self.pendingSelection = nil + self.mouseSelection = true + self.mouseDown(row: index) + self.mouseSelection = false + } + } + return + } mouseSelection = true mouseDown(row: row) mouseSelection = false @@ -380,7 +410,8 @@ 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))) + owner?.cancelPendingSelection() + return owner?.menu(row: row(at: convert(event.locationInWindow, from: nil))) } override func draggingExited(_ sender: NSDraggingInfo?) { super.draggingExited(sender) @@ -394,6 +425,7 @@ struct SidebarNativeList: NSViewRepresentable { if let sender { owner?.preserveCommitPreview(sender) } } override func mouseDown(with event: NSEvent) { + owner?.cancelPendingSelection() let hit = row(at: convert(event.locationInWindow, from: nil)) if let owner, owner.rows.indices.contains(hit), owner.rows[hit].selection == nil { let row = owner.rows[hit] @@ -469,6 +501,7 @@ struct SidebarNativeList: NSViewRepresentable { } } override func keyDown(with event: NSEvent) { + owner?.cancelPendingSelection() guard let owner, owner.rows.indices.contains(selectedRow) else { super.keyDown(with: event); return } let row = owner.rows[selectedRow] if event.keyCode == 123 { // Collapse or focus the nearest parent disclosure. diff --git a/macgit/Views/MainWindow/SidebarView+NativeRows.swift b/macgit/Views/MainWindow/SidebarView+NativeRows.swift index 5f3c8bce..298a349f 100644 --- a/macgit/Views/MainWindow/SidebarView+NativeRows.swift +++ b/macgit/Views/MainWindow/SidebarView+NativeRows.swift @@ -93,6 +93,7 @@ extension SidebarView { appearance: .init(title: item.name, icon: current ? "circle.fill" : "", badge: badges.joined(separator: " "), indent: item.indent, emphasized: current), selection: .branch(item.fullPath), select: { branchSectionActions.select(.branch(item.fullPath)) }, activate: { if !current { branchSectionActions.checkout(item.fullPath) } }, + defersPrimarySelection: !current, menu: { nativeBranchMenu(item.fullPath) }, payload: { GitDragPayload.branch(item.fullPath, repositoryURL: repositoryURL) }, dropTarget: .localBranch(name: item.fullPath, isCurrent: current)) @@ -150,6 +151,7 @@ extension SidebarView { appearance: .init(title: item.name, indent: item.indent), selection: .remoteBranch(item.fullPath), select: { remoteSectionActions.select(.remoteBranch(item.fullPath)) }, activate: { if !symbolic { remoteSectionActions.checkoutFromRow(item.fullPath) } }, + defersPrimarySelection: !symbolic, menu: { nativeRemoteMenu(item.fullPath) }, payload: symbolic ? nil : { GitDragPayload.remoteBranch(item.fullPath, repositoryURL: repositoryURL) })) } From 99fc0c85f83b9503b78ee23534791b5e12089535 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 13:49:42 +0700 Subject: [PATCH 4/9] fix: click and double better --- macgit/ViewModels/HistoryListModel.swift | 6 +- macgit/Views/History/HistoryScreen.swift | 58 ++++++++++++++----- macgit/Views/MainWindow/MainWindowView.swift | 1 + .../Sidebar/SidebarNativeList.swift | 33 ----------- .../MainWindow/SidebarView+NativeRows.swift | 2 - 5 files changed, 49 insertions(+), 51 deletions(-) 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/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/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index cff5172c..ac1f6487 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -1485,6 +1485,7 @@ struct MainWindowView: View { HistoryScreen( repositoryURL: repositoryURL, isActive: isHistoryActive, + isWorkSuspended: showingCheckoutConfirmation || pendingRemoteBranchCheckout != nil, selectedBranch: selectedBranchName, branchFilter: $appState.historyBranchFilter, includeRemotes: $appState.historyIncludeRemotes, diff --git a/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift b/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift index 63013a6f..5c2ba3b8 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarNativeList.swift @@ -30,7 +30,6 @@ struct SidebarNativeRow { var selection: SidebarSelection? var select: (() -> Void)? var activate: (() -> Void)? - var defersPrimarySelection = false var menu: (() -> NSMenu)? var accessory: ((NSView) -> Void)? var payload: (() -> GitDragPayload)? @@ -96,7 +95,6 @@ struct SidebarNativeList: NSViewRepresentable { if let payload = coordinator.dragPayload { GitDragPayloadStore.clear(ifMatching: payload) } coordinator.dragPayload = nil coordinator.incomingPayload = nil - coordinator.cancelPendingSelection() coordinator.table?.delegate = nil coordinator.table?.dataSource = nil coordinator.table?.owner = nil @@ -111,7 +109,6 @@ struct SidebarNativeList: NSViewRepresentable { var installed = false var updating = false var mouseSelection = false - private var pendingSelection: Task? var keyboardFocusID: SidebarNativeRow.ID? var dragPayload: GitDragPayload? var pasteboardKey: String? @@ -127,7 +124,6 @@ struct SidebarNativeList: NSViewRepresentable { guard let table else { return } if parent.selection != next.selection || parent.repositoryURL != next.repositoryURL { keyboardFocusID = nil } let repositoryChanged = parent.repositoryURL != next.repositoryURL - if repositoryChanged || parent.selection != next.selection { cancelPendingSelection() } let scaleChanged = parent.textScale != next.textScale let contentChanged = !installed || repositoryChanged || parent.input != next.input if !contentChanged && !scaleChanged { @@ -264,34 +260,8 @@ struct SidebarNativeList: NSViewRepresentable { rows[row].select?() } } - func cancelPendingSelection() { - pendingSelection?.cancel() - pendingSelection = nil - } func completePrimaryClick(row: Int, clickCount: Int) { guard rows.indices.contains(row) else { return } - cancelPendingSelection() - if rows[row].defersPrimarySelection { - if clickCount == 2 { - rows[row].activate?() - } else if clickCount == 1 { - let id = rows[row].id - let repository = parent.repositoryURL - let interval = NSEvent.doubleClickInterval - pendingSelection = Task { @MainActor [weak self] in - do { try await Task.sleep(for: .seconds(interval)) } - catch { return } - guard let self, !Task.isCancelled, - self.parent.repositoryURL == repository, - let index = self.indices[id] else { return } - self.pendingSelection = nil - self.mouseSelection = true - self.mouseDown(row: index) - self.mouseSelection = false - } - } - return - } mouseSelection = true mouseDown(row: row) mouseSelection = false @@ -410,7 +380,6 @@ struct SidebarNativeList: NSViewRepresentable { final class Table: NSTableView { weak var owner: Coordinator? override func menu(for event: NSEvent) -> NSMenu? { - owner?.cancelPendingSelection() return owner?.menu(row: row(at: convert(event.locationInWindow, from: nil))) } override func draggingExited(_ sender: NSDraggingInfo?) { @@ -425,7 +394,6 @@ struct SidebarNativeList: NSViewRepresentable { if let sender { owner?.preserveCommitPreview(sender) } } override func mouseDown(with event: NSEvent) { - owner?.cancelPendingSelection() let hit = row(at: convert(event.locationInWindow, from: nil)) if let owner, owner.rows.indices.contains(hit), owner.rows[hit].selection == nil { let row = owner.rows[hit] @@ -501,7 +469,6 @@ struct SidebarNativeList: NSViewRepresentable { } } override func keyDown(with event: NSEvent) { - owner?.cancelPendingSelection() guard let owner, owner.rows.indices.contains(selectedRow) else { super.keyDown(with: event); return } let row = owner.rows[selectedRow] if event.keyCode == 123 { // Collapse or focus the nearest parent disclosure. diff --git a/macgit/Views/MainWindow/SidebarView+NativeRows.swift b/macgit/Views/MainWindow/SidebarView+NativeRows.swift index 298a349f..5f3c8bce 100644 --- a/macgit/Views/MainWindow/SidebarView+NativeRows.swift +++ b/macgit/Views/MainWindow/SidebarView+NativeRows.swift @@ -93,7 +93,6 @@ extension SidebarView { appearance: .init(title: item.name, icon: current ? "circle.fill" : "", badge: badges.joined(separator: " "), indent: item.indent, emphasized: current), selection: .branch(item.fullPath), select: { branchSectionActions.select(.branch(item.fullPath)) }, activate: { if !current { branchSectionActions.checkout(item.fullPath) } }, - defersPrimarySelection: !current, menu: { nativeBranchMenu(item.fullPath) }, payload: { GitDragPayload.branch(item.fullPath, repositoryURL: repositoryURL) }, dropTarget: .localBranch(name: item.fullPath, isCurrent: current)) @@ -151,7 +150,6 @@ extension SidebarView { appearance: .init(title: item.name, indent: item.indent), selection: .remoteBranch(item.fullPath), select: { remoteSectionActions.select(.remoteBranch(item.fullPath)) }, activate: { if !symbolic { remoteSectionActions.checkoutFromRow(item.fullPath) } }, - defersPrimarySelection: !symbolic, menu: { nativeRemoteMenu(item.fullPath) }, payload: symbolic ? nil : { GitDragPayload.remoteBranch(item.fullPath, repositoryURL: repositoryURL) })) } From c20cd4d6ba7d56a604c7d2c79cc27a96d8603979 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 14:15:20 +0700 Subject: [PATCH 5/9] fix: Defer stash and history work while modals are open Add HistoryCommitRowView to keep the selected commit emphasized when the sidebar has focus and constrain scroll offsets in the commit table controller. Suspend stash loading and diff requests while a pending action is in flight, and gate history work on detached-head and pending checkout state. --- .../Views/History/HistoryCommitRowView.swift | 11 +++++ .../HistoryCommitTableController.swift | 14 +++++- macgit/Views/MainWindow/MainWindowView.swift | 9 +++- macgit/Views/Stashes/StashView.swift | 49 +++++++++++++------ 4 files changed, 63 insertions(+), 20 deletions(-) create mode 100644 macgit/Views/History/HistoryCommitRowView.swift diff --git a/macgit/Views/History/HistoryCommitRowView.swift b/macgit/Views/History/HistoryCommitRowView.swift new file mode 100644 index 00000000..f0ff7fee --- /dev/null +++ b/macgit/Views/History/HistoryCommitRowView.swift @@ -0,0 +1,11 @@ +// 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 { window?.isKeyWindow == true } + set { super.isEmphasized = newValue } + } +} diff --git a/macgit/Views/History/HistoryCommitTableController.swift b/macgit/Views/History/HistoryCommitTableController.swift index 50f57f26..0e626ddc 100644 --- a/macgit/Views/History/HistoryCommitTableController.swift +++ b/macgit/Views/History/HistoryCommitTableController.swift @@ -170,8 +170,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 +213,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/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index ac1f6487..4054140a 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -1367,7 +1367,11 @@ struct MainWindowView: View { case .item(.pullRequests): EmptyView() case .stash(let ref): - StashView(repositoryURL: repositoryURL, stashRef: ref) + StashView( + repositoryURL: repositoryURL, + stashRef: ref, + isWorkSuspended: pendingStashRef != nil && pendingStashAction != nil + ) case .submodule(let path): SubmoduleDetailPlaceholderView { openWorktreeInNewWindow( @@ -1485,7 +1489,8 @@ struct MainWindowView: View { HistoryScreen( repositoryURL: repositoryURL, isActive: isHistoryActive, - isWorkSuspended: showingCheckoutConfirmation || pendingRemoteBranchCheckout != nil, + isWorkSuspended: showingCheckoutConfirmation || pendingRemoteBranchCheckout != nil + || showingDetachedHeadConfirmation || pendingHistoryCheckout != nil, selectedBranch: selectedBranchName, branchFilter: $appState.historyBranchFilter, includeRemotes: $appState.historyIncludeRemotes, 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 { From edc6b05ca4502eaafb894c984ddff79acb221cb4 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 14:31:40 +0700 Subject: [PATCH 6/9] fix: apply bg color when select branch, tag --- .../Views/History/HistoryCommitRowView.swift | 13 ++++++-- .../History/HistoryCommitScrollView.swift | 16 +++++++++ .../History/HistoryCommitTableCells.swift | 21 +++++++++--- .../HistoryCommitTableController.swift | 33 ++++++++++++++----- .../Views/History/HistoryRefBadgeView.swift | 5 ++- 5 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 macgit/Views/History/HistoryCommitScrollView.swift diff --git a/macgit/Views/History/HistoryCommitRowView.swift b/macgit/Views/History/HistoryCommitRowView.swift index f0ff7fee..52ea50e8 100644 --- a/macgit/Views/History/HistoryCommitRowView.swift +++ b/macgit/Views/History/HistoryCommitRowView.swift @@ -5,7 +5,16 @@ import AppKit /// the Sidebar owns keyboard focus. final class HistoryCommitRowView: NSTableRowView { override var isEmphasized: Bool { - get { window?.isKeyWindow == true } - set { super.isEmphasized = newValue } + 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 0e626ddc..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 } 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 } } } From d1574621b65bcbaa8d6dff0260664a5200a369a6 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 15:00:46 +0700 Subject: [PATCH 7/9] feat: Add configurable Git Flow sidebar role labels Introduce GitFlowRoleDisplayMode with a settings picker, role pills in the sidebar, and cloud sync support for the new field. --- macgit/Models/GitFlowCloudConfiguration.swift | 7 +- macgit/Models/GitFlowConfiguration.swift | 5 ++ macgit/Models/GitFlowRoleDisplayMode.swift | 17 +++++ .../FirestoreGitFlowConfigurationStore.swift | 18 ++++- .../Common/RepositorySettingsSheetView.swift | 6 ++ macgit/Views/MainWindow/MainWindowView.swift | 1 + .../Sidebar/SidebarGitFlowRolePill.swift | 66 +++++++++++++++++++ .../Sidebar/SidebarNativeInput.swift | 2 + .../Sidebar/SidebarNativeList.swift | 20 +++++- .../MainWindow/SidebarView+NativeRows.swift | 5 +- macgit/Views/MainWindow/SidebarView.swift | 3 + .../GitFlowConfigurationSyncTests.swift | 15 +++++ 12 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 macgit/Models/GitFlowRoleDisplayMode.swift create mode 100644 macgit/Views/MainWindow/Sidebar/SidebarGitFlowRolePill.swift 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/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/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/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index 4054140a..7f8f2f9d 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -957,6 +957,7 @@ struct MainWindowView: View { currentBranchFallbackSyncStatus: currentBranchFallbackSyncStatus, defaultRemoteName: repoSettings.defaultRemoteName, isAccountMenuDisabled: operationProgress.activeOperation != nil, + gitFlowRoleDisplayMode: gitFlowConfiguration.roleDisplayMode, gitFlowConfiguration: gitFlowConfiguration, gitFlowFinishCheckpoint: gitFlowFinishCheckpoint, gitFlowRecoveryIssue: gitFlowRecoveryIssue, diff --git a/macgit/Views/MainWindow/Sidebar/SidebarGitFlowRolePill.swift b/macgit/Views/MainWindow/Sidebar/SidebarGitFlowRolePill.swift new file mode 100644 index 00000000..33c62980 --- /dev/null +++ b/macgit/Views/MainWindow/Sidebar/SidebarGitFlowRolePill.swift @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import AppKit + +final class SidebarGitFlowRolePill: NSView { + private let label = NSTextField(labelWithString: "") + private var role: GitFlowBranchRole? + private var scale: CGFloat = 1 + + override init(frame: NSRect) { + super.init(frame: frame) + addSubview(label) + label.alignment = .center + label.lineBreakMode = .byClipping + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func configure(role: GitFlowBranchRole?, mode: GitFlowRoleDisplayMode, scale: CGFloat) { + self.role = role + self.scale = scale + isHidden = role == nil || mode == .hidden + let name = role?.rawValue ?? "" + label.stringValue = mode == .initial ? String(name.prefix(1)) : name + toolTip = name + label.font = .systemFont(ofSize: 9 * scale, weight: .semibold) + label.textColor = color + setAccessibilityLabel(role?.rawValue ?? "") + invalidateIntrinsicContentSize() + needsLayout = true + needsDisplay = true + } + + override var intrinsicContentSize: NSSize { + NSSize(width: ceil(label.intrinsicContentSize.width) + 12 * scale, height: 17 * scale) + } + + override func layout() { + super.layout() + let height = label.intrinsicContentSize.height + label.frame = NSRect(x: 6 * scale, y: (bounds.height - height) / 2, + width: max(0, bounds.width - 12 * scale), height: height) + } + + override func draw(_ dirtyRect: NSRect) { + color.withAlphaComponent(0.16).setFill() + NSBezierPath(roundedRect: bounds, xRadius: bounds.height / 2, yRadius: bounds.height / 2).fill() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + label.textColor = color + needsDisplay = true + } + + private var color: NSColor { + switch role { + case .main: .systemBlue + 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 5c2ba3b8..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 @@ -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/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, From f54eb2459f2f4e28ab843325ce1cd91d0fd0e55f Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 15:05:33 +0700 Subject: [PATCH 8/9] refactor: Replace Git Flow role pill label with CoreText drawing Switch the sidebar pill from an NSTextField subview to direct CoreText rendering so glyph bounds center the badge text and the main role uses system cyan. --- .../Sidebar/SidebarGitFlowRolePill.swift | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/macgit/Views/MainWindow/Sidebar/SidebarGitFlowRolePill.swift b/macgit/Views/MainWindow/Sidebar/SidebarGitFlowRolePill.swift index 33c62980..7143f520 100644 --- a/macgit/Views/MainWindow/Sidebar/SidebarGitFlowRolePill.swift +++ b/macgit/Views/MainWindow/Sidebar/SidebarGitFlowRolePill.swift @@ -1,29 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import AppKit +import CoreText final class SidebarGitFlowRolePill: NSView { - private let label = NSTextField(labelWithString: "") + private var text = "" + private var font = NSFont.systemFont(ofSize: 9, weight: .semibold) private var role: GitFlowBranchRole? private var scale: CGFloat = 1 - override init(frame: NSRect) { - super.init(frame: frame) - addSubview(label) - label.alignment = .center - label.lineBreakMode = .byClipping - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(role: GitFlowBranchRole?, mode: GitFlowRoleDisplayMode, scale: CGFloat) { self.role = role self.scale = scale isHidden = role == nil || mode == .hidden let name = role?.rawValue ?? "" - label.stringValue = mode == .initial ? String(name.prefix(1)) : name + text = mode == .initial ? String(name.prefix(1)) : name toolTip = name - label.font = .systemFont(ofSize: 9 * scale, weight: .semibold) - label.textColor = color + font = .systemFont(ofSize: 9 * scale, weight: .semibold) setAccessibilityLabel(role?.rawValue ?? "") invalidateIntrinsicContentSize() needsLayout = true @@ -31,30 +23,33 @@ final class SidebarGitFlowRolePill: NSView { } override var intrinsicContentSize: NSSize { - NSSize(width: ceil(label.intrinsicContentSize.width) + 12 * scale, height: 17 * scale) - } - - override func layout() { - super.layout() - let height = label.intrinsicContentSize.height - label.frame = NSRect(x: 6 * scale, y: (bounds.height - height) / 2, - width: max(0, bounds.width - 12 * scale), height: height) + 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() - label.textColor = color needsDisplay = true } private var color: NSColor { switch role { - case .main: .systemBlue + case .main: .systemCyan case .develop: .systemYellow case .feature: .systemPurple case .bugfix: .systemOrange From bbfe8dec9bcf86fd329c05335dc1066f24eedc0c Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Wed, 14 Oct 2026 15:08:49 +0700 Subject: [PATCH 9/9] fix: suspend stale loads during stash and tag operations --- .../MainWindow/MainWindowView+CheckoutActions.swift | 2 ++ macgit/Views/MainWindow/MainWindowView.swift | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/macgit/Views/MainWindow/MainWindowView+CheckoutActions.swift b/macgit/Views/MainWindow/MainWindowView+CheckoutActions.swift index 79f54065..72f3d73f 100644 --- a/macgit/Views/MainWindow/MainWindowView+CheckoutActions.swift +++ b/macgit/Views/MainWindow/MainWindowView+CheckoutActions.swift @@ -62,6 +62,8 @@ extension MainWindowView { } func performTagCheckout(tag: String) async { + activeTagCheckoutCount += 1 + defer { activeTagCheckoutCount -= 1 } await performCheckout(ref: tag, stash: false) } diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index 7f8f2f9d..5d4a5f63 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -146,10 +146,12 @@ struct MainWindowView: View { @State var branchToRename: String = "" @State var showingDetachedHeadConfirmation = false @State var tagToCheckout: String = "" + @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? @@ -739,6 +741,7 @@ struct MainWindowView: View { } .task(id: pullRequestAccessTaskID) { guard selectedItem == .item(.pullRequests) else { return } + pullRequestAccessDecision = nil _ = await authorizePullRequestAccess(presentNotice: false) } .onChange(of: releaseNotesPresentation == nil) { _, dismissed in @@ -1371,7 +1374,8 @@ struct MainWindowView: View { StashView( repositoryURL: repositoryURL, stashRef: ref, - isWorkSuspended: pendingStashRef != nil && pendingStashAction != nil + isWorkSuspended: activeStashActionCount > 0 + || (pendingStashRef != nil && pendingStashAction != nil) ) case .submodule(let path): SubmoduleDetailPlaceholderView { @@ -1491,7 +1495,8 @@ struct MainWindowView: View { repositoryURL: repositoryURL, isActive: isHistoryActive, isWorkSuspended: showingCheckoutConfirmation || pendingRemoteBranchCheckout != nil - || showingDetachedHeadConfirmation || pendingHistoryCheckout != nil, + || showingDetachedHeadConfirmation || pendingHistoryCheckout != nil + || activeTagCheckoutCount > 0, selectedBranch: selectedBranchName, branchFilter: $appState.historyBranchFilter, includeRemotes: $appState.historyIncludeRemotes, @@ -2199,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: