diff --git a/macgit/App/CreatePullRequestWindowController.swift b/macgit/App/CreatePullRequestWindowController.swift new file mode 100644 index 00000000..c3241261 --- /dev/null +++ b/macgit/App/CreatePullRequestWindowController.swift @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import Combine +import SwiftUI + +@MainActor +final class CreatePullRequestWindowController: NSWindowController, NSWindowDelegate, ObservableObject { + private var onClose: (() -> Void)? + @Published private(set) var cancelRequestID = UUID() + + init() { + super.init(window: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show(repositoryName: String, content: Content, onClose: @escaping () -> Void) { + close() + self.onClose = onClose + + let screen = NSApp.keyWindow?.screen ?? NSScreen.main + let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 800) + let size = NSSize( + width: min(1080, visibleFrame.width - 40), + height: min(720, visibleFrame.height - 80) + ) + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "Create Pull Request — \(repositoryName)" + window.isReleasedWhenClosed = false + window.tabbingMode = .disallowed + window.contentMinSize = NSSize(width: min(820, size.width), height: min(520, size.height)) + window.delegate = self + + let hostingView = NSHostingView(rootView: content) + hostingView.sizingOptions = [] + window.contentView = hostingView + window.setContentSize(size) + window.setFrameOrigin(NSPoint( + x: visibleFrame.midX - window.frame.width / 2, + y: visibleFrame.midY - window.frame.height / 2 + )) + self.window = window + showWindow(nil) + window.makeKeyAndOrderFront(nil) + } + + override func close() { + window?.close() + } + + func activateIfPresented() -> Bool { + guard let window else { return false } + if window.isMiniaturized { + window.deminiaturize(nil) + } + window.makeKeyAndOrderFront(nil) + return true + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + cancelRequestID = UUID() + return false + } + + func windowWillClose(_ notification: Notification) { + let callback = onClose + onClose = nil + window = nil + callback?() + } +} + +struct CreatePullRequestWindowContent: View { + @ObservedObject var controller: PullRequestController + @ObservedObject var windowController: CreatePullRequestWindowController + let seed: PullRequestDraftSeed + let repositoryURL: URL + let authorizeAction: () async -> Bool + let onGenerateText: PullRequestAIGenerationAction + let onCancel: () -> Void + let onCreate: (PullRequestDraft) async -> Void + @State private var isSubmitting = false + + var body: some View { + CreatePullRequestView( + seed: seed, + repositoryURL: repositoryURL, + isSubmitting: isSubmitting || controller.isPerformingAction, + changedFileCount: controller.createDraftChangedFileCount, + isLoadingChanges: controller.isLoadingCreateDraftChanges, + changesErrorMessage: controller.createDraftChangesErrorMessage, + participants: controller.createDraftParticipants, + isLoadingParticipants: controller.isLoadingCreateDraftParticipants, + participantsErrorMessage: controller.createDraftParticipantsErrorMessage, + loadSourceBranches: { query in + await controller.loadCreateDraftSourceBranches(query: query) + }, + loadTargetBranches: { query in + await controller.loadCreateDraftTargetBranches(query: query) + }, + 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() + } + } + }, + onCreate: { draft in + guard !isSubmitting else { return } + isSubmitting = true + Task { + await onCreate(draft) + isSubmitting = false + } + }, + isPresentedInWindow: true, + cancelRequestID: windowController.cancelRequestID + ) + .overlay { + if isSubmitting || controller.isPerformingAction { + ZStack { + Color(nsColor: .windowBackgroundColor) + .opacity(0.72) + ProgressView("Creating pull request…") + .controlSize(.large) + } + .ignoresSafeArea() + } + } + .alert("Pull Request", isPresented: detailErrorPresented) { + Button("OK", role: .cancel) { + controller.detailErrorMessage = nil + } + } message: { + Text(controller.detailErrorMessage ?? "Could not create pull request.") + } + } + + private var detailErrorPresented: Binding { + Binding( + get: { controller.detailErrorMessage != nil }, + set: { isPresented in + if !isPresented { + controller.detailErrorMessage = nil + } + } + ) + } +} diff --git a/macgit/App/PullRequestController.swift b/macgit/App/PullRequestController.swift index 68306649..f952f314 100644 --- a/macgit/App/PullRequestController.swift +++ b/macgit/App/PullRequestController.swift @@ -274,6 +274,74 @@ final class PullRequestController: ObservableObject { await loadPullRequests(remoteURLString: remoteURLString, page: page, forceRefresh: forceRefresh) } + func prepareCreatePullRequest( + repositoryURL: URL, + remoteName requestedRemoteName: String? = nil, + sourceBranch: String? = nil + ) async { + activeRepositoryURL = repositoryURL + detailErrorMessage = nil + + let remoteName = if let requestedRemoteName { + requestedRemoteName + } else { + await remoteNameProvider(repositoryURL) + } + guard let remoteName, + let remoteURLString = await remoteURLProvider(repositoryURL, remoteName) else { + detailErrorMessage = "No remotes configured." + return + } + guard let remoteIdentity = GitRemoteIdentityResolver.identity( + from: remoteURLString, + knownGitLabHosts: connectedGitLabHosts, + knownHosts: providerAccountController.accounts.map { + GitProviderHost(kind: $0.provider, baseURL: $0.hostURL) + } + ) else { + detailErrorMessage = PullRequestProviderError.unsupportedProvider.localizedDescription + return + } + + let repository = GitRepositoryIdentity( + provider: remoteIdentity.provider, + hostURL: remoteIdentity.hostURL, + owner: remoteIdentity.ownerPath, + name: remoteIdentity.repositoryName + ) + let matchingAccounts = matchingAccounts(for: repository) + guard !matchingAccounts.isEmpty else { + accountConnectionHost = GitProviderHost( + kind: repository.provider, + baseURL: repository.hostURL + ).normalized + detailErrorMessage = "Connect Account..." + return + } + guard let apiCredential = apiCredential(for: matchingAccounts) else { + accountConnectionHost = GitProviderHost( + kind: repository.provider, + baseURL: repository.hostURL + ).normalized + detailErrorMessage = matchingAccounts.contains(where: supportsProviderAPI) + ? "Reconnect..." + : "Connect Account..." + return + } + guard services[repository.provider] != nil else { + detailErrorMessage = PullRequestProviderError.unsupportedProvider.localizedDescription + return + } + + activeRemoteName = remoteName + activeRemoteURLString = remoteURLString + activeRepository = repository + activeToken = apiCredential.token + selectedProviderAccountID = apiCredential.account.id + accountConnectionHost = nil + await presentCreatePullRequest(sourceBranch: sourceBranch) + } + func loadPullRequests( repositoryURL: URL, remoteName: String, diff --git a/macgit/ViewModels/HistoryListModel.swift b/macgit/ViewModels/HistoryListModel.swift index e742f6d3..7d688d9d 100644 --- a/macgit/ViewModels/HistoryListModel.swift +++ b/macgit/ViewModels/HistoryListModel.swift @@ -256,11 +256,21 @@ final class HistoryListModel { storeSnapshot() } - func selectBranchTipIfPossible() { - guard branchFilter == .all, activeSearchQuery.isEmpty, publishedKey == loadKey, - let selectedBranch, let hash = branchTips[selectedBranch], let tip = commit(hash) else { return } + func selectReferenceTipIfLoaded(_ reference: String) async -> Bool { + guard branchFilter == .all, activeSearchQuery.isEmpty, publishedKey == loadKey else { return false } + let requestedGeneration = generation + let requestedKey = loadKey + let hash: String? + if let loadedHash = branchTips[reference] { + hash = loadedHash + } else { + hash = await GitStatusService.shared.tipHash(for: reference, in: repositoryURL) + } + guard generation == requestedGeneration, loadKey == requestedKey, + selectedBranch == reference, let hash, let tip = commit(hash) else { return false } select(tip, focus: true) storeSnapshot() + return true } func clearCache() { cache.removeAll() } diff --git a/macgit/Views/Common/DiffNativeTable.swift b/macgit/Views/Common/DiffNativeTable.swift index cd259de9..5f1dd2b4 100644 --- a/macgit/Views/Common/DiffNativeTable.swift +++ b/macgit/Views/Common/DiffNativeTable.swift @@ -58,8 +58,9 @@ struct DiffNativeTable: NSViewRepresentable { private var panels: [Int: DiffNativeHunkView] = [:] private var recycledPanels: [DiffNativeHunkView] = [] private var widthTask: Task? - private var observer: NSObjectProtocol? + private var observers: [NSObjectProtocol] = [] private var updating = false + private var needsRelayout = false /// Keep one viewport of fully configured content on either side of the /// clip. This moves hosting-view reuse away from the hunk boundary the @@ -149,7 +150,10 @@ struct DiffNativeTable: NSViewRepresentable { func observe(_ scroll: NSScrollView) { scroll.contentView.postsBoundsChangedNotifications = true - observer = NotificationCenter.default.addObserver( + scroll.contentView.postsFrameChangedNotifications = true + document.postsFrameChangedNotifications = true + let center = NotificationCenter.default + observers.append(center.addObserver( forName: NSView.boundsDidChangeNotification, object: scroll.contentView, queue: .main ) { [weak self, weak scroll] _ in @@ -157,13 +161,41 @@ struct DiffNativeTable: NSViewRepresentable { guard let self, let scroll else { return } self.layout(scroll) } - } + }) + observers.append(center.addObserver( + forName: NSView.frameDidChangeNotification, object: scroll.contentView, + queue: .main + ) { [weak self, weak scroll] _ in + MainActor.assumeIsolated { + guard let self, let scroll else { return } + self.layout(scroll) + } + }) + observers.append(center.addObserver( + forName: NSView.frameDidChangeNotification, object: document, + queue: .main + ) { [weak self, weak scroll] _ in + MainActor.assumeIsolated { + guard let self, let scroll else { return } + self.layout(scroll) + } + }) } private func layout(_ scroll: NSScrollView, refresh: Bool = false) { - guard let model, !updating else { return } + guard let model else { return } + guard !updating else { + needsRelayout = true + return + } updating = true - defer { updating = false } + defer { + updating = false + if needsRelayout { + needsRelayout = false + layout(scroll) + } + } let bounds = scroll.contentView.bounds let size = CGSize( width: max(1, bounds.width), height: max(bounds.height, geometry.height)) @@ -263,8 +295,8 @@ struct DiffNativeTable: NSViewRepresentable { widthTask = nil for panel in panels.values { panel.canvas.cancelPendingWork() } textStore.cancel() - if let observer { NotificationCenter.default.removeObserver(observer) } - observer = nil + for observer in observers { NotificationCenter.default.removeObserver(observer) } + observers.removeAll() } } } diff --git a/macgit/Views/History/HistoryScreen.swift b/macgit/Views/History/HistoryScreen.swift index 6579bb2b..f17403f0 100644 --- a/macgit/Views/History/HistoryScreen.swift +++ b/macgit/Views/History/HistoryScreen.swift @@ -106,7 +106,13 @@ struct HistoryScreen: View { .onChange(of: baseBranch) { _, value in listModel.baseBranch = value } .onChange(of: selectedBranch) { _, value in listModel.selectedBranch = value - listModel.selectBranchTipIfPossible() + 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) + } } .onChange(of: searchText) { _, value in listModel.setSearchText(value) } .onChange(of: historyLoadSizeRaw) { _, value in diff --git a/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift b/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift index 4fcaa77a..5cee3b75 100644 --- a/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift +++ b/macgit/Views/MainWindow/MainWindowView+PullRequestActions.swift @@ -74,25 +74,22 @@ extension MainWindowView { func prepareCreatePullRequest(branch: String) async { guard await authorizePullRequestAccess() else { return } - await MainActor.run { - selectedItem = .item(.pullRequests) - } + guard !createPullRequestWindow.activateIfPresented() else { return } do { let remote = try await prepareRemoteBranchForPullRequest(branch: branch) - await pullRequestController.loadPullRequests(repositoryURL: repositoryURL, remoteName: remote) - if let errorMessage = pullRequestController.errorMessage { - await MainActor.run { - syncState.showError(errorMessage) - } - return - } - await pullRequestController.presentCreatePullRequest(sourceBranch: 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 { @@ -103,42 +100,33 @@ extension MainWindowView { func prepareCreatePullRequest() async { guard await authorizePullRequestAccess() else { return } - await MainActor.run { - selectedItem = .item(.pullRequests) - } - await pullRequestController.loadPullRequests(repositoryURL: repositoryURL) - if let errorMessage = pullRequestController.errorMessage { - await MainActor.run { - syncState.showError(errorMessage) - } - return - } - await pullRequestController.presentCreatePullRequest() + 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 submitCreatePullRequest(_ draft: PullRequestDraft) async { guard await authorizePullRequestAccess() else { return } do { - let remote = try await prepareRemoteBranchForPullRequest(branch: draft.sourceBranch) - await pullRequestController.loadPullRequests( - repositoryURL: repositoryURL, - remoteName: remote, - forceRefresh: true - ) - if let errorMessage = pullRequestController.errorMessage { + _ = try await prepareRemoteBranchForPullRequest(branch: draft.sourceBranch) + await pullRequestController.createPullRequest(draft) + if pullRequestController.createDraftSeed == nil { await MainActor.run { - syncState.showError(errorMessage) + createPullRequestWindow.close() + if let detailErrorMessage = pullRequestController.detailErrorMessage { + syncState.showError(detailErrorMessage) + pullRequestController.detailErrorMessage = nil + } } - return } - await pullRequestController.createPullRequest(draft) } catch { await MainActor.run { syncState.showError(error.localizedDescription) @@ -146,6 +134,38 @@ extension MainWindowView { } } + @MainActor + private func presentCreatePullRequestWindow() { + guard let seed = pullRequestController.createDraftSeed else { return } + createPullRequestWindow.show( + repositoryName: repositoryURL.lastPathComponent, + content: CreatePullRequestWindowContent( + controller: pullRequestController, + windowController: createPullRequestWindow, + seed: seed, + repositoryURL: repositoryURL, + authorizeAction: { await authorizePullRequestAccess() }, + onGenerateText: { field, sourceBranch, targetBranch in + try await generatePullRequestText( + field: field, + sourceBranch: sourceBranch, + targetBranch: targetBranch + ) + }, + onCancel: { + pullRequestController.dismissCreatePullRequest() + createPullRequestWindow.close() + }, + onCreate: { draft in + await submitCreatePullRequest(draft) + } + ), + onClose: { + pullRequestController.dismissCreatePullRequest() + } + ) + } + @MainActor func generatePullRequestText( field: PullRequestAIDraftField, diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index 024fd1b3..551a2971 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -172,6 +172,7 @@ struct MainWindowView: View { @State private var pullPreselectedBranch: String? = nil @State var showingSearchModal = false @State private var repositorySettingsWindow = RepositorySettingsWindowController() + @State var createPullRequestWindow = CreatePullRequestWindowController() @State var showingRepositorySettings = false @State var initiallySelectGitFlowSettings = false @State var pendingSearchFileOpenRequest: SearchFileOpenRequest? @@ -788,6 +789,7 @@ struct MainWindowView: View { .onDisappear { repositorySettingsWindow.close() branchComparisonWindow.close() + createPullRequestWindow.close() isPromotionHostVisible = false if promotionPresentation != nil { ProPromotionPresentationStore.shared.releasePresentation() @@ -1033,7 +1035,7 @@ struct MainWindowView: View { showingRenameBranchSheet = true }, onRequestCreatePullRequest: { branch in - runRepositoryOperation("Preparing pull request for \(branch)...") { + Task { await prepareCreatePullRequest(branch: branch) } }, @@ -1177,7 +1179,7 @@ struct MainWindowView: View { showingNewTagSheet = true }, onRequestCreatePullRequestFromWorkspace: { - runRepositoryOperation("Preparing pull request...") { + Task { await prepareCreatePullRequest() } }, @@ -1372,22 +1374,10 @@ struct MainWindowView: View { accountConnectionErrorMessage: providerAccountController.errorMessage, onReconnectAccount: onOpenConnections, onRequestCreatePullRequest: { - runRepositoryOperation("Preparing pull request...") { + Task { await prepareCreatePullRequest() } }, - onSubmitCreatePullRequest: { draft in - runRepositoryOperation("Creating pull request...") { - await submitCreatePullRequest(draft) - } - }, - onGenerateCreatePullRequestText: { field, sourceBranch, targetBranch in - try await generatePullRequestText( - field: field, - sourceBranch: sourceBranch, - targetBranch: targetBranch - ) - }, authorizeAction: { await authorizePullRequestAccess() } ) case .denied(let denial): diff --git a/macgit/Views/PullRequests/CreatePullRequestSheet.swift b/macgit/Views/PullRequests/CreatePullRequestSheet.swift index d1079e26..f570ab4b 100644 --- a/macgit/Views/PullRequests/CreatePullRequestSheet.swift +++ b/macgit/Views/PullRequests/CreatePullRequestSheet.swift @@ -45,6 +45,8 @@ struct CreatePullRequestView: View { var onCancel: () -> Void var onBranchesChanged: (String, String?) -> Void var onCreate: (PullRequestDraft) -> Void + var isPresentedInWindow: Bool = false + var cancelRequestID: UUID? @State private var sourceBranch: String @State private var targetBranch: String? @@ -72,7 +74,9 @@ struct CreatePullRequestView: View { onGenerateText: @escaping PullRequestAIGenerationAction, onCancel: @escaping () -> Void, onBranchesChanged: @escaping (String, String?) -> Void, - onCreate: @escaping (PullRequestDraft) -> Void + onCreate: @escaping (PullRequestDraft) -> Void, + isPresentedInWindow: Bool = false, + cancelRequestID: UUID? = nil ) { self.seed = seed self.repositoryURL = repositoryURL @@ -89,6 +93,8 @@ struct CreatePullRequestView: View { self.onCancel = onCancel self.onBranchesChanged = onBranchesChanged self.onCreate = onCreate + self.isPresentedInWindow = isPresentedInWindow + self.cancelRequestID = cancelRequestID _sourceBranch = State(initialValue: seed.sourceBranch) _targetBranch = State(initialValue: seed.targetBranch) _title = State(initialValue: seed.suggestedTitle) @@ -124,6 +130,9 @@ struct CreatePullRequestView: View { .onChange(of: targetBranch) { _, _ in reloadChanges() } + .onChange(of: cancelRequestID) { _, _ in + requestCancel() + } .confirmationDialog( "Discard Pull Request Draft?", isPresented: $showingDiscardConfirmation @@ -137,8 +146,10 @@ struct CreatePullRequestView: View { private var header: some View { HStack(spacing: 12) { - Button("Back to Pull Requests", systemImage: "chevron.left", action: requestCancel) - .buttonStyle(.borderless) + if !isPresentedInWindow { + Button("Back to Pull Requests", systemImage: "chevron.left", action: requestCancel) + .buttonStyle(.borderless) + } Text("Create Pull Request") .font(.headline) Spacer() @@ -372,6 +383,7 @@ struct CreatePullRequestView: View { } private func requestCancel() { + guard !isSubmitting else { return } if hasDraftChanges { showingDiscardConfirmation = true } else { diff --git a/macgit/Views/PullRequests/PullRequestListView.swift b/macgit/Views/PullRequests/PullRequestListView.swift index d51c12c6..75d35dae 100644 --- a/macgit/Views/PullRequests/PullRequestListView.swift +++ b/macgit/Views/PullRequests/PullRequestListView.swift @@ -26,113 +26,73 @@ struct PullRequestListView: View { var accountConnectionErrorMessage: String? = nil var onReconnectAccount: () -> Void = {} var onRequestCreatePullRequest: () -> Void = {} - var onSubmitCreatePullRequest: (PullRequestDraft) -> Void = { _ in } - var onGenerateCreatePullRequestText: PullRequestAIGenerationAction = { _, _, _ in "" } var authorizeAction: () async -> Bool = { true } @State private var pendingCommentPullRequest: PullRequestSummary? @State private var selectedPullRequestID: Int? var body: some View { - Group { - if let seed = controller.createDraftSeed { - CreatePullRequestView( - seed: seed, - repositoryURL: repositoryURL, - isSubmitting: controller.isPerformingAction, - changedFileCount: controller.createDraftChangedFileCount, - isLoadingChanges: controller.isLoadingCreateDraftChanges, - changesErrorMessage: controller.createDraftChangesErrorMessage, - participants: controller.createDraftParticipants, - isLoadingParticipants: controller.isLoadingCreateDraftParticipants, - participantsErrorMessage: controller.createDraftParticipantsErrorMessage, - loadSourceBranches: { query in - await controller.loadCreateDraftSourceBranches(query: query) - }, - loadTargetBranches: { query in - await controller.loadCreateDraftTargetBranches(query: query) - }, - onGenerateText: onGenerateCreatePullRequestText, - onCancel: { controller.dismissCreatePullRequest() }, - onBranchesChanged: { sourceBranch, targetBranch in - Task { - guard await authorizeAction() else { return } - if let targetBranch { - await controller.loadCreateDraftChanges( - sourceBranch: sourceBranch, - targetBranch: targetBranch - ) - } else { - controller.resetCreateDraftChanges() - } - } - }, - onCreate: onSubmitCreatePullRequest - ) - } else { - VStack(spacing: 0) { - header - Divider() - - if controller.isLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let errorMessage = controller.errorMessage { - VStack(spacing: 12) { - Text(errorMessage) - .font(.headline.scaled(by: textScale)) - if controller.needsAccountConnectionAction { - Text("Pull requests require an OAuth account over HTTPS. SSH keys are only used for Git fetch and push.") - .font(.callout.scaled(by: textScale)) - .multilineTextAlignment(.center) - .foregroundStyle(.tertiary) - .frame(maxWidth: 440) - - HStack(spacing: 10) { - Button(controller.accountConnectionActionTitle, action: onReconnectAccount) - Button("Reload", systemImage: "arrow.clockwise") { - Task { - guard await authorizeAction() else { return } - await controller.loadPullRequests(repositoryURL: repositoryURL, forceRefresh: true) - } - } - .disabled(controller.isLoading) - } - .font(.body.scaled(by: textScale)) - if let accountConnectionErrorMessage { - Text(accountConnectionErrorMessage) - .font(.callout.scaled(by: textScale)) - .multilineTextAlignment(.center) + VStack(spacing: 0) { + header + Divider() + + if controller.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = controller.errorMessage { + VStack(spacing: 12) { + Text(errorMessage) + .font(.headline.scaled(by: textScale)) + if controller.needsAccountConnectionAction { + Text("Pull requests require an OAuth account over HTTPS. SSH keys are only used for Git fetch and push.") + .font(.callout.scaled(by: textScale)) + .multilineTextAlignment(.center) + .foregroundStyle(.tertiary) + .frame(maxWidth: 440) + + HStack(spacing: 10) { + Button(controller.accountConnectionActionTitle, action: onReconnectAccount) + Button("Reload", systemImage: "arrow.clockwise") { + Task { + guard await authorizeAction() else { return } + await controller.loadPullRequests(repositoryURL: repositoryURL, forceRefresh: true) } } + .disabled(controller.isLoading) + } + .font(.body.scaled(by: textScale)) + if let accountConnectionErrorMessage { + Text(accountConnectionErrorMessage) + .font(.callout.scaled(by: textScale)) + .multilineTextAlignment(.center) } - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if controller.visibleItems.isEmpty { - Text(emptyStateMessage) - .font(.body.scaled(by: textScale)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if selectedPullRequestID != nil { - PersistentHSplit( - autosaveName: "PullRequestMainSplit", - left: { - pullRequestListPanel - }, - right: { - detailPanel - .frame( - minWidth: 680, - idealWidth: 820, - maxWidth: .infinity, - maxHeight: .infinity, - alignment: .top - ) - } - ) - } else { - pullRequestListPanel } } + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if controller.visibleItems.isEmpty { + Text(emptyStateMessage) + .font(.body.scaled(by: textScale)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if selectedPullRequestID != nil { + PersistentHSplit( + autosaveName: "PullRequestMainSplit", + left: { + pullRequestListPanel + }, + right: { + detailPanel + .frame( + minWidth: 680, + idealWidth: 820, + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .top + ) + } + ) + } else { + pullRequestListPanel } } .replacingSheet(item: $pendingCommentPullRequest) { pullRequest in diff --git a/macgitTests/DiffNativeTableTests.swift b/macgitTests/DiffNativeTableTests.swift index 10b77780..faa735b4 100644 --- a/macgitTests/DiffNativeTableTests.swift +++ b/macgitTests/DiffNativeTableTests.swift @@ -68,6 +68,32 @@ final class DiffNativeTableTests: XCTestCase { coordinator.stop() } + func testResizeRelayoutsDocumentAndVisiblePanels() throws { + let model = makeModel([ + DiffHunk( + header: "@@", + lines: [DiffLine(oldLineNumber: nil, newLineNumber: 1, text: "line", type: .added)] + ) + ]) + let coordinator = model.makeCoordinator() + let scroll = NSScrollView(frame: CGRect(x: 0, y: 0, width: 600, height: 400)) + scroll.documentView = coordinator.document + coordinator.observe(scroll) + coordinator.connectTextStore() + coordinator.apply(model, scroll: scroll) + defer { coordinator.stop() } + + scroll.setFrameSize(NSSize(width: 900, height: 400)) + scroll.layoutSubtreeIfNeeded() + + XCTAssertEqual(coordinator.document.frame.width, scroll.contentView.bounds.width) + let panel = try XCTUnwrap( + coordinator.document.subviews.compactMap { $0 as? DiffNativeHunkView }.first + ) + XCTAssertEqual(panel.frame.width, scroll.contentView.bounds.width) + XCTAssertEqual(panel.canvas.frame.width, scroll.contentView.bounds.width) + } + @MainActor private func makeModel(_ hunks: [DiffHunk]) -> DiffNativeTable { DiffNativeTable( diff --git a/macgitTests/HistoryPaginationTests.swift b/macgitTests/HistoryPaginationTests.swift index d7de9bba..4abb7e87 100644 --- a/macgitTests/HistoryPaginationTests.swift +++ b/macgitTests/HistoryPaginationTests.swift @@ -53,6 +53,29 @@ final class HistoryPaginationTests: XCTestCase { XCTAssertEqual(featurePage.first?.message, "feature tip") } + func testOldBranchTipFallsBackToBranchScopedHistoryWhenOutsideLoadedPage() async throws { + let repoURL = try makeRepoWithOlderFeatureBranchTip() + defer { try? FileManager.default.removeItem(at: repoURL) } + let model = HistoryListModel( + repositoryURL: repoURL, + branchFilter: .all, + selectedBranch: "feature", + pageSize: 1 + ) + + await model.load() + + XCTAssertEqual(model.rows.commits.map(\.message), ["main 3"]) + let selectedLoadedTip = await model.selectReferenceTipIfLoaded("feature") + XCTAssertFalse(selectedLoadedTip) + + model.branchFilter = .branch("feature") + await model.load() + + XCTAssertEqual(model.rows.commits.first?.message, "feature tip") + XCTAssertEqual(model.selectedCommit?.message, "feature tip") + } + func testHistoryPagingStateTracksLoadedPages() { var state = HistoryPagingState(pageSize: 100) XCTAssertTrue(state.beginLoadingMore()) @@ -253,18 +276,24 @@ final class HistoryPaginationTests: XCTestCase { let fileURL = repoURL.appendingPathComponent("tracked.txt") try "base\n".write(to: fileURL, atomically: true, encoding: .utf8) try runGit(["add", "tracked.txt"], in: repoURL) - try runGit(["commit", "-m", "base"], in: repoURL) + try runGit(["commit", "-m", "base"], in: repoURL, + environment: ["GIT_AUTHOR_DATE": "2026-01-01T00:00:00Z", + "GIT_COMMITTER_DATE": "2026-01-01T00:00:00Z"]) try runGit(["checkout", "-b", "feature"], in: repoURL) try "feature tip\n".write(to: fileURL, atomically: true, encoding: .utf8) try runGit(["add", "tracked.txt"], in: repoURL) - try runGit(["commit", "-m", "feature tip"], in: repoURL) + try runGit(["commit", "-m", "feature tip"], in: repoURL, + environment: ["GIT_AUTHOR_DATE": "2026-01-01T00:01:00Z", + "GIT_COMMITTER_DATE": "2026-01-01T00:01:00Z"]) try runGit(["checkout", "main"], in: repoURL) for index in 1...3 { try "main \(index)\n".write(to: fileURL, atomically: true, encoding: .utf8) try runGit(["add", "tracked.txt"], in: repoURL) - try runGit(["commit", "-m", "main \(index)"], in: repoURL) + let timestamp = "2026-01-01T00:0\(index + 1):00Z" + try runGit(["commit", "-m", "main \(index)"], in: repoURL, + environment: ["GIT_AUTHOR_DATE": timestamp, "GIT_COMMITTER_DATE": timestamp]) } return repoURL @@ -359,11 +388,16 @@ final class HistoryPaginationTests: XCTestCase { try runGit(["config", "user.email", "tests@example.com"], in: repositoryURL) } - private func runGit(_ arguments: [String], in repositoryURL: URL) throws { + private func runGit( + _ arguments: [String], + in repositoryURL: URL, + environment: [String: String] = [:] + ) throws { let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/git") task.arguments = arguments task.currentDirectoryURL = repositoryURL + task.environment = ProcessInfo.processInfo.environment.merging(environment) { _, override in override } let stdout = Pipe() let stderr = Pipe() diff --git a/macgitTests/PullRequestControllerTests.swift b/macgitTests/PullRequestControllerTests.swift index 9cb11875..998d6622 100644 --- a/macgitTests/PullRequestControllerTests.swift +++ b/macgitTests/PullRequestControllerTests.swift @@ -622,6 +622,41 @@ final class PullRequestControllerTests: XCTestCase { XCTAssertEqual(controller.createDraftSeed?.suggestedTitle, "Context Menu Pr") } + func testPrepareCreatePullRequestDoesNotLoadPullRequestList() async throws { + let account = makeAccount() + let token = makeToken() + let service = FakePullRequestProvider(result: .success([makeSummary()])) + let accountController = GitProviderAccountController( + store: FakePullRequestAccountStore(accounts: [account]), + tokenVault: FakePullRequestTokenVault(tokensByAccountID: [account.id: token]) + ) + await accountController.updateMacgitAccount(AccountSnapshot( + uid: "macgit-user-1", + email: "user@example.com", + displayName: nil, + providerIDs: [] + )) + let repositoryURL = URL(fileURLWithPath: "/tmp/macgit-pr-window") + let controller = PullRequestController( + providerAccountController: accountController, + tokenVault: FakePullRequestTokenVault(tokensByAccountID: [account.id: token]), + services: [.github: service], + remoteNameProvider: { _ in "origin" }, + remoteURLProvider: { _, _ in "https://github.com/octocat/Hello-World.git" }, + currentBranchProvider: { _ in "feature/pr-window" }, + defaultBranchProvider: { _ in "main" } + ) + + await controller.prepareCreatePullRequest( + repositoryURL: repositoryURL, + sourceBranch: "feature/pr-window" + ) + + XCTAssertEqual(service.listCallCount, 0) + XCTAssertEqual(controller.createDraftSeed?.sourceBranch, "feature/pr-window") + XCTAssertEqual(controller.createDraftSeed?.targetBranch, "main") + } + func testLoadCreateDraftSourceBranchesReturnsLocalBranchesFilteredByQuery() async throws { let account = makeAccount() let token = makeToken()