diff --git a/Maple/Models/AppState.swift b/Maple/Models/AppState.swift index a5ddaff..1e1c9ff 100644 --- a/Maple/Models/AppState.swift +++ b/Maple/Models/AppState.swift @@ -22,6 +22,8 @@ final class AppState { var branches: [GitBranch] = [] var stashes: [GitStashEntry] = [] var currentDiffLines: [DiffLine] = [] + var currentDiffFile: DiffFile? + var selectedHunks: Set = [] var commitDiffLines: [DiffLine] = [] var currentBlameLines: [BlameLine] = [] var changesViewMode: ChangesViewMode = .diff diff --git a/Maple/Models/GitModels.swift b/Maple/Models/GitModels.swift index b46ed5e..f782e76 100644 --- a/Maple/Models/GitModels.swift +++ b/Maple/Models/GitModels.swift @@ -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) -> 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 diff --git a/Maple/Services/DiffParser.swift b/Maple/Services/DiffParser.swift new file mode 100644 index 0000000..8311241 --- /dev/null +++ b/Maple/Services/DiffParser.swift @@ -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 = [] + } + } +} diff --git a/Maple/Services/GitCoordinator.swift b/Maple/Services/GitCoordinator.swift index aef2e33..100ce61 100644 --- a/Maple/Services/GitCoordinator.swift +++ b/Maple/Services/GitCoordinator.swift @@ -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 @@ -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 } } @@ -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 { diff --git a/Maple/Services/GitService.swift b/Maple/Services/GitService.swift index ffb6b26..de28e1a 100644 --- a/Maple/Services/GitService.swift +++ b/Maple/Services/GitService.swift @@ -32,7 +32,12 @@ actor GitService { private static let defaultTimeout: TimeInterval = 30 - func run(_ arguments: [String], in directory: String, timeout: TimeInterval = defaultTimeout) async throws -> String { + func run( + _ arguments: [String], + in directory: String, + timeout: TimeInterval = defaultTimeout, + stdin: String? = nil + ) async throws -> String { guard FileManager.default.isExecutableFile(atPath: Self.gitPath) else { throw GitError.gitNotFound(path: Self.gitPath) } @@ -40,7 +45,20 @@ actor GitService { let process = makeProcess(arguments: arguments, directory: directory) let stdout = Pipe(); process.standardOutput = stdout let stderr = Pipe(); process.standardError = stderr - let stdinFD = attachDevNullStdin(to: process) + + let stdinPipe: Pipe? + let stdinFD: Int32 + if stdin != nil { + // Caller wants to feed data in. Use a Pipe; we'll write after launch + // and close the write end so git sees EOF. + let pipe = Pipe() + process.standardInput = pipe + stdinPipe = pipe + stdinFD = -1 + } else { + stdinPipe = nil + stdinFD = attachDevNullStdin(to: process) + } do { try process.run() @@ -53,6 +71,19 @@ actor GitService { throw GitError.processLaunchFailed(underlying: "git \(cmd): \(detail)") } + // If stdin was provided, write it now and close the write end so the + // child sees EOF and can exit. Writing before the drain starts is safe + // for small payloads (< pipe buffer); for larger ones we'd need to + // write in a detached task too, but interactive staging patches are + // well below that threshold in practice. + if let stdinPipe, let stdin { + let handle = stdinPipe.fileHandleForWriting + if let data = stdin.data(using: .utf8) { + try? handle.write(contentsOf: data) + } + try? handle.close() + } + // Start draining BEFORE waiting on the process. macOS pipe buffers cap at // ~64KB; if git produces more output than that and nothing is reading, // git blocks on write, the process never exits, and our timeout fires. @@ -362,6 +393,21 @@ actor GitService { return Self.parseDiff(output) } + /// Structured variant of `diff(for:staged:in:)` returning the parsed + /// `DiffFile` (preamble + hunks) so callers can build partial patches. + /// Returns `nil` when git produced no diff (no changes). + func diffFile(for filePath: String, staged: Bool, in directory: String) async throws -> DiffFile? { + var args = ["diff", "--no-color"] + if staged { + args.append("--cached") + } + args.append("--") + args.append(filePath) + + let output = try await run(args, in: directory) + return Self.parseDiffFiles(output).first + } + func diffForCommit(_ commitHash: String, in directory: String) async throws -> [DiffLine] { let output = try await run( ["show", "--no-color", "--format=", commitHash], @@ -394,40 +440,21 @@ actor GitService { return lines } - static func parseDiff(_ output: String) -> [DiffLine] { - var lines: [DiffLine] = [] - var oldLine = 0 - var newLine = 0 - - for rawLine in output.components(separatedBy: "\n") { - if rawLine.hasPrefix("@@") { - // Hunk header format: "@@ -oldStart,oldCount +newStart,newCount @@" - let numbers = rawLine.components(separatedBy: " ") - if numbers.count >= 3 { - let newPart = numbers[2] - let oldPart = numbers[1] - newLine = Int(newPart.dropFirst().components(separatedBy: ",").first ?? "0") ?? 0 - oldLine = Int(oldPart.dropFirst().components(separatedBy: ",").first ?? "0") ?? 0 - } - lines.append(DiffLine(content: rawLine, type: .header, oldLineNumber: nil, newLineNumber: nil)) - } else if rawLine.hasPrefix("+") && !rawLine.hasPrefix("+++") { - let content = String(rawLine.dropFirst()) - lines.append(DiffLine(content: content, type: .addition, oldLineNumber: nil, newLineNumber: newLine)) - newLine += 1 - } else if rawLine.hasPrefix("-") && !rawLine.hasPrefix("---") { - let content = String(rawLine.dropFirst()) - lines.append(DiffLine(content: content, type: .deletion, oldLineNumber: oldLine, newLineNumber: nil)) - oldLine += 1 - } else if rawLine.hasPrefix(" ") { - let content = String(rawLine.dropFirst()) - lines.append(DiffLine(content: content, type: .context, oldLineNumber: oldLine, newLineNumber: newLine)) - oldLine += 1 - newLine += 1 - } - // Silently drop "diff --git", "index", "---", "+++" preamble lines. - } - - return lines + // Diff parsing lives in `DiffParser`. These thin aliases keep older callers + // that used Self.parseDiff / Self.parseDiffFiles working without churn. + static func parseDiffFiles(_ output: String) -> [DiffFile] { DiffParser.parseFiles(output) } + static func parseDiff(_ output: String) -> [DiffLine] { DiffParser.parseFlat(output) } + + // MARK: - Apply patch (for interactive staging) + + /// Pipes a patch to `git apply`, optionally with `--cached` (to stage into + /// the index) and/or `--reverse` (to unstage from the index). + func applyPatch(_ patch: String, cached: Bool = false, reverse: Bool = false, in directory: String) async throws { + var args = ["apply", "--whitespace=nowarn"] + if cached { args.append("--cached") } + if reverse { args.append("--reverse") } + args.append("-") + _ = try await run(args, in: directory, stdin: patch) } // MARK: - Blame diff --git a/Maple/Views/ChangesTabView.swift b/Maple/Views/ChangesTabView.swift index 4126aa6..9ff25b4 100644 --- a/Maple/Views/ChangesTabView.swift +++ b/Maple/Views/ChangesTabView.swift @@ -7,6 +7,60 @@ import SwiftUI +private struct HunkStagingToolbar: View { + @Bindable var state: AppState + + private var hasSelection: Bool { !state.selectedHunks.isEmpty } + + private var isStagedView: Bool { + state.selectedFileChange?.isStaged ?? false + } + + private var hunkCount: Int { + state.currentDiffFile?.hunks.count ?? 0 + } + + var body: some View { + HStack(spacing: 8) { + Button { + if state.selectedHunks.count == hunkCount { + state.selectedHunks = [] + } else { + state.selectedHunks = Set(0.. 0 ? "Deselect all" : "Select all") + } + .disabled(hunkCount == 0) + + Text("\(state.selectedHunks.count) of \(hunkCount) hunks") + .font(.caption) + .foregroundStyle(.secondary) + + Spacer() + + if isStagedView { + Button { + Task { await state.coordinator.unstageSelectedHunks() } + } label: { + Label("Unstage selected", systemImage: "minus.circle") + } + .disabled(!hasSelection || state.operationInProgress) + } else { + Button { + Task { await state.coordinator.stageSelectedHunks() } + } label: { + Label("Stage selected", systemImage: "plus.circle") + } + .disabled(!hasSelection || state.operationInProgress) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.bar) + } +} + struct ChangesTabView: View { @Bindable var state: AppState let availableWidth: CGFloat @@ -72,7 +126,16 @@ struct ChangesTabView: View { switch state.changesViewMode { case .diff: - DiffView(fileName: state.selectedFileChange?.path, diffLines: state.currentDiffLines) + if state.currentDiffFile != nil { + HunkStagingToolbar(state: state) + Divider() + } + DiffView( + fileName: state.selectedFileChange?.path, + diffLines: state.currentDiffLines, + diffFile: state.currentDiffFile, + selection: state.currentDiffFile != nil ? $state.selectedHunks : nil + ) case .blame: BlameView(fileName: state.selectedFileChange?.path, blameLines: state.currentBlameLines) } diff --git a/Maple/Views/DiffView.swift b/Maple/Views/DiffView.swift index 0ac64b1..4dfb571 100644 --- a/Maple/Views/DiffView.swift +++ b/Maple/Views/DiffView.swift @@ -10,6 +10,20 @@ import SwiftUI struct DiffView: View { let fileName: String? let diffLines: [DiffLine] + var diffFile: DiffFile? + var selection: Binding>? + + init( + fileName: String?, + diffLines: [DiffLine], + diffFile: DiffFile? = nil, + selection: Binding>? = nil + ) { + self.fileName = fileName + self.diffLines = diffLines + self.diffFile = diffFile + self.selection = selection + } var body: some View { VStack(spacing: 0) { @@ -51,12 +65,43 @@ struct DiffView: View { .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let file = diffFile, let selection { + structuredBody(file: file, selection: selection) } else { - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(diffLines) { line in - DiffLineView(line: line) - } + flatBody + } + } + } + + private var flatBody: some View { + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(diffLines) { line in + DiffLineView(line: line) + } + } + } + } + + private func structuredBody(file: DiffFile, selection: Binding>) -> some View { + ScrollView(.vertical) { + LazyVStack(spacing: 0) { + ForEach(Array(file.hunks.enumerated()), id: \.offset) { index, hunk in + HunkHeaderRow( + header: hunk.header, + isSelected: Binding( + get: { selection.wrappedValue.contains(index) }, + set: { newValue in + if newValue { + selection.wrappedValue.insert(index) + } else { + selection.wrappedValue.remove(index) + } + } + ) + ) + ForEach(hunk.lines) { line in + DiffLineView(line: line) } } } @@ -72,6 +117,34 @@ struct DiffView: View { } } +private struct HunkHeaderRow: View { + let header: String + @Binding var isSelected: Bool + + var body: some View { + HStack(spacing: 6) { + Toggle("", isOn: $isSelected) + .toggleStyle(.checkbox) + .labelsHidden() + .padding(.leading, 8) + + Text("@@") + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.blue) + + Text(header) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + + Spacer() + } + .padding(.vertical, 3) + .background(.blue.opacity(0.08)) + } +} + struct DiffLineView: View { let line: DiffLine diff --git a/README.md b/README.md index ee0bc92..e42a4e9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Most Git GUIs on macOS are Electron based, locked behind a subscription, or over ## Features - **Real commit graph.** Lane assignment, curved edges per parent, merge nodes rendered as a ring so joins stay legible across busy histories. +- **Interactive staging.** Tick a checkbox on any hunk header and stage only what you want. Works in reverse too: view the staged diff and unstage selected hunks without touching the rest. - **Diff viewer with Blame toggle.** Syntax coloured hunks, line numbers, and per line author / hash / date when Blame is on. - **Merge and rebase with conflict UX.** Detects `UU`, `AA`, `DD` automatically, shows an operation banner with Abort / Continue / Skip, and lets you resolve per file with Use Ours or Use Theirs. - **Branch management.** Local and remote branches, checkout (including remote to local tracking), create, rename, delete. @@ -81,10 +82,11 @@ Utils/ FolderPicker, DateExtensions - [x] Blame view with per line author, hash, date - [x] Commit graph with real branch topology - [x] Merge and rebase with conflict resolution UI +- [x] Interactive staging — hunk level (select hunks with checkboxes, stage or unstage) ### Next -- [ ] Interactive staging (stage individual hunks and lines) +- [ ] Interactive staging — line level (pick individual `+` / `-` lines within a hunk) - [ ] Tag management (create, list, delete) - [ ] Search filtering (commits, files) - [ ] Clone from URL