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
5 changes: 4 additions & 1 deletion Maple/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ final class AppState {
var stashes: [GitStashEntry] = []
var currentDiffLines: [DiffLine] = []
var currentDiffFile: DiffFile?
var selectedHunks: Set<Int> = []
/// Line-level selection keyed by hunk index. Values are the indices of
/// `+` / `-` lines within that hunk's `lines` array. A hunk with every
/// modifiable line selected behaves identically to whole-hunk selection.
var selectedLines: [Int: Set<Int>] = [:]
var commitDiffLines: [DiffLine] = []
var currentBlameLines: [BlameLine] = []
var changesViewMode: ChangesViewMode = .diff
Expand Down
82 changes: 80 additions & 2 deletions Maple/Models/GitModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,7 @@ struct DiffFile: Identifiable, Sendable {

/// 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.
/// truthful). Partial-line selection lives in `patchText(forLines:)`.
func patchText(forHunkIndices selected: Set<Int>) -> String {
guard !selected.isEmpty, !preamble.isEmpty else { return "" }
var lines: [String] = preamble
Expand All @@ -176,6 +175,85 @@ struct DiffFile: Identifiable, Sendable {
}
return lines.joined(separator: "\n") + "\n"
}

/// Builds a patch that keeps only the `+` / `-` lines named by `selection`
/// within each hunk. Unselected additions are dropped entirely (they do not
/// exist in the "old" side of the file). Unselected deletions become
/// context lines (they still exist after the patch is applied). The hunk
/// header's `oldCount` / `newCount` are rewritten to match the rewritten
/// body; `oldStart` / `newStart` stay anchored to their original positions.
///
/// - Parameter selection: map of hunk index → set of line indices within
/// that hunk's `lines` array to keep as real `+` / `-` edits.
/// - Returns: a git-apply-compatible patch, or an empty string if no hunk
/// contributes any selected change.
func patchText(forLines selection: [Int: Set<Int>]) -> String {
guard !preamble.isEmpty else { return "" }

var output: [String] = preamble
var anyEmitted = false

for (hunkIndex, hunk) in hunks.enumerated() {
guard let selectedIndices = selection[hunkIndex],
!selectedIndices.isEmpty else { continue }

let rewritten = Self.rewriteHunk(hunk, selectedLineIndices: selectedIndices)
guard rewritten.hasRealEdit else { continue }

output.append("@@ -\(hunk.oldStart),\(rewritten.oldCount) +\(hunk.newStart),\(rewritten.newCount) @@")
output.append(contentsOf: rewritten.body)
anyEmitted = true
}

guard anyEmitted else { return "" }
return output.joined(separator: "\n") + "\n"
}

private struct RewrittenHunk {
var body: [String]
var oldCount: Int
var newCount: Int
var hasRealEdit: Bool
}

private static func rewriteHunk(_ hunk: DiffHunk, selectedLineIndices: Set<Int>) -> RewrittenHunk {
var result = RewrittenHunk(body: [], oldCount: 0, newCount: 0, hasRealEdit: false)
for (lineIndex, line) in hunk.lines.enumerated() {
let isSelected = selectedLineIndices.contains(lineIndex)
emit(line: line, isSelected: isSelected, into: &result)
}
return result
}

private static func emit(line: DiffLine, isSelected: Bool, into result: inout RewrittenHunk) {
switch line.type {
case .context:
result.body.append(" " + line.content)
result.oldCount += 1
result.newCount += 1
case .addition:
// Unselected additions are dropped entirely — they don't exist in
// the old side and we don't want them in the new side either.
guard isSelected else { return }
result.body.append("+" + line.content)
result.newCount += 1
result.hasRealEdit = true
case .deletion:
if isSelected {
result.body.append("-" + line.content)
result.oldCount += 1
result.hasRealEdit = true
} else {
// Unselected deletion becomes context so the line survives the
// partial patch.
result.body.append(" " + line.content)
result.oldCount += 1
result.newCount += 1
}
case .header:
return
}
}
}

struct BlameLine: Identifiable, Sendable {
Expand Down
30 changes: 16 additions & 14 deletions Maple/Services/GitCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ final class GitCoordinator {
state.selectedFileChange = nil
state.currentDiffLines = []
state.currentDiffFile = nil
state.selectedHunks = []
state.selectedLines = [:]
state.commitDiffLines = []
} catch {
state.errorMessage = error.localizedDescription
Expand Down Expand Up @@ -180,11 +180,11 @@ final class GitCoordinator {
guard let path = state.currentRepoPath, let file = state.selectedFileChange else {
state.currentDiffLines = []
state.currentDiffFile = nil
state.selectedHunks = []
state.selectedLines = [:]
return
}

state.selectedHunks = []
state.selectedLines = [:]

do {
if file.status == .untracked {
Expand Down Expand Up @@ -259,14 +259,16 @@ final class GitCoordinator {
}
}

// MARK: - Hunk staging
// MARK: - Hunk / line staging

/// Stages the selected hunks of the currently viewed (unstaged) file by
/// piping a reconstructed patch to `git apply --cached`.
func stageSelectedHunks() async {
/// Stages the selected `+` / `-` lines of the currently viewed (unstaged)
/// file by piping a reconstructed patch to `git apply --cached`. When an
/// entire hunk's worth of lines is selected the result is identical to the
/// whole-hunk patch.
func stageSelectedLines() async {
guard let file = state.currentDiffFile,
!state.selectedHunks.isEmpty else { return }
let patch = file.patchText(forHunkIndices: state.selectedHunks)
!state.selectedLines.isEmpty else { return }
let patch = file.patchText(forLines: state.selectedLines)
guard !patch.isEmpty else { return }

await runLightOperation { path in
Expand All @@ -275,12 +277,12 @@ final class GitCoordinator {
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 {
/// Unstages the selected `+` / `-` lines of the currently viewed (staged)
/// file by piping the reverse patch to `git apply --cached --reverse`.
func unstageSelectedLines() async {
guard let file = state.currentDiffFile,
!state.selectedHunks.isEmpty else { return }
let patch = file.patchText(forHunkIndices: state.selectedHunks)
!state.selectedLines.isEmpty else { return }
let patch = file.patchText(forLines: state.selectedLines)
guard !patch.isEmpty else { return }

await runLightOperation { path in
Expand Down
53 changes: 40 additions & 13 deletions Maple/Views/ChangesTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,55 +10,82 @@ 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
private var selectedLineCount: Int {
state.selectedLines.values.reduce(0) { $0 + $1.count }
}

private var modifiableLineCount: Int {
guard let file = state.currentDiffFile else { return 0 }
return file.hunks.reduce(0) { total, hunk in
total + hunk.lines.reduce(0) { count, line in
count + ((line.type == .addition || line.type == .deletion) ? 1 : 0)
}
}
}

private var allSelected: Bool {
modifiableLineCount > 0 && selectedLineCount == modifiableLineCount
}

private var hasSelection: Bool { selectedLineCount > 0 }

var body: some View {
HStack(spacing: 8) {
Button {
if state.selectedHunks.count == hunkCount {
state.selectedHunks = []
if allSelected {
state.selectedLines = [:]
} else {
state.selectedHunks = Set(0..<hunkCount)
state.selectedLines = Self.allLines(in: state.currentDiffFile)
}
} label: {
Text(state.selectedHunks.count == hunkCount && hunkCount > 0 ? "Deselect all" : "Select all")
Text(allSelected ? "Deselect all" : "Select all")
}
.disabled(hunkCount == 0)
.disabled(modifiableLineCount == 0)

Text("\(state.selectedHunks.count) of \(hunkCount) hunks")
Text("\(selectedLineCount) of \(modifiableLineCount) lines")
.font(.caption)
.foregroundStyle(.secondary)

Spacer()

if isStagedView {
Button {
Task { await state.coordinator.unstageSelectedHunks() }
Task { await state.coordinator.unstageSelectedLines() }
} label: {
Label("Unstage selected", systemImage: "minus.circle")
}
.keyboardShortcut("s", modifiers: .command)
.disabled(!hasSelection || state.operationInProgress)
} else {
Button {
Task { await state.coordinator.stageSelectedHunks() }
Task { await state.coordinator.stageSelectedLines() }
} label: {
Label("Stage selected", systemImage: "plus.circle")
}
.keyboardShortcut("s", modifiers: .command)
.disabled(!hasSelection || state.operationInProgress)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.bar)
}

private static func allLines(in file: DiffFile?) -> [Int: Set<Int>] {
guard let file else { return [:] }
var out: [Int: Set<Int>] = [:]
for (hunkIndex, hunk) in file.hunks.enumerated() {
let indices = DiffView.modifiableIndices(in: hunk)
if !indices.isEmpty {
out[hunkIndex] = indices
}
}
return out
}
}

struct ChangesTabView: View {
Expand Down Expand Up @@ -134,7 +161,7 @@ struct ChangesTabView: View {
fileName: state.selectedFileChange?.path,
diffLines: state.currentDiffLines,
diffFile: state.currentDiffFile,
selection: state.currentDiffFile != nil ? $state.selectedHunks : nil
selection: state.currentDiffFile != nil ? $state.selectedLines : nil
)
case .blame:
BlameView(fileName: state.selectedFileChange?.path, blameLines: state.currentBlameLines)
Expand Down
Loading
Loading