From d2a0576e74e7f5dfe95be7fcbf664df84fff3344 Mon Sep 17 00:00:00 2001 From: Pool Camacho Date: Mon, 20 Apr 2026 06:19:38 -0600 Subject: [PATCH 1/3] feat(staging): per-line selection within a hunk Stage 4 of the interactive-staging plan. Replaces the whole-hunk-only selection model with per-line selection, falling back to whole-hunk behaviour when every modifiable line in a hunk is picked. - AppState.selectedHunks (Set) becomes selectedLines ([Int: Set]) keyed by hunk index, valued by line indices within that hunk's lines array. One model covers both hunk-whole and partial selections. - DiffFile.patchText(forLines:) walks each selected hunk and rewrites the body: unselected + lines are dropped, unselected - lines are demoted to context so the line survives the partial patch, and the hunk header's oldCount / newCount are recomputed. oldStart / newStart stay anchored to their original positions. - GitCoordinator.stageSelectedLines / unstageSelectedLines replace the previous hunk-only helpers and pipe the rewritten patch to git apply --cached (with --reverse for unstaging). - DiffView: every + / - DiffLineView gains an optional selection binding and a checkbox. Context rows reserve the slot so columns stay aligned. The @@ header checkbox still toggles the whole hunk by writing all-or-nothing into selectedLines. - HunkStagingToolbar: Select all now targets every modifiable line across all hunks; the counter reports "X of Y lines" and the Stage / Unstage button flips based on the selected file's isStaged. --- Maple/Models/AppState.swift | 5 +- Maple/Models/GitModels.swift | 82 +++++++++++++++++++- Maple/Services/GitCoordinator.swift | 30 ++++---- Maple/Views/ChangesTabView.swift | 51 ++++++++---- Maple/Views/DiffView.swift | 115 ++++++++++++++++++++++++---- 5 files changed, 237 insertions(+), 46 deletions(-) diff --git a/Maple/Models/AppState.swift b/Maple/Models/AppState.swift index 1e1c9ff..07ef7c8 100644 --- a/Maple/Models/AppState.swift +++ b/Maple/Models/AppState.swift @@ -23,7 +23,10 @@ final class AppState { var stashes: [GitStashEntry] = [] var currentDiffLines: [DiffLine] = [] var currentDiffFile: DiffFile? - var selectedHunks: Set = [] + /// 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] = [:] var commitDiffLines: [DiffLine] = [] var currentBlameLines: [BlameLine] = [] var changesViewMode: ChangesViewMode = .diff diff --git a/Maple/Models/GitModels.swift b/Maple/Models/GitModels.swift index f782e76..7c1c119 100644 --- a/Maple/Models/GitModels.swift +++ b/Maple/Models/GitModels.swift @@ -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) -> String { guard !selected.isEmpty, !preamble.isEmpty else { return "" } var lines: [String] = preamble @@ -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]) -> 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) -> 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 { diff --git a/Maple/Services/GitCoordinator.swift b/Maple/Services/GitCoordinator.swift index 100ce61..360103b 100644 --- a/Maple/Services/GitCoordinator.swift +++ b/Maple/Services/GitCoordinator.swift @@ -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 @@ -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 { @@ -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 @@ -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 diff --git a/Maple/Views/ChangesTabView.swift b/Maple/Views/ChangesTabView.swift index 9ff25b4..f8a1269 100644 --- a/Maple/Views/ChangesTabView.swift +++ b/Maple/Views/ChangesTabView.swift @@ -10,30 +10,43 @@ 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.. 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) @@ -41,14 +54,14 @@ private struct HunkStagingToolbar: View { if isStagedView { Button { - Task { await state.coordinator.unstageSelectedHunks() } + Task { await state.coordinator.unstageSelectedLines() } } label: { Label("Unstage selected", systemImage: "minus.circle") } .disabled(!hasSelection || state.operationInProgress) } else { Button { - Task { await state.coordinator.stageSelectedHunks() } + Task { await state.coordinator.stageSelectedLines() } } label: { Label("Stage selected", systemImage: "plus.circle") } @@ -59,6 +72,18 @@ private struct HunkStagingToolbar: View { .padding(.vertical, 6) .background(.bar) } + + private static func allLines(in file: DiffFile?) -> [Int: Set] { + guard let file else { return [:] } + var out: [Int: Set] = [:] + for (hunkIndex, hunk) in file.hunks.enumerated() { + let indices = DiffView.modifiableIndices(in: hunk) + if !indices.isEmpty { + out[hunkIndex] = indices + } + } + return out + } } struct ChangesTabView: View { @@ -134,7 +159,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) diff --git a/Maple/Views/DiffView.swift b/Maple/Views/DiffView.swift index 4dfb571..6ff7078 100644 --- a/Maple/Views/DiffView.swift +++ b/Maple/Views/DiffView.swift @@ -11,13 +11,13 @@ struct DiffView: View { let fileName: String? let diffLines: [DiffLine] var diffFile: DiffFile? - var selection: Binding>? + var selection: Binding<[Int: Set]>? init( fileName: String?, diffLines: [DiffLine], diffFile: DiffFile? = nil, - selection: Binding>? = nil + selection: Binding<[Int: Set]>? = nil ) { self.fileName = fileName self.diffLines = diffLines @@ -83,31 +83,90 @@ struct DiffView: View { } } - private func structuredBody(file: DiffFile, selection: Binding>) -> some View { + private func structuredBody( + file: DiffFile, + selection: Binding<[Int: Set]> + ) -> some View { ScrollView(.vertical) { LazyVStack(spacing: 0) { - ForEach(Array(file.hunks.enumerated()), id: \.offset) { index, hunk in + ForEach(Array(file.hunks.enumerated()), id: \.offset) { hunkIndex, 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) - } - } - ) + isSelected: headerBinding(hunk: hunk, hunkIndex: hunkIndex, selection: selection) ) - ForEach(hunk.lines) { line in - DiffLineView(line: line) + ForEach(Array(hunk.lines.enumerated()), id: \.offset) { lineIndex, line in + switch line.type { + case .addition, .deletion: + DiffLineView( + line: line, + isSelected: lineBinding( + hunkIndex: hunkIndex, + lineIndex: lineIndex, + selection: selection + ), + reservesSelectionSlot: true + ) + case .context, .header: + DiffLineView(line: line, reservesSelectionSlot: true) + } } } } } } + private func headerBinding( + hunk: DiffHunk, + hunkIndex: Int, + selection: Binding<[Int: Set]> + ) -> Binding { + let modifiable: Set = Self.modifiableIndices(in: hunk) + return Binding( + get: { + guard !modifiable.isEmpty else { return false } + return selection.wrappedValue[hunkIndex] == modifiable + }, + set: { newValue in + if newValue { + selection.wrappedValue[hunkIndex] = modifiable + } else { + selection.wrappedValue.removeValue(forKey: hunkIndex) + } + } + ) + } + + private func lineBinding( + hunkIndex: Int, + lineIndex: Int, + selection: Binding<[Int: Set]> + ) -> Binding { + Binding( + get: { selection.wrappedValue[hunkIndex]?.contains(lineIndex) ?? false }, + set: { newValue in + var current = selection.wrappedValue[hunkIndex] ?? [] + if newValue { + current.insert(lineIndex) + } else { + current.remove(lineIndex) + } + if current.isEmpty { + selection.wrappedValue.removeValue(forKey: hunkIndex) + } else { + selection.wrappedValue[hunkIndex] = current + } + } + ) + } + + static func modifiableIndices(in hunk: DiffHunk) -> Set { + var out: Set = [] + for (idx, line) in hunk.lines.enumerated() where line.type == .addition || line.type == .deletion { + out.insert(idx) + } + return out + } + private var additions: Int { diffLines.filter { $0.type == .addition }.count } @@ -147,6 +206,18 @@ private struct HunkHeaderRow: View { struct DiffLineView: View { let line: DiffLine + var isSelected: Binding? + var reservesSelectionSlot: Bool = false + + init( + line: DiffLine, + isSelected: Binding? = nil, + reservesSelectionSlot: Bool = false + ) { + self.line = line + self.isSelected = isSelected + self.reservesSelectionSlot = reservesSelectionSlot + } private var backgroundColor: Color { switch line.type { @@ -177,6 +248,16 @@ struct DiffLineView: View { var body: some View { HStack(spacing: 0) { + if let isSelected { + Toggle("", isOn: isSelected) + .toggleStyle(.checkbox) + .labelsHidden() + .padding(.horizontal, 4) + .frame(width: slotWidth) + } else if reservesSelectionSlot { + Color.clear.frame(width: slotWidth) + } + HStack(spacing: 2) { Text(line.oldLineNumber.map { String($0) } ?? "") .frame(width: 38, alignment: .trailing) @@ -201,6 +282,8 @@ struct DiffLineView: View { .padding(.vertical, 1.5) .background(backgroundColor) } + + private var slotWidth: CGFloat { 28 } } #Preview { From 5b5134dbe4eb1f71bd9ea7a594a394f9e37924cd Mon Sep 17 00:00:00 2001 From: Pool Camacho Date: Mon, 20 Apr 2026 06:22:15 -0600 Subject: [PATCH 2/3] docs(readme): add stars, forks, issues, last-commit badges Adds four shields.io badges next to the existing CI / Platform / Swift / License / Sponsors row so the repo page surfaces engagement signals (stargazers, forks, open issues) and freshness (last commit) at a glance. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index e42a4e9..5a915a1 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ [![Platform](https://img.shields.io/badge/platform-macOS%2014%2B-lightgrey)](https://www.apple.com/macos/) [![Swift](https://img.shields.io/badge/Swift-5-orange)](https://swift.org) [![License: MIT](https://img.shields.io/github/license/poolcamacho/Maple)](LICENSE) +[![Stars](https://img.shields.io/github/stars/poolcamacho/Maple?style=flat)](https://github.com/poolcamacho/Maple/stargazers) +[![Forks](https://img.shields.io/github/forks/poolcamacho/Maple?style=flat)](https://github.com/poolcamacho/Maple/network/members) +[![Issues](https://img.shields.io/github/issues/poolcamacho/Maple)](https://github.com/poolcamacho/Maple/issues) +[![Last Commit](https://img.shields.io/github/last-commit/poolcamacho/Maple?style=flat)](https://github.com/poolcamacho/Maple/commits/master) [![GitHub Sponsors](https://img.shields.io/github/sponsors/poolcamacho?label=Sponsor&logo=GitHub)](https://github.com/sponsors/poolcamacho) A **free, fast, native** macOS Git client built with SwiftUI. Inspired by [GitExtensions](https://gitextensions.github.io/), designed to feel at home on macOS. From 0032212b17414bc3709c4b65437e86fe9ee823d2 Mon Sep 17 00:00:00 2001 From: Pool Camacho Date: Mon, 20 Apr 2026 06:24:45 -0600 Subject: [PATCH 3/3] feat(staging): Cmd+S shortcut + README line-level done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Binds Cmd+S to the visible Stage / Unstage selected button in the changes diff toolbar. The shortcut fires whichever action is active given the selected file's staged state. Also promotes interactive staging — line level to Done in the README roadmap, removes it from Next, and trims Cmd+S from the pending keyboard-shortcuts bullet since it ships here. --- Maple/Views/ChangesTabView.swift | 2 ++ README.md | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Maple/Views/ChangesTabView.swift b/Maple/Views/ChangesTabView.swift index f8a1269..cf56fb6 100644 --- a/Maple/Views/ChangesTabView.swift +++ b/Maple/Views/ChangesTabView.swift @@ -58,6 +58,7 @@ private struct HunkStagingToolbar: View { } label: { Label("Unstage selected", systemImage: "minus.circle") } + .keyboardShortcut("s", modifiers: .command) .disabled(!hasSelection || state.operationInProgress) } else { Button { @@ -65,6 +66,7 @@ private struct HunkStagingToolbar: View { } label: { Label("Stage selected", systemImage: "plus.circle") } + .keyboardShortcut("s", modifiers: .command) .disabled(!hasSelection || state.operationInProgress) } } diff --git a/README.md b/README.md index 5a915a1..ecd65ef 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,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. +- **Interactive staging.** Tick a checkbox on any hunk header — or any individual `+` / `-` line — and stage only what you want. `Cmd+S` stages (or unstages) the current selection. Works in reverse too: view the staged diff and surgically peel changes back into the working tree. - **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. @@ -87,15 +87,16 @@ Utils/ FolderPicker, DateExtensions - [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) +- [x] Interactive staging — line level (pick individual `+` / `-` lines within a hunk) +- [x] `Cmd+S` stages / unstages the current selection ### Next -- [ ] Interactive staging — line level (pick individual `+` / `-` lines within a hunk) - [ ] Tag management (create, list, delete) - [ ] Search filtering (commits, files) - [ ] Clone from URL - [ ] Remote management (add, remove, configure) -- [ ] Keyboard shortcuts (`Cmd+S` stage, `Cmd+Enter` commit, command palette) +- [ ] Keyboard shortcuts (`Cmd+Enter` commit, command palette) - [ ] Persist open repositories between sessions - [ ] Settings and preferences - [ ] Signed and notarized releases