Skip to content
Merged
85 changes: 85 additions & 0 deletions macgit/App/CommitPatchConflictWindowController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//
// CommitPatchConflictWindowController.swift
// macgit
//
// Copyright (C) 2026 Thanh Tran <trantienthanh2412@gmail.com>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
import AppKit
import SwiftUI

@MainActor
final class CommitPatchConflictWindowController: NSWindowController, NSWindowDelegate {
private weak var controller: CommitPatchController?

init() { super.init(window: nil) }

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func show(file: CommitPatchReviewFile, controller: CommitPatchController) {
self.controller = controller
let visibleFrame = (NSApp.keyWindow?.screen ?? NSScreen.main)?.visibleFrame
?? NSRect(x: 0, y: 0, width: 1200, height: 800)
let size = NSSize(width: min(1100, visibleFrame.width - 40),
height: min(760, visibleFrame.height - 80))
let window = NSWindow(contentRect: NSRect(origin: .zero, size: size),
styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
window.title = "\(file.reviewTitle) — \(file.file.path)"
window.isReleasedWhenClosed = false
window.tabbingMode = .disallowed
window.contentMinSize = NSSize(width: min(700, size.width), height: min(500, size.height))
window.delegate = self
let hostingView = NSHostingView(rootView: GeometryReader { geometry in
CommitPatchConflictWindowContent(file: file, controller: controller)
.frame(width: geometry.size.width, height: geometry.size.height)
})
// The window owns the viewport, including for long lines and large diffs.
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)
}

func windowShouldClose(_ sender: NSWindow) -> Bool {
controller?.isBusy != true
}

func windowWillClose(_ notification: Notification) {
// Release the hosted observation/callbacks before notifying the owner.
window?.contentView = nil
window = nil
let owner = controller
controller = nil
owner?.closeConflict()
}
}

private struct CommitPatchConflictWindowContent: View {
let file: CommitPatchReviewFile
let controller: CommitPatchController

var body: some View {
CommitPatchConflictSheet(file: file, isBusy: controller.isBusy, errorMessage: controller.reviewError,
onCancel: { controller.closeConflict() },
onSkip: { controller.skip(fileID: file.id) },
onResolve: { result in controller.resolveAndClose(fileID: file.id, result: result) })
}
}
159 changes: 159 additions & 0 deletions macgit/App/CommitPatchController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//
// CommitPatchController.swift
// macgit
//
// Copyright (C) 2026 Thanh Tran <trantienthanh2412@gmail.com>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
import AppKit
import Foundation
import Observation

@MainActor @Observable
final class CommitPatchController {
var prepared: PreparedCommitPatch? {
didSet { if prepared == nil { closeConflict() } }
}
private(set) var isResolving = false
@ObservationIgnored private var conflictWindow: CommitPatchConflictWindowController?

func openConflict(_ file: CommitPatchReviewFile) {
guard !isBusy, prepared?.reviewFiles.contains(where: { $0.id == file.id && $0.state == .conflict }) == true else { return }
if let conflictWindow {
conflictWindow.showWindow(nil)
conflictWindow.window?.makeKeyAndOrderFront(nil)
return
}
let window = CommitPatchConflictWindowController()
conflictWindow = window
isResolving = true
window.show(file: file, controller: self)
}

func closeConflict() {
let window = conflictWindow
conflictWindow = nil
isResolving = false
window?.close()
}

private(set) var isBusy = false
private(set) var isPreparing = false
@ObservationIgnored private var preparationTask: Task<Void, Never>?
@ObservationIgnored private var preparationID = UUID()
var errorMessage = ""
var showingError = false
private(set) var reviewError: String?

func prepare(_ request: CommitPatchRequest, in repositoryURL: URL) {
guard !isBusy, prepared == nil else { return }
reviewError = nil
isBusy = true
isPreparing = true
let id = UUID()
preparationID = id
preparationTask = Task {
defer {
if preparationID == id {
isBusy = false
isPreparing = false
preparationTask = nil
}
}
do {
let result = try await GitStatusService.shared.prepareCommitPatch(request, in: repositoryURL)
guard preparationID == id, !Task.isCancelled else { return }
prepared = result
} catch {
guard preparationID == id, !Task.isCancelled else { return }
errorMessage = error.localizedDescription
showingError = true
}
}
}

func cancelPreparation() {
guard isPreparing else { return }
preparationID = UUID()
preparationTask?.cancel()
preparationTask = nil
isPreparing = false
isBusy = false
}

func apply(undoManager: GitUndoManager?, syncState: SyncState?, run: RepositoryOperationRunner) {
guard let prepared, !isBusy else { return }
guard !prepared.hasConflicts else { return }
guard prepared.hasChanges else {
self.prepared = nil
return
}
reviewError = nil
isBusy = true
run("\(prepared.request.direction.rawValue) selected changes…") { [self] in
defer { isBusy = false }
do {
try await GitStatusService.shared.applyCommitPatch(prepared)
undoManager?.register(GitUndoEntry(
repositoryURL: prepared.repositoryURL,
label: "\(prepared.request.direction.rawValue) selected changes from \(prepared.request.commit.prefix(7))",
undoOperation: .sequence([.requireHead(prepared.targetHead), .checkedWorkingTreePatch(patch: prepared.patch, reverse: true)]),
redoOperation: .sequence([.requireHead(prepared.targetHead), .checkedWorkingTreePatch(patch: prepared.patch, reverse: false)])
))
self.prepared = nil
await syncState?.refresh(repositoryURL: prepared.repositoryURL)
NotificationCenter.default.post(name: .repositoryDidChange, object: nil,
userInfo: ["repositoryURL": prepared.repositoryURL])
} catch {
reviewError = error.localizedDescription
}
}
}

/// Skipping only changes the preview; Apply revalidates the repository before writing.
func skip(fileID: UUID) {
guard !isBusy, var updated = prepared,
let index = updated.reviewFiles.firstIndex(where: { $0.id == fileID && $0.state == .conflict }) else { return }
updated.reviewFiles[index].state = .skipped
updated.reviewFiles[index].conflict = nil
updated.rebuildPatch()
reviewError = nil
prepared = updated
closeConflict()
}

func resolveAndClose(fileID: UUID, result: String) {
guard !isBusy else { return }
Task { [self] in
if await resolve(fileID: fileID, result: result) {
closeConflict()
}
}
}

func resolve(fileID: UUID, result: String?) async -> Bool {
guard let prepared, !isBusy else { return false }
isBusy = true
reviewError = nil
defer { isBusy = false }
do {
self.prepared = try await GitStatusService.shared.resolveCommitPatch(prepared, fileID: fileID, result: result)
return true
} catch {
reviewError = error.localizedDescription
return false
}
}
}
50 changes: 50 additions & 0 deletions macgit/Models/CommitPatchRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// CommitPatchRequest.swift
// macgit
//
// Copyright (C) 2026 Thanh Tran <trantienthanh2412@gmail.com>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
import Foundation

nonisolated struct CommitPatchRequest: Sendable {
enum Direction: String, Sendable {
case apply = "Apply"
case revert = "Revert"
}

// Line numbers are relative to the immutable source commit, never the working copy.
struct Line: Hashable, Sendable {
let old: Int?
let new: Int?

init(_ line: DiffLine) {
old = line.oldLineNumber
new = line.newLineNumber
}

init(old: Int?, new: Int?) {
self.old = old
self.new = new
}
}

let commit: String
let files: [CommitFileChange]
let direction: Direction
// nil means complete files; a nonempty set means only these changed lines in one file.
let lines: Set<Line>?
let scope: String
}
77 changes: 77 additions & 0 deletions macgit/Models/CommitPatchReviewFile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// CommitPatchReviewFile.swift
// macgit
//
// Copyright (C) 2026 Thanh Tran <trantienthanh2412@gmail.com>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
import Foundation

nonisolated struct CommitPatchReviewFile: Identifiable, Sendable {
enum State: String, Sendable {
case ready = "Ready"
case merged = "Merged with your changes"
case alreadyApplied = "Already present"
case conflict = "Needs resolution"
case skipped = "Skipped"
case resolved = "Resolved"
}

let file: CommitFileChange
var patch: String
var state: State
var conflict: Conflict?
var id: UUID { file.id }

struct Conflict: Sendable {
enum Kind: Sendable, Equatable {
case missingFile
case unsupportedChange
case existingMarkers
case overlappingEdits
}

let kind: Kind
let message: String
let current: String
let selected: String
// nil means a structural conflict that cannot safely use the text editor.
let markedResult: String?
let permissions: Int
}

var reviewTitle: String {
conflict?.markedResult == nil ? "Review Selected Changes" : "Resolve Text Conflict"
}

var displayState: String {
guard state == .conflict, let conflict else { return state.rawValue }
switch conflict.kind {
case .missingFile: return "File missing"
case .unsupportedChange: return "Cannot apply safely"
case .existingMarkers, .overlappingEdits: return "Needs resolution"
}
}

var appliesChanges: Bool {
state != .conflict && state != .skipped && state != .alreadyApplied && !patch.isEmpty
}

static func containsConflictMarkers(_ text: String) -> Bool {
text.components(separatedBy: "\n").contains {
$0.hasPrefix("<<<<<<<") || $0.hasPrefix("=======") || $0.hasPrefix(">>>>>>>") || $0.hasPrefix("|||||||")
}
}
}
Loading
Loading