Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Maple/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ final class AppState {
var branches: [GitBranch] = []
var stashes: [GitStashEntry] = []
var currentDiffLines: [DiffLine] = []
var currentDiffFile: DiffFile?
var selectedHunks: Set<Int> = []
var commitDiffLines: [DiffLine] = []
var currentBlameLines: [BlameLine] = []
var changesViewMode: ChangesViewMode = .diff
Expand Down
77 changes: 77 additions & 0 deletions Maple/Models/GitModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,83 @@ struct GitBranch: Identifiable, Hashable, Sendable {
}
}

/// A contiguous run of diff lines under a single `@@ ... @@` hunk header.
/// Carries the original header text so patches can be reconstructed without
/// re-parsing line numbers into the same format git expects.
struct DiffHunk: Identifiable, Sendable {
let id = UUID()
let header: String
let oldStart: Int
let oldCount: Int
let newStart: Int
let newCount: Int
let lines: [DiffLine]

nonisolated init(
header: String,
oldStart: Int,
oldCount: Int,
newStart: Int,
newCount: Int,
lines: [DiffLine]
) {
self.header = header
self.oldStart = oldStart
self.oldCount = oldCount
self.newStart = newStart
self.newCount = newCount
self.lines = lines
}
}

/// One file's diff: the raw preamble lines (`diff --git`, `index`, `---`, `+++`)
/// grouped together with that file's hunks. Preserving the preamble is what
/// lets us round-trip a subset of hunks back into `git apply --cached`.
struct DiffFile: Identifiable, Sendable {
let id = UUID()
let path: String?
let preamble: [String]
let hunks: [DiffHunk]

nonisolated init(path: String?, preamble: [String], hunks: [DiffHunk]) {
self.path = path
self.preamble = preamble
self.hunks = hunks
}

/// Flattened view for rendering; mirrors the old `[DiffLine]` shape
/// (hunk headers as `.header` lines, followed by their content lines).
var flattened: [DiffLine] {
var out: [DiffLine] = []
for hunk in hunks {
out.append(DiffLine(content: hunk.header, type: .header, oldLineNumber: nil, newLineNumber: nil))
out.append(contentsOf: hunk.lines)
}
return out
}

/// Builds a patch string including only the hunks at the given indices.
/// Only valid for whole-hunk selection (the hunk header's line counts stay
/// truthful). Partial-line selection needs a different codepath that also
/// rewrites those counts.
func patchText(forHunkIndices selected: Set<Int>) -> String {
guard !selected.isEmpty, !preamble.isEmpty else { return "" }
var lines: [String] = preamble
for (index, hunk) in hunks.enumerated() where selected.contains(index) {
lines.append(hunk.header)
for line in hunk.lines {
switch line.type {
case .addition: lines.append("+" + line.content)
case .deletion: lines.append("-" + line.content)
case .context: lines.append(" " + line.content)
case .header: continue
}
}
}
return lines.joined(separator: "\n") + "\n"
}
}

struct BlameLine: Identifiable, Sendable {
let id = UUID()
let lineNumber: Int
Expand Down
148 changes: 148 additions & 0 deletions Maple/Services/DiffParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//
// DiffParser.swift
// Maple
//
// Created by Pool Camacho on 4/15/26.
//

import Foundation

/// Parses raw `git diff` / `git show` output into structured `DiffFile` /
/// `DiffLine` representations.
enum DiffParser {

/// Structured view: one entry per file, each with its preamble preserved so
/// a patch can be reconstructed for a subset of hunks later.
static func parseFiles(_ output: String) -> [DiffFile] {
var state = State()
for raw in output.components(separatedBy: "\n") {
state.consume(raw)
}
state.flushFile()
return state.files
}

/// Flattened view kept for callers that render the diff as a single list.
/// Hunk headers are emitted as `DiffLine(type: .header)`.
static func parseFlat(_ output: String) -> [DiffLine] {
var lines: [DiffLine] = []
for file in parseFiles(output) {
lines.append(contentsOf: file.flattened)
}
return lines
}

// MARK: - Helpers

private struct HunkNumbers {
let oldStart: Int
let oldCount: Int
let newStart: Int
let newCount: Int
}

/// Parses a hunk header like `"@@ -10,3 +12,4 @@ optional context"` into its
/// four numeric fields. Returns nil if the header is malformed.
private static func parseHunkHeader(_ raw: String) -> HunkNumbers? {
let parts = raw.components(separatedBy: " ")
guard parts.count >= 3 else { return nil }
let oldBits = parts[1].dropFirst().components(separatedBy: ",")
let newBits = parts[2].dropFirst().components(separatedBy: ",")
return HunkNumbers(
oldStart: Int(oldBits.first ?? "0") ?? 0,
oldCount: oldBits.count > 1 ? Int(oldBits[1]) ?? 1 : 1,
newStart: Int(newBits.first ?? "0") ?? 0,
newCount: newBits.count > 1 ? Int(newBits[1]) ?? 1 : 1
)
}

private static let preamblePrefixes: [String] = [
"index ", "--- ",
"new file mode ", "deleted file mode ",
"similarity index ", "rename from ", "rename to "
]

private static func isPreambleLine(_ line: String) -> Bool {
preamblePrefixes.contains(where: { line.hasPrefix($0) })
}

// MARK: - Parser state

private struct State {
var files: [DiffFile] = []
var currentPreamble: [String] = []
var currentPath: String?
var currentHunks: [DiffHunk] = []
var hunkHeader: String?
var hunkNumbers = HunkNumbers(oldStart: 0, oldCount: 0, newStart: 0, newCount: 0)
var hunkLines: [DiffLine] = []
var oldLine = 0
var newLine = 0

mutating func consume(_ raw: String) {
if raw.hasPrefix("diff --git") {
flushFile()
currentPreamble = [raw]
} else if DiffParser.isPreambleLine(raw) {
currentPreamble.append(raw)
} else if raw.hasPrefix("+++ ") {
currentPreamble.append(raw)
capturePath(from: raw)
} else if raw.hasPrefix("@@") {
flushHunk()
startHunk(header: raw)
} else if raw.hasPrefix("+") {
hunkLines.append(DiffLine(content: String(raw.dropFirst()), type: .addition, oldLineNumber: nil, newLineNumber: newLine))
newLine += 1
} else if raw.hasPrefix("-") {
hunkLines.append(DiffLine(content: String(raw.dropFirst()), type: .deletion, oldLineNumber: oldLine, newLineNumber: nil))
oldLine += 1
} else if raw.hasPrefix(" ") {
hunkLines.append(DiffLine(content: String(raw.dropFirst()), type: .context, oldLineNumber: oldLine, newLineNumber: newLine))
oldLine += 1
newLine += 1
}
}

private mutating func capturePath(from raw: String) {
let after = String(raw.dropFirst(4))
if after.hasPrefix("b/") {
currentPath = String(after.dropFirst(2))
} else if after != "/dev/null" {
currentPath = after
}
}

private mutating func startHunk(header: String) {
hunkHeader = header
if let numbers = DiffParser.parseHunkHeader(header) {
hunkNumbers = numbers
oldLine = numbers.oldStart
newLine = numbers.newStart
}
}

mutating func flushHunk() {
guard let header = hunkHeader else { return }
currentHunks.append(DiffHunk(
header: header,
oldStart: hunkNumbers.oldStart,
oldCount: hunkNumbers.oldCount,
newStart: hunkNumbers.newStart,
newCount: hunkNumbers.newCount,
lines: hunkLines
))
hunkHeader = nil
hunkLines = []
}

mutating func flushFile() {
flushHunk()
guard !currentPreamble.isEmpty || !currentHunks.isEmpty else { return }
files.append(DiffFile(path: currentPath, preamble: currentPreamble, hunks: currentHunks))
currentPreamble = []
currentPath = nil
currentHunks = []
}
}
}
42 changes: 41 additions & 1 deletion Maple/Services/GitCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ final class GitCoordinator {
state.selectedCommit = nil
state.selectedFileChange = nil
state.currentDiffLines = []
state.currentDiffFile = nil
state.selectedHunks = []
state.commitDiffLines = []
} catch {
state.errorMessage = error.localizedDescription
Expand Down Expand Up @@ -177,17 +179,25 @@ final class GitCoordinator {
func loadFileDiff() async {
guard let path = state.currentRepoPath, let file = state.selectedFileChange else {
state.currentDiffLines = []
state.currentDiffFile = nil
state.selectedHunks = []
return
}

state.selectedHunks = []

do {
if file.status == .untracked {
state.currentDiffLines = try await git.diffForUntrackedFile(file.path, in: path)
state.currentDiffFile = nil
} else {
state.currentDiffLines = try await git.diff(for: file.path, staged: file.isStaged, in: path)
let diffFile = try await git.diffFile(for: file.path, staged: file.isStaged, in: path)
state.currentDiffFile = diffFile
state.currentDiffLines = diffFile?.flattened ?? []
}
} catch {
state.currentDiffLines = []
state.currentDiffFile = nil
}
}

Expand Down Expand Up @@ -249,6 +259,36 @@ final class GitCoordinator {
}
}

// MARK: - Hunk staging

/// Stages the selected hunks of the currently viewed (unstaged) file by
/// piping a reconstructed patch to `git apply --cached`.
func stageSelectedHunks() async {
guard let file = state.currentDiffFile,
!state.selectedHunks.isEmpty else { return }
let patch = file.patchText(forHunkIndices: state.selectedHunks)
guard !patch.isEmpty else { return }

await runLightOperation { path in
try await git.applyPatch(patch, cached: true, in: path)
}
await loadFileDiff()
}

/// Unstages the selected hunks of the currently viewed (staged) file by
/// piping the reverse patch to `git apply --cached --reverse`.
func unstageSelectedHunks() async {
guard let file = state.currentDiffFile,
!state.selectedHunks.isEmpty else { return }
let patch = file.patchText(forHunkIndices: state.selectedHunks)
guard !patch.isEmpty else { return }

await runLightOperation { path in
try await git.applyPatch(patch, cached: true, reverse: true, in: path)
}
await loadFileDiff()
}

// MARK: - Commit

func performCommit(message: String, amend: Bool = false) async {
Expand Down
Loading
Loading