Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6c40b36
feat(diff): revert individual hunks from the diff view
fcmiranda Apr 30, 2026
4a72c45
feat(diff): hover tooltip on revert-hunk marker
Blankeos Apr 30, 2026
7877843
feat(diff): bind <c-r> to revert selected hunk
Blankeos Apr 30, 2026
b579130
refactor(revert-hunk): rebind revert-hunk from ctrl+r to enter.
Blankeos Apr 30, 2026
214f465
fix(revert): inaccurate index-based hunk reverting.
Blankeos Apr 30, 2026
22d0113
feat(revert-hunk): added a local undo state stack.
Blankeos Apr 30, 2026
80ecfdf
fix(diff): remove redundant ctrl-j/k and sync hunk selection with nav…
fcmiranda May 15, 2026
5f448fb
feat(revert-hunk): keep selection after revert and style marker
fcmiranda May 15, 2026
0ba76ef
fix(revert-hunk): recenter selected hunk after refresh
fcmiranda May 15, 2026
9008152
feat(diff): implement sticky revert hunk marker and visual line span …
fcmiranda May 15, 2026
566d706
fix(gui): center selected revert block when navigating hunks
fcmiranda May 15, 2026
87e365a
fix(gui): improve line index calculation for selected revert hunk
fcmiranda May 15, 2026
0600a4b
fix(gui): remove cycling hints for revert blocks from help entries
fcmiranda May 15, 2026
7f31007
feat(gui): add hunk mode with navigation and help hints
fcmiranda May 15, 2026
0db65e2
refactor(revert-hunk): bind revert action to r
fcmiranda May 15, 2026
352a834
fix(gui): update revert icon to new design in help entries and config
fcmiranda May 15, 2026
cf69f2b
Refactor hunk marker handling and rename related structures
fcmiranda May 15, 2026
bb2f672
feat(gui): enhance hunk mode entry and update status bar hints
fcmiranda May 16, 2026
7726d5a
Merge pull request #1 from fcmiranda/develop
fcmiranda May 16, 2026
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
12 changes: 12 additions & 0 deletions src/config/keybindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ pub struct UniversalKeybinding {
pub prev_screen_mode: String,
#[serde(rename = "createPatchOptionsMenu")]
pub create_patch_options_menu: String,
#[serde(rename = "prevRevertBlock")]
pub prev_revert_block: String,
#[serde(rename = "nextRevertBlock")]
pub next_revert_block: String,
#[serde(rename = "revertBlock")]
pub revert_block: String,
#[serde(rename = "undoRevertBlock")]
pub undo_revert_block: String,
}

impl Default for UniversalKeybinding {
Expand Down Expand Up @@ -157,6 +165,10 @@ impl Default for UniversalKeybinding {
next_screen_mode: "+".into(),
prev_screen_mode: "_".into(),
create_patch_options_menu: "<c-p>".into(),
prev_revert_block: "{".into(),
next_revert_block: "}".into(),
revert_block: "r".into(),
undo_revert_block: "u".into(),
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use anyhow::Result;
pub use app_state::AppState;
pub use keybindings::KeybindingConfig;
pub use theme::{Theme, ColorTheme, COLOR_THEMES};
pub use user_config::UserConfig;
pub use user_config::{HunkMarkerConfig, UserConfig, parse_optional_color};

pub fn config_dir_candidates() -> Vec<PathBuf> {
let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Expand Down
53 changes: 53 additions & 0 deletions src/config/user_config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::path::Path;

use anyhow::Result;
use ratatui::style::Color;
use serde::{Deserialize, Serialize};

use super::keybindings::KeybindingConfig;
Expand Down Expand Up @@ -91,6 +92,8 @@ pub struct GuiConfig {
pub show_bottom_line: bool,
#[serde(rename = "nerdFontsVersion")]
pub nerd_fonts_version: String,
#[serde(rename = "revertHunkMarker")]
pub hunk_marker: HunkMarkerConfig,
}

impl Default for GuiConfig {
Expand All @@ -106,10 +109,60 @@ impl Default for GuiConfig {
show_command_log: true,
show_bottom_line: true,
nerd_fonts_version: "3".to_string(),
hunk_marker: HunkMarkerConfig::default(),
}
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct HunkMarkerConfig {
pub icon: String,
pub bold: Option<bool>,
pub color: Option<String>,
#[serde(rename = "selectedColor")]
pub selected_color: Option<String>,
#[serde(rename = "hoverColor")]
pub hover_color: Option<String>,
}

impl Default for HunkMarkerConfig {
fn default() -> Self {
Self {
icon: "".to_string(),
bold: None,
color: None,
selected_color: None,
hover_color: None,
}
}
}

pub fn parse_optional_color(value: Option<&str>) -> Option<Color> {
let value = value?.trim();
if value.is_empty() {
return None;
}
match value.to_lowercase().as_str() {
"default" => None,
"black" => Some(Color::Black),
"red" => Some(Color::Red),
"green" => Some(Color::Green),
"yellow" => Some(Color::Yellow),
"blue" => Some(Color::Blue),
"magenta" => Some(Color::Magenta),
"cyan" => Some(Color::Cyan),
"white" => Some(Color::White),
s if s.starts_with('#') && s.len() == 7 => {
let r = u8::from_str_radix(&s[1..3], 16).ok()?;
let g = u8::from_str_radix(&s[3..5], 16).ok()?;
let b = u8::from_str_radix(&s[5..7], 16).ok()?;
Some(Color::Rgb(r, g, b))
}
_ => None,
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ThemeConfig {
Expand Down
175 changes: 172 additions & 3 deletions src/git/staging.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use anyhow::Result;
use anyhow::{Context, Result, bail};

use super::GitCommands;

Expand Down Expand Up @@ -50,7 +50,7 @@ impl GitCommands {

/// Stage a specific hunk by applying it as a patch.
pub fn stage_hunk(&self, file_path: &str, hunk: &DiffHunk) -> Result<()> {
let patch = build_patch(file_path, hunk);
let patch = self.build_hunk_patch(file_path, hunk);
self.git()
.args(&["apply", "--cached", "--unidiff-zero", "-"])
.stdin(patch)
Expand All @@ -60,7 +60,7 @@ impl GitCommands {

/// Unstage a specific hunk by reverse-applying it as a patch.
pub fn unstage_hunk(&self, file_path: &str, hunk: &DiffHunk) -> Result<()> {
let patch = build_patch(file_path, hunk);
let patch = self.build_hunk_patch(file_path, hunk);
self.git()
.args(&["apply", "--cached", "--reverse", "--unidiff-zero", "-"])
.stdin(patch)
Expand All @@ -77,6 +77,175 @@ impl GitCommands {
};
Ok(self.parse_diff_hunks(&diff))
}

/// Reverse-apply only the lines of a single visual change block to the
/// working tree copy of `file_path`. `want_old` and `want_new` are the
/// inclusive old-file and new-file line-number ranges the visual block
/// covers; either may be `None` for pure insertion or pure deletion.
/// The block typically lives inside one of the `@@` hunks of
/// `unified_diff`, but may be narrower than that `@@` — visual blocks
/// can be split by 1–3 lines of context within a single `@@`.
pub fn revert_visual_block_in_worktree(
&self,
file_path: &str,
unified_diff: &str,
want_old: Option<(usize, usize)>,
want_new: Option<(usize, usize)>,
) -> Result<()> {
let patch = build_visual_block_patch(file_path, unified_diff, want_old, want_new)?;
self.git()
.args(&["apply", "--reverse", "--unidiff-zero", "-"])
.stdin(patch)
.run_expecting_success()
.with_context(|| format!("failed to revert hunk in {}", file_path))?;
Ok(())
}

pub fn build_visual_block_patch_text(
&self,
file_path: &str,
unified_diff: &str,
want_old: Option<(usize, usize)>,
want_new: Option<(usize, usize)>,
) -> Result<String> {
build_visual_block_patch(file_path, unified_diff, want_old, want_new)
}

pub fn build_hunk_patch(&self, file_path: &str, hunk: &DiffHunk) -> String {
build_patch(file_path, hunk)
}

pub fn apply_patch_text(&self, patch: String, cached: bool, reverse: bool) -> Result<()> {
let mut args = vec!["apply"];
if cached {
args.push("--cached");
}
if reverse {
args.push("--reverse");
}
args.extend(["--unidiff-zero", "-"]);
self.git().args(&args).stdin(patch).run_expecting_success()?;
Ok(())
}
}

fn build_visual_block_patch(
file_path: &str,
unified_diff: &str,
want_old: Option<(usize, usize)>,
want_new: Option<(usize, usize)>,
) -> Result<String> {
if want_old.is_none() && want_new.is_none() {
bail!("empty visual block");
}

let mut emitted: Vec<String> = Vec::new();
let mut anchor_old: Option<usize> = None;
let mut anchor_new: Option<usize> = None;
let mut old_count = 0usize;
let mut new_count = 0usize;

let mut in_hunk = false;
let mut old_counter = 0usize;
let mut new_counter = 0usize;
let mut last_emitted = false;

for line in unified_diff.lines() {
if line.starts_with("@@") {
let (os, _, ns, _) = parse_hunk_header(line);
old_counter = os;
new_counter = ns;
in_hunk = true;
last_emitted = false;
continue;
}
if !in_hunk {
continue;
}
// A new file's preamble can interleave between hunks of multi-file
// diffs; abandon the current hunk until we see a fresh `@@`.
if line.starts_with("diff ")
|| line.starts_with("--- ")
|| line.starts_with("+++ ")
|| line.starts_with("index ")
|| line.starts_with("similarity ")
|| line.starts_with("rename ")
|| line.starts_with("new file ")
|| line.starts_with("deleted file ")
|| line.starts_with("Binary ")
{
in_hunk = false;
last_emitted = false;
continue;
}
// A "\ No newline at end of file" marker refers to the immediately
// preceding diff line. Propagate it only when that line was emitted.
if line.starts_with('\\') {
if last_emitted {
emitted.push(line.to_string());
}
continue;
}
if line.starts_with('-') {
let in_range =
want_old.is_some_and(|(lo, hi)| old_counter >= lo && old_counter <= hi);
if in_range {
if anchor_old.is_none() {
anchor_old = Some(old_counter);
}
if anchor_new.is_none() {
anchor_new = Some(new_counter);
}
emitted.push(line.to_string());
old_count += 1;
last_emitted = true;
} else {
last_emitted = false;
}
old_counter += 1;
} else if line.starts_with('+') {
let in_range =
want_new.is_some_and(|(lo, hi)| new_counter >= lo && new_counter <= hi);
if in_range {
if anchor_old.is_none() {
anchor_old = Some(old_counter);
}
if anchor_new.is_none() {
anchor_new = Some(new_counter);
}
emitted.push(line.to_string());
new_count += 1;
last_emitted = true;
} else {
last_emitted = false;
}
new_counter += 1;
} else if line.starts_with(' ') || line.is_empty() {
old_counter += 1;
new_counter += 1;
last_emitted = false;
}
}

if emitted.is_empty() {
bail!("visual block matched no diff lines");
}

let old_start = anchor_old.unwrap_or(0);
let new_start = anchor_new.unwrap_or(0);

let mut patch = String::new();
patch.push_str(&format!("--- a/{}\n", file_path));
patch.push_str(&format!("+++ b/{}\n", file_path));
patch.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
old_start, old_count, new_start, new_count
));
for line in &emitted {
patch.push_str(line);
patch.push('\n');
}
Ok(patch)
}

fn parse_hunk_header(header: &str) -> (usize, usize, usize, usize) {
Expand Down
10 changes: 3 additions & 7 deletions src/gui/controller/diff_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,12 +515,6 @@ fn handle_diff_exploration_key(gui: &mut Gui, key: KeyEvent) -> Result<()> {
KeyCode::Char('l') | KeyCode::Right => {
gui.diff_view.scroll_right(4);
}
KeyCode::Char('}') => {
gui.diff_view.next_hunk();
}
KeyCode::Char('{') => {
gui.diff_view.prev_hunk();
}
KeyCode::Char(']') => {
use crate::pager::side_by_side::DiffSideView;
gui.diff_view.side_view = match gui.diff_view.side_view {
Expand Down Expand Up @@ -712,7 +706,9 @@ fn show_diff_mode_help(gui: &mut Gui) {
HelpEntry { key: "<enter>".into(), description: "Edit selector / Focus diff".into() },
HelpEntry { key: "`".into(), description: "Toggle file tree view".into() },
HelpEntry { key: "j/k".into(), description: "Navigate files / Scroll diff".into() },
HelpEntry { key: "{/}".into(), description: "Previous / next hunk".into() },
HelpEntry { key: "H".into(), description: "Enter hunk mode".into() },
HelpEntry { key: "j/k".into(), description: "Cycle hunks in hunk mode".into() },
HelpEntry { key: "esc".into(), description: "Exit hunk mode".into() },
HelpEntry { key: "[/]".into(), description: "Toggle old / new only view".into() },
HelpEntry { key: "z".into(), description: "Toggle line wrap".into() },
HelpEntry { key: "g/G".into(), description: "Go to top / bottom".into() },
Expand Down
Loading
Loading