From 6c40b36096647edd3bdfeb1158a23f867402b63b Mon Sep 17 00:00:00 2001 From: felipe cavalcante miranda Date: Thu, 30 Apr 2026 14:24:21 +0800 Subject: [PATCH 01/18] feat(diff): revert individual hunks from the diff view Adds a per-hunk revert affordance on unstaged file diffs. In the Files panel, / cycle through hunks, reverts the selected hunk, and clicking the marker glyph in the divider column reverts that hunk directly. Hunks are reverted via `git apply --reverse` against the worktree, scoped to one hunk at a time. --- src/config/keybindings.rs | 6 ++ src/git/staging.rs | 30 ++++++- src/gui/mod.rs | 133 ++++++++++++++++++++++++++++ src/gui/presentation/diff_mode.rs | 2 +- src/gui/views.rs | 6 +- src/pager/side_by_side.rs | 139 ++++++++++++++++++++++++++++-- 6 files changed, 306 insertions(+), 10 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index de24421..61b516b 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -110,6 +110,10 @@ 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, } impl Default for UniversalKeybinding { @@ -157,6 +161,8 @@ impl Default for UniversalKeybinding { next_screen_mode: "+".into(), prev_screen_mode: "_".into(), create_patch_options_menu: "".into(), + prev_revert_block: "".into(), + next_revert_block: "".into(), } } } diff --git a/src/git/staging.rs b/src/git/staging.rs index c2141bb..4eab0c5 100644 --- a/src/git/staging.rs +++ b/src/git/staging.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result, bail}; use super::GitCommands; @@ -77,6 +77,34 @@ impl GitCommands { }; Ok(self.parse_diff_hunks(&diff)) } + + /// Given a unified diff and a hunk index, reverse-apply that single hunk + /// to the working tree copy of `file_path`. + pub fn revert_hunk_in_worktree_from_unified_diff( + &self, + file_path: &str, + unified_diff: &str, + hunk_index: usize, + ) -> Result<()> { + let hunks = self.parse_diff_hunks(unified_diff); + let Some(hunk) = hunks.get(hunk_index) else { + bail!( + "hunk {} out of range ({} hunks)", + hunk_index + 1, + hunks.len() + ); + }; + + let patch = build_patch(file_path, hunk); + self.git() + .args(&["apply", "--reverse", "--unidiff-zero", "-"]) + .stdin(patch) + .run_expecting_success() + .with_context(|| { + format!("failed to revert hunk {} in {}", hunk_index + 1, file_path) + })?; + Ok(()) + } } fn parse_hunk_header(header: &str) -> (usize, usize, usize, usize) { diff --git a/src/gui/mod.rs b/src/gui/mod.rs index c015d91..6ec191e 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1982,6 +1982,21 @@ impl Gui { } } + if matches_key(key, &keybindings.universal.next_revert_block) { + if self.context_mgr.active() == ContextId::Files { + self.diff_view.select_next_revert_hunk(); + self.center_selected_revert_block(); + } + return Ok(()); + } + if matches_key(key, &keybindings.universal.prev_revert_block) { + if self.context_mgr.active() == ContextId::Files { + self.diff_view.select_prev_revert_hunk(); + self.center_selected_revert_block(); + } + return Ok(()); + } + // Toggle command log (;) if key.code == KeyCode::Char(';') { self.show_command_log = !self.show_command_log; @@ -2087,6 +2102,13 @@ impl Gui { let max = self.diff_view.lines.len().saturating_sub(1); self.diff_view.scroll_offset = max; } + KeyCode::Enter => { + if self.context_mgr.active() == ContextId::Files + && let Some(hunk_idx) = self.diff_view.selected_revert_hunk + { + self.revert_selected_file_hunk(hunk_idx)?; + } + } _ => {} } Ok(()) @@ -3263,6 +3285,8 @@ impl Gui { HelpEntry { key: kb.universal.edit.clone(), description: "Open in editor".into() }, HelpEntry { key: kb.universal.open_file.clone(), description: "Open in default program".into() }, HelpEntry { key: "y".into(), description: "Copy to clipboard menu".into() }, + HelpEntry { key: kb.universal.next_revert_block.clone(), description: "Next revert block in diff".into() }, + HelpEntry { key: kb.universal.prev_revert_block.clone(), description: "Previous revert block in diff".into() }, ], }, ContextId::Worktrees => HelpSection { @@ -3451,6 +3475,9 @@ impl Gui { HelpEntry { key: "PgUp/PgDn".into(), description: "Page up / down".into() }, HelpEntry { key: "/".into(), description: "Search in diff".into() }, HelpEntry { key: "n/N".into(), description: "Next / previous search match".into() }, + HelpEntry { key: "/".into(), description: "Cycle next / previous revert block (Files)".into() }, + HelpEntry { key: "".into(), description: "Revert selected block (Files)".into() }, + HelpEntry { key: "click 󰧛".into(), description: "Click revert icon to revert that block".into() }, HelpEntry { key: "e".into(), description: "Edit file at line".into() }, HelpEntry { key: "o".into(), description: "Open file in default program".into() }, HelpEntry { key: "y".into(), description: "Copy selected text".into() }, @@ -4315,6 +4342,10 @@ impl Gui { let full_sidebar = self.screen_mode == ScreenMode::Full && !self.diff_focused; if in_main && !self.diff_view.is_empty() && !full_sidebar { + if self.try_handle_revert_block_click(main_panel, pl, mouse.column, mouse.row) { + self.diff_focused = true; + return; + } if let Some(panel) = pl.panel_at_x(mouse.column) { self.diff_view.selection = Some(TextSelection { panel, @@ -4649,6 +4680,10 @@ impl Gui { // Check if click is in the diff panel — start text selection if rect_contains(diff_rect, col, row) && !self.diff_view.is_empty() { let pl = DiffPanelLayout::compute(diff_rect, &self.diff_view); + if self.try_handle_revert_block_click(diff_rect, pl, col, row) { + self.diff_mode.focus = DiffModeFocus::DiffExploration; + return; + } if let Some(panel) = pl.panel_at_x(col) { self.diff_view.selection = Some(TextSelection { panel, @@ -4946,6 +4981,104 @@ impl Gui { self.compute_current_frame_layout().main_panel } + fn try_handle_revert_block_click( + &mut self, + panel_rect: ratatui::layout::Rect, + layout: DiffPanelLayout, + col: u16, + row: u16, + ) -> bool { + if self.diff_mode.active { + return false; + } + if self.context_mgr.active() != ContextId::Files { + return false; + } + if self.diff_view.wrap || self.diff_view.is_empty() { + return false; + } + if !rect_contains(panel_rect, col, row) { + return false; + } + let Some(divider_x) = layout.divider_x() else { + return false; + }; + if col != divider_x { + return false; + } + let Some(line_idx) = self.diff_view.line_index_at_row(row, &layout) else { + return false; + }; + let Some(hunk_idx) = self.diff_view.hunk_index_for_start_line(line_idx) else { + return false; + }; + self.diff_view.selected_revert_hunk = Some(hunk_idx); + if let Err(err) = self.revert_selected_file_hunk(hunk_idx) { + self.popup = PopupState::Message { + title: "Revert block failed".to_string(), + message: format!("{}", err), + kind: MessageKind::Error, + }; + } + true + } + + fn revert_selected_file_hunk(&mut self, hunk_idx: usize) -> Result<()> { + let Some(file_idx) = self.selected_file_index() else { + return Ok(()); + }; + + let model = self.model.lock().unwrap(); + let Some(file) = model.files.get(file_idx) else { + return Ok(()); + }; + + if !file.has_unstaged_changes { + self.popup = PopupState::Message { + title: "Revert block".to_string(), + message: "Block revert is available only for unstaged changes.".to_string(), + kind: MessageKind::Info, + }; + return Ok(()); + } + + let file_name = file.name.clone(); + drop(model); + + let diff = self.git.diff_file(&file_name)?; + if diff.is_empty() { + return Ok(()); + } + + self.git + .revert_hunk_in_worktree_from_unified_diff(&file_name, &diff, hunk_idx)?; + self.diff_view.selection = None; + self.needs_files_refresh = true; + self.needs_diff_refresh = true; + Ok(()) + } + + /// Keep the selected revert marker around the vertical middle of the visible diff area. + fn center_selected_revert_block(&mut self) { + let Some(sel) = self.diff_view.selected_revert_hunk else { + return; + }; + let Some(&line_idx) = self.diff_view.hunk_starts.get(sel) else { + return; + }; + + let main_panel = self.compute_main_panel_rect(); + let pl = DiffPanelLayout::compute(main_panel, &self.diff_view); + let visible_rows = (pl.inner_end_y.saturating_sub(pl.inner_y)) as usize; + if visible_rows == 0 { + return; + } + + let desired = line_idx.saturating_sub(visible_rows / 2); + let max_start = self.diff_view.lines.len().saturating_sub(visible_rows); + self.diff_view.scroll_offset = desired.min(max_start); + } + /// Approximate visible height of the active sidebar panel (inner area minus borders). fn sidebar_visible_height(&self) -> usize { let fl = self.compute_current_frame_layout(); diff --git a/src/gui/presentation/diff_mode.rs b/src/gui/presentation/diff_mode.rs index 71e843a..1c524fe 100644 --- a/src/gui/presentation/diff_mode.rs +++ b/src/gui/presentation/diff_mode.rs @@ -283,7 +283,7 @@ fn render_diff_panel( let focused = state.focus == DiffModeFocus::DiffExploration; if !diff_view.is_empty() { - side_by_side::render_diff(frame, area, diff_view, theme, focused, diff_loading); + side_by_side::render_diff(frame, area, diff_view, theme, focused, diff_loading, false); side_by_side::render_diff_search_highlights(frame, area, diff_view, theme); side_by_side::render_diff_search_bar(frame, area, diff_view, theme); } else { diff --git a/src/gui/views.rs b/src/gui/views.rs index c51cadd..b693fc2 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -98,7 +98,8 @@ pub fn render( if diff_focused { // Diff is focused: show diff fullscreen if !diff_view.is_empty() { - side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, true, diff_loading_show); + let show_revert_markers = ctx_mgr.active() == ContextId::Files; + side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, true, diff_loading_show, show_revert_markers); side_by_side::render_diff_search_highlights(frame, fl.main_panel, diff_view, theme); side_by_side::render_diff_search_bar(frame, fl.main_panel, diff_view, theme); } else if diff_loading { @@ -584,7 +585,8 @@ pub fn render( .border_style(theme.active_border); render_status_main(frame, fl.main_panel, model, config, theme, status_block); } else if !diff_view.is_empty() { - side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, diff_focused, diff_loading_show); + let show_revert_markers = ctx_mgr.active() == ContextId::Files; + side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, diff_focused, diff_loading_show, show_revert_markers); side_by_side::render_diff_search_highlights(frame, fl.main_panel, diff_view, theme); side_by_side::render_diff_search_bar(frame, fl.main_panel, diff_view, theme); } else if diff_loading { diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index ccff1e2..b37a41e 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -103,7 +103,7 @@ impl DiffPanelLayout { let inner_end_y = inner_y + panel_rect.height.saturating_sub(2); let gutter: u16 = 5; - let divider: u16 = 1; + let divider: u16 = 2; let is_new_file = state.old_content.is_empty() && state.sections.len() <= 1; @@ -184,6 +184,15 @@ impl DiffPanelLayout { DiffPanel::New => (self.new_content_x, self.new_content_end_x), } } + + /// Get the divider X column between old and new panels (both-side view only). + pub fn divider_x(&self) -> Option { + if self.is_new_file || self.old_content_end_x == 0 || self.new_content_x == 0 { + None + } else { + Some(self.old_content_end_x) + } + } } /// Which side(s) of the diff to display. @@ -240,6 +249,8 @@ pub struct DiffViewState { pub search_match_idx: usize, /// Textarea widget for search input. pub search_textarea: Option>, + /// Currently selected revert-button hunk index (for keyboard cycling). + pub selected_revert_hunk: Option, } impl Default for DiffViewState { @@ -264,6 +275,7 @@ impl Default for DiffViewState { search_matches: Vec::new(), search_match_idx: 0, search_textarea: None, + selected_revert_hunk: None, } } } @@ -522,6 +534,7 @@ impl DiffViewState { /// Apply a pre-parsed diff result, preserving scroll position for same-file reloads. pub fn apply_parsed(&mut self, parsed: ParsedDiff) { let same_file = self.filename == parsed.filename; + let prev_selected_revert_hunk = self.selected_revert_hunk; self.filename = parsed.filename; self.old_content = parsed.old_content; self.new_content = parsed.new_content; @@ -530,6 +543,11 @@ impl DiffViewState { self.hunk_line_offsets = parsed.hunk_line_offsets; self.sections = parsed.sections; self.file_exists_on_disk = parsed.file_exists_on_disk; + self.selected_revert_hunk = if same_file { + prev_selected_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; if same_file { let max = self.lines.len().saturating_sub(1); self.scroll_offset = self.scroll_offset.min(max); @@ -545,6 +563,7 @@ impl DiffViewState { pub fn load(&mut self, filename: &str, old: &str, new: &str) { // Preserve scroll position when reloading the same file (e.g. periodic refresh) let same_file = self.filename == filename; + let prev_selected_revert_hunk = self.selected_revert_hunk; self.filename = filename.to_string(); self.old_content = old.to_string(); self.new_content = new.to_string(); @@ -561,6 +580,11 @@ impl DiffViewState { self.selection = None; self.clear_search(); } + self.selected_revert_hunk = if same_file { + prev_selected_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; // Preserve side_view across reloads so periodic refresh doesn't reset it // Single section with index 0 self.sections = vec![FileSection { @@ -613,6 +637,7 @@ impl DiffViewState { let file_count = file_diffs.len(); let new_filename = format!("{} ({} files)", filename, file_count); let same_file = self.filename == new_filename; + let prev_selected_revert_hunk = self.selected_revert_hunk; self.filename = new_filename; self.old_content = String::new(); self.new_content = String::new(); @@ -624,6 +649,11 @@ impl DiffViewState { self.selection = None; self.clear_search(); } + self.selected_revert_hunk = if same_file { + prev_selected_revert_hunk + } else { + None + }; self.hunk_line_offsets = Vec::new(); @@ -673,6 +703,12 @@ impl DiffViewState { } self.hunk_starts = super::diff_algo::find_hunk_starts(&self.lines); + self.selected_revert_hunk = if same_file { + self.selected_revert_hunk + .filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; if same_file { // Clamp scroll in case the diff got shorter @@ -724,6 +760,56 @@ impl DiffViewState { self.lines.is_empty() } + /// Map a terminal row within the diff panel inner area to the visible diff line index. + /// This is exact when wrapping is disabled. + pub fn line_index_at_row(&self, row: u16, layout: &DiffPanelLayout) -> Option { + if row < layout.inner_y || row >= layout.inner_end_y { + return None; + } + let idx = self.scroll_offset + (row - layout.inner_y) as usize; + if idx < self.lines.len() { + Some(idx) + } else { + None + } + } + + /// Return true when the given line index is the first line of a diff hunk. + pub fn is_hunk_start_line(&self, line_idx: usize) -> bool { + self.hunk_starts.binary_search(&line_idx).is_ok() + } + + /// Get the zero-based hunk index for a hunk-start line. + pub fn hunk_index_for_start_line(&self, line_idx: usize) -> Option { + self.hunk_starts.binary_search(&line_idx).ok() + } + + /// Cycle to the next revertable hunk marker. + pub fn select_next_revert_hunk(&mut self) { + if self.hunk_starts.is_empty() { + self.selected_revert_hunk = None; + return; + } + let next = match self.selected_revert_hunk { + Some(i) => (i + 1) % self.hunk_starts.len(), + None => 0, + }; + self.selected_revert_hunk = Some(next); + } + + /// Cycle to the previous revertable hunk marker. + pub fn select_prev_revert_hunk(&mut self) { + if self.hunk_starts.is_empty() { + self.selected_revert_hunk = None; + return; + } + let prev = match self.selected_revert_hunk { + Some(0) | None => self.hunk_starts.len() - 1, + Some(i) => i.saturating_sub(1), + }; + self.selected_revert_hunk = Some(prev); + } + /// Get the highlighters for a given section index. fn highlighters_for_section(&self, section_index: usize) -> Option<(&FileHighlighter, &FileHighlighter)> { self.sections @@ -741,6 +827,7 @@ pub fn render_diff( theme: &Theme, focused: bool, diff_loading: bool, + show_revert_markers: bool, ) { let border_style = if focused { theme.active_border @@ -787,7 +874,7 @@ pub fn render_diff( } let gutter_width = 5u16; - let divider_width = 1u16; + let divider_width = 2u16; // Detect new file: old content is empty, so no left panel needed let is_new_file = state.old_content.is_empty() && state.sections.len() <= 1; @@ -1050,8 +1137,29 @@ pub fn render_diff( Style::default().bg(left_bg), panel_width); } - // Divider - buf_write_str(buf, div_x, y, "│", divider_style, divider_width); + // Divider or revert marker (first visual row of a hunk only). + let show_marker = show_revert_markers + && !state.wrap + && chunk_idx == 0 + && state.is_hunk_start_line(line_idx); + let (divider_char, marker_style) = if show_marker { + let hunk_idx = state.hunk_index_for_start_line(line_idx); + let is_selected = hunk_idx == state.selected_revert_hunk; + let style = if is_selected { + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(theme.separator) + .add_modifier(Modifier::BOLD) + }; + ("󰧛", style) + } else { + ("│", divider_style) + }; + buf_write_str(buf, div_x, y, " ", divider_style, divider_width); + buf_write_str(buf, div_x, y, divider_char, marker_style, divider_width); // Right gutter + content buf_write_str(buf, right_gutter_x, y, &right_gutter_text, right_gutter_style, gutter_width); @@ -1096,8 +1204,27 @@ pub fn render_diff( }; buf_write_spans(buf, inner.x + gutter_width, y, &left_spans, panel_width, state.horizontal_scroll); - // Divider - buf_write_str(buf, div_x, y, "│", divider_style, divider_width); + // Divider or revert marker. + let show_marker = + show_revert_markers && !state.wrap && state.is_hunk_start_line(line_idx); + let (divider_char, marker_style) = if show_marker { + let hunk_idx = state.hunk_index_for_start_line(line_idx); + let is_selected = hunk_idx == state.selected_revert_hunk; + let style = if is_selected { + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(theme.separator) + .add_modifier(Modifier::BOLD) + }; + ("󰧛", style) + } else { + ("│", divider_style) + }; + buf_write_str(buf, div_x, y, " ", divider_style, divider_width); + buf_write_str(buf, div_x, y, divider_char, marker_style, divider_width); // Right gutter buf_write_str(buf, right_gutter_x, y, &right_num, right_gutter_style, gutter_width); From 4a72c454b1347db7c10e6b07cd5e78e6e3effccd Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 30 Apr 2026 15:02:20 +0800 Subject: [PATCH 02/18] feat(diff): hover tooltip on revert-hunk marker Show "enter Revert hunk" tooltip beside the marker glyph when the mouse hovers over it, mirroring the AI-generate button's tooltip pattern. The tooltip overlays the right diff panel on the marker's row only while the cursor is on the divider column at a hunk start. --- src/gui/mod.rs | 49 +++++++++++++++++--------- src/pager/side_by_side.rs | 73 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 21 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 6ec191e..547bd60 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -4329,6 +4329,15 @@ impl Gui { let main_panel = self.compute_main_panel_rect(); let pl = DiffPanelLayout::compute(main_panel, &self.diff_view); + // Track mouse hover over the revert-block marker (for tooltip). + if !self.diff_mode.active { + let new_hover = + self.revert_hunk_at_position(main_panel, &pl, mouse.column, mouse.row); + if self.diff_view.hovered_revert_hunk != new_hover { + self.diff_view.hovered_revert_hunk = new_hover; + } + } + match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { let in_main = main_panel.x <= mouse.column @@ -4981,35 +4990,41 @@ impl Gui { self.compute_current_frame_layout().main_panel } - fn try_handle_revert_block_click( - &mut self, + fn revert_hunk_at_position( + &self, panel_rect: ratatui::layout::Rect, - layout: DiffPanelLayout, + layout: &DiffPanelLayout, col: u16, row: u16, - ) -> bool { - if self.diff_mode.active { - return false; - } + ) -> Option { if self.context_mgr.active() != ContextId::Files { - return false; + return None; } if self.diff_view.wrap || self.diff_view.is_empty() { - return false; + return None; } if !rect_contains(panel_rect, col, row) { - return false; + return None; } - let Some(divider_x) = layout.divider_x() else { - return false; - }; + let divider_x = layout.divider_x()?; if col != divider_x { - return false; + return None; } - let Some(line_idx) = self.diff_view.line_index_at_row(row, &layout) else { + let line_idx = self.diff_view.line_index_at_row(row, layout)?; + self.diff_view.hunk_index_for_start_line(line_idx) + } + + fn try_handle_revert_block_click( + &mut self, + panel_rect: ratatui::layout::Rect, + layout: DiffPanelLayout, + col: u16, + row: u16, + ) -> bool { + if self.diff_mode.active { return false; - }; - let Some(hunk_idx) = self.diff_view.hunk_index_for_start_line(line_idx) else { + } + let Some(hunk_idx) = self.revert_hunk_at_position(panel_rect, &layout, col, row) else { return false; }; self.diff_view.selected_revert_hunk = Some(hunk_idx); diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index b37a41e..5a5ee62 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -251,6 +251,8 @@ pub struct DiffViewState { pub search_textarea: Option>, /// Currently selected revert-button hunk index (for keyboard cycling). pub selected_revert_hunk: Option, + /// Hunk index currently under the mouse cursor (for tooltip rendering). + pub hovered_revert_hunk: Option, } impl Default for DiffViewState { @@ -276,6 +278,7 @@ impl Default for DiffViewState { search_match_idx: 0, search_textarea: None, selected_revert_hunk: None, + hovered_revert_hunk: None, } } } @@ -1034,6 +1037,8 @@ pub fn render_diff( .width .saturating_sub(gutter_width * 2 + panel_width + divider_width); + let mut hover_tooltip_y: Option = None; + let mut row = 0usize; for (idx_offset, diff_line) in state.lines[state.scroll_offset..].iter().enumerate() { if row >= visible_height { @@ -1142,9 +1147,13 @@ pub fn render_diff( && !state.wrap && chunk_idx == 0 && state.is_hunk_start_line(line_idx); + let marker_hunk_idx = if show_marker { + state.hunk_index_for_start_line(line_idx) + } else { + None + }; let (divider_char, marker_style) = if show_marker { - let hunk_idx = state.hunk_index_for_start_line(line_idx); - let is_selected = hunk_idx == state.selected_revert_hunk; + let is_selected = marker_hunk_idx == state.selected_revert_hunk; let style = if is_selected { Style::default() .fg(theme.accent) @@ -1160,6 +1169,12 @@ pub fn render_diff( }; buf_write_str(buf, div_x, y, " ", divider_style, divider_width); buf_write_str(buf, div_x, y, divider_char, marker_style, divider_width); + if show_marker + && marker_hunk_idx.is_some() + && marker_hunk_idx == state.hovered_revert_hunk + { + hover_tooltip_y = Some(y); + } // Right gutter + content buf_write_str(buf, right_gutter_x, y, &right_gutter_text, right_gutter_style, gutter_width); @@ -1207,9 +1222,13 @@ pub fn render_diff( // Divider or revert marker. let show_marker = show_revert_markers && !state.wrap && state.is_hunk_start_line(line_idx); + let marker_hunk_idx = if show_marker { + state.hunk_index_for_start_line(line_idx) + } else { + None + }; let (divider_char, marker_style) = if show_marker { - let hunk_idx = state.hunk_index_for_start_line(line_idx); - let is_selected = hunk_idx == state.selected_revert_hunk; + let is_selected = marker_hunk_idx == state.selected_revert_hunk; let style = if is_selected { Style::default() .fg(theme.accent) @@ -1225,6 +1244,12 @@ pub fn render_diff( }; buf_write_str(buf, div_x, y, " ", divider_style, divider_width); buf_write_str(buf, div_x, y, divider_char, marker_style, divider_width); + if show_marker + && marker_hunk_idx.is_some() + && marker_hunk_idx == state.hovered_revert_hunk + { + hover_tooltip_y = Some(y); + } // Right gutter buf_write_str(buf, right_gutter_x, y, &right_num, right_gutter_style, gutter_width); @@ -1253,6 +1278,46 @@ pub fn render_diff( row += 1; } } + + if let Some(y) = hover_tooltip_y { + render_revert_tooltip(buf, div_x + divider_width, y, right_content_width + gutter_width, theme); + } + } +} + +fn render_revert_tooltip(buf: &mut Buffer, x: u16, y: u16, max_width: u16, theme: &Theme) { + let tip_style = Style::default() + .bg(theme.selected_bg) + .fg(theme.text_strong); + let key_style = Style::default() + .bg(theme.selected_bg) + .fg(theme.accent_secondary) + .add_modifier(Modifier::BOLD); + + let parts: [(&str, Style); 3] = [ + (" ", tip_style), + ("enter", key_style), + (" Revert hunk ", tip_style), + ]; + + let buf_area = buf.area(); + if y < buf_area.y || y >= buf_area.y + buf_area.height { + return; + } + let max_x = (x + max_width).min(buf_area.x + buf_area.width); + + let mut col = x; + for (text, style) in &parts { + for ch in text.chars() { + if col >= max_x { + return; + } + if let Some(cell) = buf.cell_mut((col, y)) { + cell.set_char(ch); + cell.set_style(*style); + } + col += 1; + } } } From 7877843384006306c31a1c0e3bcb58bdf6cca7fc Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 30 Apr 2026 15:05:10 +0800 Subject: [PATCH 03/18] feat(diff): bind to revert selected hunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Enter handler with a dedicated `` keybinding for reverting the currently selected (or hovered) hunk. Also update the hover tooltip to show `c-r Revert hunk`. Enter no longer triggers revert — `` is the single source of truth for the action key. feat(diff): hover color + diff-focused status bar hints - Revert-hunk marker now uses accent_secondary on mouse hover (matching the AI sparkle's hover pattern), distinct from accent (selected via c-j/c-k cycle) and separator (default). - Status bar shows a tighter, diff-focused hint set when the diff panel has focus: {/} prev/next hunk, [/] side view, and on Files context c-j/c-k cycle + c-r revert. The c-r key is highlighted when a hunk is selected so the action is more discoverable. feat(diff): only surface c-r when a hunk is selected c-r is now hidden from the status bar unless `selected_revert_hunk` is set, and is prepended as the first hint when present. Pressing c-r without a selection is a no-op so showing it always was noise — it appears only when the action will actually do something. fix(diff): don't scroll on c-j/c-k if hunk already visible center_selected_revert_block was unconditionally re-centering the diff on every cycle, which is jarring when the target hunk is already in the viewport. Only scroll when the hunk's start row is outside the visible range. feat(diff): group c-r and c-j/c-k revert hints together Move the cycle keys (c-j/c-k) next to c-r at the front of the status bar so the revert action and its cycle keys read as one cluster instead of being split across the line. fix(diff): show hover feedback even when revert hunk is already selected Previously the selected color (accent) overrode hover styling, so hovering a hunk that was already selected via c-j/c-k showed no visual change. Add an UNDERLINED modifier on hover that stacks with whichever fg color is in effect, so the marker reacts to the cursor in every state. fix(diff): use bg fill for hover so it shows even when hunk is selected UNDERLINED didn't render reliably on the Nerd Font marker glyph in many terminals. Switch to a selected_bg background fill on hover — visible regardless of fg color and terminal capabilities, including when a hunk is already selected (accent fg + selected_bg fill). fix(diff): make hover unambiguously visible via REVERSED The selected_bg fill blended too subtly with the selected accent fg — hovering a focused hunk looked nearly identical to just being focused. Use REVERSED to invert fg/bg on hover; the marker reads as a high- contrast highlight regardless of which fg color is currently active. fix(diff): solid bg fill on hover + drop c-r tooltip when not selected Hover now paints the marker as a solid colored cell (fg ↔ bg with panel background). Visually unmistakable in any terminal — including when the hunk is already selected, where the previous REVERSED/bg tweaks were too subtle. Tooltip text drops the c-r keybind when the hovered hunk isn't the selected one, since pressing c-r in that state is misleading. Falls back to a plain "Revert hunk" label. fix(diff): paint full divider gap with hover bg Single-cell bg fill was easy to miss against the surrounding panel background — fill both columns of the divider gap with the hover bg so the marker reads as a clearly-coloured pill against the diff. Also fixes the second render path which was still on UNDERLINED. fix(diff): hover changes only the icon color, no bg fill Drop the colored bg pill — hover now switches the marker fg to text_strong, which wins over both the selected accent and default separator colors. Selected stays accent when not hovered, default is separator. Hover is visible in every state because the icon color itself changes; nothing else in the divider gap is touched. fix(diff): use accent_secondary for hover so it matches tooltip key color Hover color was text_strong (off-white). Switch to accent_secondary so the hovered marker reads in the same yellow as the c-r shortcut key text inside the tooltip — visually links the two. --- src/config/keybindings.rs | 3 ++ src/gui/mod.rs | 34 +++++++++++--- src/gui/views.rs | 94 ++++++++++++++++++++++++--------------- src/pager/side_by_side.rs | 56 +++++++++++++---------- 4 files changed, 121 insertions(+), 66 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 61b516b..df2db94 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -114,6 +114,8 @@ pub struct UniversalKeybinding { pub prev_revert_block: String, #[serde(rename = "nextRevertBlock")] pub next_revert_block: String, + #[serde(rename = "revertBlock")] + pub revert_block: String, } impl Default for UniversalKeybinding { @@ -163,6 +165,7 @@ impl Default for UniversalKeybinding { create_patch_options_menu: "".into(), prev_revert_block: "".into(), next_revert_block: "".into(), + revert_block: "".into(), } } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 547bd60..8a0a23b 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1996,6 +1996,25 @@ impl Gui { } return Ok(()); } + if matches_key(key, &keybindings.universal.revert_block) { + if self.context_mgr.active() == ContextId::Files { + let hunk_idx = self + .diff_view + .selected_revert_hunk + .or(self.diff_view.hovered_revert_hunk); + if let Some(hunk_idx) = hunk_idx { + self.diff_view.selected_revert_hunk = Some(hunk_idx); + if let Err(err) = self.revert_selected_file_hunk(hunk_idx) { + self.popup = PopupState::Message { + title: "Revert block failed".to_string(), + message: format!("{}", err), + kind: MessageKind::Error, + }; + } + } + } + return Ok(()); + } // Toggle command log (;) if key.code == KeyCode::Char(';') { @@ -2102,13 +2121,6 @@ impl Gui { let max = self.diff_view.lines.len().saturating_sub(1); self.diff_view.scroll_offset = max; } - KeyCode::Enter => { - if self.context_mgr.active() == ContextId::Files - && let Some(hunk_idx) = self.diff_view.selected_revert_hunk - { - self.revert_selected_file_hunk(hunk_idx)?; - } - } _ => {} } Ok(()) @@ -3287,6 +3299,7 @@ impl Gui { HelpEntry { key: "y".into(), description: "Copy to clipboard menu".into() }, HelpEntry { key: kb.universal.next_revert_block.clone(), description: "Next revert block in diff".into() }, HelpEntry { key: kb.universal.prev_revert_block.clone(), description: "Previous revert block in diff".into() }, + HelpEntry { key: kb.universal.revert_block.clone(), description: "Revert selected block".into() }, ], }, ContextId::Worktrees => HelpSection { @@ -5089,6 +5102,13 @@ impl Gui { return; } + let scroll = self.diff_view.scroll_offset; + // Already in viewport? Don't scroll. The marker glyph sits on the + // hunk's first row, so only that row needs to be visible. + if line_idx >= scroll && line_idx < scroll + visible_rows { + return; + } + let desired = line_idx.saturating_sub(visible_rows / 2); let max_start = self.diff_view.lines.len().saturating_sub(visible_rows); self.diff_view.scroll_offset = desired.min(max_start); diff --git a/src/gui/views.rs b/src/gui/views.rs index b693fc2..4746bbc 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -243,7 +243,7 @@ pub fn render( commit_details_scroll, ); } - render_status_bar(frame, fl.status_bar, ctx_mgr, diff_view, theme, model); + render_status_bar(frame, fl.status_bar, ctx_mgr, diff_view, theme, model, diff_focused); // Render text selection highlight overlay and tooltip (must be before popup) render_selection_overlay(frame, diff_view, fl.main_panel, theme); if *popup != PopupState::None { @@ -687,7 +687,7 @@ pub fn render( frame.render_widget(bar, fl.status_bar); } } else { - render_status_bar(frame, fl.status_bar, ctx_mgr, diff_view, theme, model); + render_status_bar(frame, fl.status_bar, ctx_mgr, diff_view, theme, model, diff_focused); } // Render text selection highlight overlay and tooltip @@ -1349,8 +1349,10 @@ fn render_status_bar( diff_view: &DiffViewState, _theme: &crate::config::Theme, model: &Model, + diff_focused: bool, ) { let mut hints: Vec<(&str, &str)> = Vec::new(); + let mut emphasized: Vec<&str> = Vec::new(); // When in a special state (rebasing/merging/cherry-picking), show those options prominently if model.is_rebasing { @@ -1361,53 +1363,73 @@ fn render_status_bar( hints.push(("m", "continue/abort cherry-pick")); } - // Context-specific hints - match ctx_mgr.active() { - ContextId::Files => { - hints.extend([("c", "commit"), ("a", "stage all"), ("space", "toggle"), ("d", "discard"), ("e", "edit"), ("o", "open")]); - } - ContextId::Branches => { - hints.extend([("space", "checkout"), ("n", "new"), ("d", "delete"), ("M", "merge"), ("r", "rebase")]); - } - ContextId::Commits => { - hints.extend([("r", "reword"), ("g", "reset"), ("t", "revert"), ("C", "cherry-pick"), ("ctrl+l", "filter branch")]); - } - ContextId::Stash => { - hints.extend([("g", "pop"), ("space", "apply"), ("d", "drop")]); - } - ContextId::Remotes => { - hints.extend([("enter", "branches"), ("f", "fetch"), ("P", "push"), ("p", "pull")]); - } - ContextId::RemoteBranches => { - hints.extend([("enter", "commits"), ("space", "checkout"), ("M", "merge"), ("r", "rebase"), ("d", "delete")]); - } - ContextId::Tags => { - hints.extend([("n", "new"), ("d", "delete"), ("P", "push")]); + if diff_focused && !diff_view.is_empty() { + // Diff-focused hint set: only the diff-relevant keys, kept tight. + // Revert-related keys are grouped together at the front so users see + // c-r right next to its cycle keys. c-r itself only appears when a + // hunk is actually selected (pressing it otherwise is a no-op). + if ctx_mgr.active() == ContextId::Files { + let has_selection = diff_view.selected_revert_hunk.is_some(); + let mut idx = 0; + if has_selection { + hints.insert(idx, ("c-r", "revert hunk")); + emphasized.push("c-r"); + idx += 1; + } + hints.insert(idx, ("c-j/c-k", "cycle revert")); } - ContextId::Worktrees => { - hints.extend([("space", "switch"), ("n", "new"), ("d", "remove")]); + hints.push(("{/}", "prev/next hunk")); + hints.push(("[/]", "side view")); + } else { + // Sidebar-focused: context-specific hints. + match ctx_mgr.active() { + ContextId::Files => { + hints.extend([("c", "commit"), ("a", "stage all"), ("space", "toggle"), ("d", "discard"), ("e", "edit"), ("o", "open")]); + } + ContextId::Branches => { + hints.extend([("space", "checkout"), ("n", "new"), ("d", "delete"), ("M", "merge"), ("r", "rebase")]); + } + ContextId::Commits => { + hints.extend([("r", "reword"), ("g", "reset"), ("t", "revert"), ("C", "cherry-pick"), ("ctrl+l", "filter branch")]); + } + ContextId::Stash => { + hints.extend([("g", "pop"), ("space", "apply"), ("d", "drop")]); + } + ContextId::Remotes => { + hints.extend([("enter", "branches"), ("f", "fetch"), ("P", "push"), ("p", "pull")]); + } + ContextId::RemoteBranches => { + hints.extend([("enter", "commits"), ("space", "checkout"), ("M", "merge"), ("r", "rebase"), ("d", "delete")]); + } + ContextId::Tags => { + hints.extend([("n", "new"), ("d", "delete"), ("P", "push")]); + } + ContextId::Worktrees => { + hints.extend([("space", "switch"), ("n", "new"), ("d", "remove")]); + } + ContextId::Submodules => { + hints.extend([("space", "update"), ("a", "add"), ("d", "remove"), ("e", "enter")]); + } + _ => {} } - ContextId::Submodules => { - hints.extend([("space", "update"), ("a", "add"), ("d", "remove"), ("e", "enter")]); + if !diff_view.is_empty() { + hints.push(("J/K", "scroll diff")); + hints.push(("{/}", "hunks")); } - _ => {} } - // Global hints + // Global hints (always last) hints.extend([("q", "quit"), ("tab/1-5", "panels"), ("j/k", "nav")]); - // Diff scroll info - if !diff_view.is_empty() { - hints.extend([("J/K", "scroll diff"), ("{/}", "hunks")]); - } - let key_style = Style::default().fg(_theme.text).add_modifier(ratatui::style::Modifier::BOLD); + let key_emphasis_style = Style::default().fg(_theme.accent).add_modifier(ratatui::style::Modifier::BOLD); let desc_style = Style::default().fg(_theme.text_dimmed); let spans: Vec = hints .iter() .flat_map(|(key, desc)| { + let style = if emphasized.contains(key) { key_emphasis_style } else { key_style }; vec![ - Span::styled(format!(" {} ", key), key_style), + Span::styled(format!(" {} ", key), style), Span::styled(format!("{} ", desc), desc_style), ] }) diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index 5a5ee62..0b7d05b 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -1152,18 +1152,21 @@ pub fn render_diff( } else { None }; + let marker_is_hovered = show_marker + && marker_hunk_idx.is_some() + && marker_hunk_idx == state.hovered_revert_hunk; let (divider_char, marker_style) = if show_marker { let is_selected = marker_hunk_idx == state.selected_revert_hunk; - let style = if is_selected { - Style::default() - .fg(theme.accent) - .add_modifier(Modifier::BOLD) + // Hover wins over selection so the hover state is always + // visible — even on a hunk that's currently selected. + let fg = if marker_is_hovered { + theme.accent_secondary + } else if is_selected { + theme.accent } else { - Style::default() - .fg(theme.separator) - .add_modifier(Modifier::BOLD) + theme.separator }; - ("󰧛", style) + ("󰧛", Style::default().fg(fg).add_modifier(Modifier::BOLD)) } else { ("│", divider_style) }; @@ -1227,18 +1230,19 @@ pub fn render_diff( } else { None }; + let marker_is_hovered = show_marker + && marker_hunk_idx.is_some() + && marker_hunk_idx == state.hovered_revert_hunk; let (divider_char, marker_style) = if show_marker { let is_selected = marker_hunk_idx == state.selected_revert_hunk; - let style = if is_selected { - Style::default() - .fg(theme.accent) - .add_modifier(Modifier::BOLD) + let fg = if marker_is_hovered { + theme.accent_secondary + } else if is_selected { + theme.accent } else { - Style::default() - .fg(theme.separator) - .add_modifier(Modifier::BOLD) + theme.separator }; - ("󰧛", style) + ("󰧛", Style::default().fg(fg).add_modifier(Modifier::BOLD)) } else { ("│", divider_style) }; @@ -1280,12 +1284,14 @@ pub fn render_diff( } if let Some(y) = hover_tooltip_y { - render_revert_tooltip(buf, div_x + divider_width, y, right_content_width + gutter_width, theme); + let show_key = state.hovered_revert_hunk.is_some() + && state.hovered_revert_hunk == state.selected_revert_hunk; + render_revert_tooltip(buf, div_x + divider_width, y, right_content_width + gutter_width, theme, show_key); } } } -fn render_revert_tooltip(buf: &mut Buffer, x: u16, y: u16, max_width: u16, theme: &Theme) { +fn render_revert_tooltip(buf: &mut Buffer, x: u16, y: u16, max_width: u16, theme: &Theme, show_key: bool) { let tip_style = Style::default() .bg(theme.selected_bg) .fg(theme.text_strong); @@ -1294,11 +1300,15 @@ fn render_revert_tooltip(buf: &mut Buffer, x: u16, y: u16, max_width: u16, theme .fg(theme.accent_secondary) .add_modifier(Modifier::BOLD); - let parts: [(&str, Style); 3] = [ - (" ", tip_style), - ("enter", key_style), - (" Revert hunk ", tip_style), - ]; + let parts: Vec<(&str, Style)> = if show_key { + vec![ + (" ", tip_style), + ("c-r", key_style), + (" Revert hunk ", tip_style), + ] + } else { + vec![(" Revert hunk ", tip_style)] + }; let buf_area = buf.area(); if y < buf_area.y || y >= buf_area.y + buf_area.height { From b5791309a0b4dd2d7bfd44a50f609274e0078b1f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 30 Apr 2026 19:28:32 +0800 Subject: [PATCH 04/18] refactor(revert-hunk): rebind revert-hunk from ctrl+r to enter. - Change revert_block keybinding from to - Update Escape handler to clear hunk selection before search - Update status bar hints and tooltips accordingly --- src/config/keybindings.rs | 2 +- src/gui/mod.rs | 6 ++++-- src/gui/views.rs | 6 +++--- src/pager/side_by_side.rs | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index df2db94..d0bac22 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -165,7 +165,7 @@ impl Default for UniversalKeybinding { create_patch_options_menu: "".into(), prev_revert_block: "".into(), next_revert_block: "".into(), - revert_block: "".into(), + revert_block: "".into(), } } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 8a0a23b..6fe1d10 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2052,9 +2052,11 @@ impl Gui { } match key.code { - // Escape: clear search first, then unfocus diff + // Escape: clear revert-hunk selection first, then search, then unfocus diff KeyCode::Esc => { - if !self.diff_view.search_query.is_empty() { + if self.diff_view.selected_revert_hunk.is_some() { + self.diff_view.selected_revert_hunk = None; + } else if !self.diff_view.search_query.is_empty() { self.diff_view.clear_search(); } else { self.diff_focused = false; diff --git a/src/gui/views.rs b/src/gui/views.rs index 4746bbc..208e0d4 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -1366,14 +1366,14 @@ fn render_status_bar( if diff_focused && !diff_view.is_empty() { // Diff-focused hint set: only the diff-relevant keys, kept tight. // Revert-related keys are grouped together at the front so users see - // c-r right next to its cycle keys. c-r itself only appears when a + // enter right next to its cycle keys. enter itself only appears when a // hunk is actually selected (pressing it otherwise is a no-op). if ctx_mgr.active() == ContextId::Files { let has_selection = diff_view.selected_revert_hunk.is_some(); let mut idx = 0; if has_selection { - hints.insert(idx, ("c-r", "revert hunk")); - emphasized.push("c-r"); + hints.insert(idx, ("enter", "revert hunk")); + emphasized.push("enter"); idx += 1; } hints.insert(idx, ("c-j/c-k", "cycle revert")); diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index 0b7d05b..08801d4 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -1303,7 +1303,7 @@ fn render_revert_tooltip(buf: &mut Buffer, x: u16, y: u16, max_width: u16, theme let parts: Vec<(&str, Style)> = if show_key { vec![ (" ", tip_style), - ("c-r", key_style), + ("enter", key_style), (" Revert hunk ", tip_style), ] } else { From 214f4655e61995c55554176b8ee387be009d1e16 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 30 Apr 2026 20:02:09 +0800 Subject: [PATCH 05/18] fix(revert): inaccurate index-based hunk reverting. --- src/git/staging.rs | 148 +++++++++++++++++++++++++++++++++----- src/gui/mod.rs | 10 ++- src/pager/side_by_side.rs | 88 +++++++++++++++++++++++ 3 files changed, 228 insertions(+), 18 deletions(-) diff --git a/src/git/staging.rs b/src/git/staging.rs index 4eab0c5..326153f 100644 --- a/src/git/staging.rs +++ b/src/git/staging.rs @@ -78,35 +78,149 @@ impl GitCommands { Ok(self.parse_diff_hunks(&diff)) } - /// Given a unified diff and a hunk index, reverse-apply that single hunk - /// to the working tree copy of `file_path`. - pub fn revert_hunk_in_worktree_from_unified_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, - hunk_index: usize, + want_old: Option<(usize, usize)>, + want_new: Option<(usize, usize)>, ) -> Result<()> { - let hunks = self.parse_diff_hunks(unified_diff); - let Some(hunk) = hunks.get(hunk_index) else { - bail!( - "hunk {} out of range ({} hunks)", - hunk_index + 1, - hunks.len() - ); - }; - - let patch = build_patch(file_path, hunk); + 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 {}", hunk_index + 1, file_path) - })?; + .with_context(|| format!("failed to revert hunk in {}", file_path))?; Ok(()) } } +fn build_visual_block_patch( + file_path: &str, + unified_diff: &str, + want_old: Option<(usize, usize)>, + want_new: Option<(usize, usize)>, +) -> Result { + if want_old.is_none() && want_new.is_none() { + bail!("empty visual block"); + } + + let mut emitted: Vec = Vec::new(); + let mut anchor_old: Option = None; + let mut anchor_new: Option = 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) { // @@ -1,5 +1,7 @@ let parts: Vec<&str> = header.split_whitespace().collect(); diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 6fe1d10..e56a57c 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -5075,13 +5075,21 @@ impl Gui { let file_name = file.name.clone(); drop(model); + let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) + else { + return Ok(()); + }; + if want_old.is_none() && want_new.is_none() { + return Ok(()); + } + let diff = self.git.diff_file(&file_name)?; if diff.is_empty() { return Ok(()); } self.git - .revert_hunk_in_worktree_from_unified_diff(&file_name, &diff, hunk_idx)?; + .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; self.diff_view.selection = None; self.needs_files_refresh = true; self.needs_diff_refresh = true; diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index 08801d4..bf232a6 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -538,6 +538,7 @@ impl DiffViewState { pub fn apply_parsed(&mut self, parsed: ParsedDiff) { let same_file = self.filename == parsed.filename; let prev_selected_revert_hunk = self.selected_revert_hunk; + let prev_hovered_revert_hunk = self.hovered_revert_hunk; self.filename = parsed.filename; self.old_content = parsed.old_content; self.new_content = parsed.new_content; @@ -551,6 +552,11 @@ impl DiffViewState { } else { None }; + self.hovered_revert_hunk = if same_file { + prev_hovered_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; if same_file { let max = self.lines.len().saturating_sub(1); self.scroll_offset = self.scroll_offset.min(max); @@ -567,6 +573,7 @@ impl DiffViewState { // Preserve scroll position when reloading the same file (e.g. periodic refresh) let same_file = self.filename == filename; let prev_selected_revert_hunk = self.selected_revert_hunk; + let prev_hovered_revert_hunk = self.hovered_revert_hunk; self.filename = filename.to_string(); self.old_content = old.to_string(); self.new_content = new.to_string(); @@ -588,6 +595,11 @@ impl DiffViewState { } else { None }; + self.hovered_revert_hunk = if same_file { + prev_hovered_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; // Preserve side_view across reloads so periodic refresh doesn't reset it // Single section with index 0 self.sections = vec![FileSection { @@ -641,6 +653,7 @@ impl DiffViewState { let new_filename = format!("{} ({} files)", filename, file_count); let same_file = self.filename == new_filename; let prev_selected_revert_hunk = self.selected_revert_hunk; + let prev_hovered_revert_hunk = self.hovered_revert_hunk; self.filename = new_filename; self.old_content = String::new(); self.new_content = String::new(); @@ -657,6 +670,11 @@ impl DiffViewState { } else { None }; + self.hovered_revert_hunk = if same_file { + prev_hovered_revert_hunk + } else { + None + }; self.hunk_line_offsets = Vec::new(); @@ -712,6 +730,12 @@ impl DiffViewState { } else { None }; + self.hovered_revert_hunk = if same_file { + self.hovered_revert_hunk + .filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; if same_file { // Clamp scroll in case the diff got shorter @@ -787,6 +811,70 @@ impl DiffViewState { self.hunk_starts.binary_search(&line_idx).ok() } + /// For the visual hunk at `block_idx`, return the (old, new) line-number + /// ranges (inclusive) the block covers in the underlying file. Either + /// side may be `None` when the block is a pure insertion (no `-` lines) + /// or pure deletion (no `+` lines). Used to slice a sub-patch out of the + /// raw unified diff so revert affects only this visual block, not the + /// surrounding `@@` hunk that may contain other change blocks. + pub fn visual_block_line_ranges( + &self, + block_idx: usize, + ) -> Option<(Option<(usize, usize)>, Option<(usize, usize)>)> { + let start = *self.hunk_starts.get(block_idx)?; + let mut end = start; + while end < self.lines.len() + && !matches!(self.lines[end].change_type, ChangeType::Equal) + { + end += 1; + } + // DiffLine line numbers are content-relative (positions inside the + // concatenated old/new strings produced by parse_unified_diff), but + // the unified-diff walker we feed these ranges to tracks file-relative + // line numbers. `hunk_line_offsets` provides the per-`@@` deltas we + // need to bridge the two. + let (old_offset, new_offset) = self.line_number_offsets_at(start); + let mut old_range: Option<(usize, usize)> = None; + let mut new_range: Option<(usize, usize)> = None; + for line in &self.lines[start..end] { + if matches!(line.change_type, ChangeType::Delete | ChangeType::Modified) { + if let Some((n, _)) = &line.old_line { + let n = *n + old_offset; + old_range = Some(match old_range { + None => (n, n), + Some((lo, hi)) => (lo.min(n), hi.max(n)), + }); + } + } + if matches!(line.change_type, ChangeType::Insert | ChangeType::Modified) { + if let Some((n, _)) = &line.new_line { + let n = *n + new_offset; + new_range = Some(match new_range { + None => (n, n), + Some((lo, hi)) => (lo.min(n), hi.max(n)), + }); + } + } + } + Some((old_range, new_range)) + } + + /// Find the `@@` hunk that owns the DiffLine at `line_idx` and return + /// its (old, new) content→file line-number offsets. Returns (0, 0) when + /// no hunk metadata is available (e.g. the `load(...)` raw-content path, + /// where content and file numbering already coincide). + fn line_number_offsets_at(&self, line_idx: usize) -> (usize, usize) { + let mut offsets = (0usize, 0usize); + for &(start_idx, old_off, new_off) in &self.hunk_line_offsets { + if start_idx <= line_idx { + offsets = (old_off, new_off); + } else { + break; + } + } + offsets + } + /// Cycle to the next revertable hunk marker. pub fn select_next_revert_hunk(&mut self) { if self.hunk_starts.is_empty() { From 22d0113104441ce58ec9ba7c8b82bbf37d681365 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 30 Apr 2026 21:10:50 +0800 Subject: [PATCH 06/18] feat(revert-hunk): added a local undo state stack. --- src/config/keybindings.rs | 3 + src/gui/mod.rs | 65 +++++++- src/gui/views.rs | 5 + src/pager/side_by_side.rs | 306 ++++++++++++++++++++++++++++++-------- 4 files changed, 313 insertions(+), 66 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index d0bac22..5aa9fb3 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -116,6 +116,8 @@ pub struct UniversalKeybinding { pub next_revert_block: String, #[serde(rename = "revertBlock")] pub revert_block: String, + #[serde(rename = "undoRevertBlock")] + pub undo_revert_block: String, } impl Default for UniversalKeybinding { @@ -166,6 +168,7 @@ impl Default for UniversalKeybinding { prev_revert_block: "".into(), next_revert_block: "".into(), revert_block: "".into(), + undo_revert_block: "u".into(), } } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index e56a57c..4b89150 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -13,7 +13,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::time::Instant; -use anyhow::Result; +use anyhow::{Context, Result}; use crossterm::event::{self, Event, KeyCode, KeyEvent, MouseEvent}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::{execute, cursor}; @@ -2015,6 +2015,20 @@ impl Gui { } return Ok(()); } + if matches_key(key, &keybindings.universal.undo_revert_block) { + if self.context_mgr.active() == ContextId::Files + && !self.diff_view.revert_undo_stack.is_empty() + { + if let Err(err) = self.undo_last_revert_block() { + self.popup = PopupState::Message { + title: "Undo revert failed".to_string(), + message: format!("{}", err), + kind: MessageKind::Error, + }; + } + } + return Ok(()); + } // Toggle command log (;) if key.code == KeyCode::Char(';') { @@ -3302,6 +3316,7 @@ impl Gui { HelpEntry { key: kb.universal.next_revert_block.clone(), description: "Next revert block in diff".into() }, HelpEntry { key: kb.universal.prev_revert_block.clone(), description: "Previous revert block in diff".into() }, HelpEntry { key: kb.universal.revert_block.clone(), description: "Revert selected block".into() }, + HelpEntry { key: kb.universal.undo_revert_block.clone(), description: "Undo last revert (session)".into() }, ], }, ContextId::Worktrees => HelpSection { @@ -3493,6 +3508,18 @@ impl Gui { HelpEntry { key: "/".into(), description: "Cycle next / previous revert block (Files)".into() }, HelpEntry { key: "".into(), description: "Revert selected block (Files)".into() }, HelpEntry { key: "click 󰧛".into(), description: "Click revert icon to revert that block".into() }, + HelpEntry { + key: "u".into(), + description: if self.diff_view.revert_undo_stack.is_empty() { + "Undo last revert (nothing to undo)".into() + } else { + format!( + "Undo last revert ({}/{})", + self.diff_view.revert_undo_stack.len(), + self.diff_view.revert_undo_high_water, + ) + }, + }, HelpEntry { key: "e".into(), description: "Edit file at line".into() }, HelpEntry { key: "o".into(), description: "Open file in default program".into() }, HelpEntry { key: "y".into(), description: "Copy selected text".into() }, @@ -5088,14 +5115,50 @@ impl Gui { return Ok(()); } + // Snapshot the working-tree file before reverting so the user can undo + // (`u`) within this session. Only keep the snapshot if the revert + // actually succeeds; otherwise we'd leak unrelated state into the stack. + let abs_path = self.git.repo_path().join(&file_name); + let pre_bytes = std::fs::read(&abs_path).ok(); + self.git .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; + + if let Some(bytes) = pre_bytes { + let stack = &mut self.diff_view.revert_undo_stack; + if stack.len() >= crate::pager::side_by_side::REVERT_UNDO_STACK_CAP { + stack.remove(0); + } + stack.push(crate::pager::side_by_side::RevertUndoEntry { + file_path: file_name.clone(), + pre_revert_bytes: bytes, + }); + self.diff_view.revert_undo_high_water = + self.diff_view.revert_undo_high_water.max(stack.len()); + } + self.diff_view.selection = None; self.needs_files_refresh = true; self.needs_diff_refresh = true; Ok(()) } + fn undo_last_revert_block(&mut self) -> Result<()> { + let Some(entry) = self.diff_view.revert_undo_stack.pop() else { + return Ok(()); + }; + let abs_path = self.git.repo_path().join(&entry.file_path); + std::fs::write(&abs_path, &entry.pre_revert_bytes).with_context(|| { + format!("failed to restore {}", entry.file_path) + })?; + if self.diff_view.revert_undo_stack.is_empty() { + self.diff_view.revert_undo_high_water = 0; + } + self.needs_files_refresh = true; + self.needs_diff_refresh = true; + Ok(()) + } + /// Keep the selected revert marker around the vertical middle of the visible diff area. fn center_selected_revert_block(&mut self) { let Some(sel) = self.diff_view.selected_revert_hunk else { diff --git a/src/gui/views.rs b/src/gui/views.rs index 208e0d4..a20cc36 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -1370,6 +1370,7 @@ fn render_status_bar( // hunk is actually selected (pressing it otherwise is a no-op). if ctx_mgr.active() == ContextId::Files { let has_selection = diff_view.selected_revert_hunk.is_some(); + let has_undo = !diff_view.revert_undo_stack.is_empty(); let mut idx = 0; if has_selection { hints.insert(idx, ("enter", "revert hunk")); @@ -1377,6 +1378,10 @@ fn render_status_bar( idx += 1; } hints.insert(idx, ("c-j/c-k", "cycle revert")); + idx += 1; + if has_undo { + hints.insert(idx, ("u", "undo revert")); + } } hints.push(("{/}", "prev/next hunk")); hints.push(("[/]", "side view")); diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index bf232a6..762edcd 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -1,9 +1,9 @@ +use ratatui::Frame; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::Span; +use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; -use ratatui::Frame; use crate::config::Theme; @@ -134,7 +134,11 @@ impl DiffPanelLayout { } } else { let total_chrome = gutter * 2 + divider; - let content_w = if inner_w > total_chrome { inner_w - total_chrome } else { 0 }; + let content_w = if inner_w > total_chrome { + inner_w - total_chrome + } else { + 0 + }; let panel_w = content_w / 2; let old_content_x = inner_x + gutter; @@ -214,6 +218,15 @@ pub struct DiffSearchMatch { pub col: usize, } +/// One entry in the diff view's revert-hunk undo stack. +pub struct RevertUndoEntry { + pub file_path: String, + pub pre_revert_bytes: Vec, +} + +/// Maximum entries kept in the revert-hunk undo stack. +pub const REVERT_UNDO_STACK_CAP: usize = 20; + /// State for the diff view panel. pub struct DiffViewState { pub scroll_offset: usize, @@ -253,6 +266,14 @@ pub struct DiffViewState { pub selected_revert_hunk: Option, /// Hunk index currently under the mouse cursor (for tooltip rendering). pub hovered_revert_hunk: Option, + /// Pre-revert file snapshots, most-recent last. Bounded by + /// `REVERT_UNDO_STACK_CAP`. Used to undo revert-hunk actions within a + /// session. + pub revert_undo_stack: Vec, + /// Peak `revert_undo_stack.len()` since it was last empty. Drives the + /// `n/m` denominator in the bottom-right footnote; resets to 0 once the + /// stack drains so a fresh streak starts at `1/1`. + pub revert_undo_high_water: usize, } impl Default for DiffViewState { @@ -279,6 +300,8 @@ impl Default for DiffViewState { search_textarea: None, selected_revert_hunk: None, hovered_revert_hunk: None, + revert_undo_stack: Vec::new(), + revert_undo_high_water: 0, } } } @@ -431,7 +454,13 @@ impl DiffViewState { } /// Parse old/new content into a ParsedDiff on any thread (no &self needed). - pub fn parse_content(filename: &str, old: &str, new: &str, tab_width: usize, file_exists_on_disk: bool) -> ParsedDiff { + pub fn parse_content( + filename: &str, + old: &str, + new: &str, + tab_width: usize, + file_exists_on_disk: bool, + ) -> ParsedDiff { let lines = super::diff_algo::compute_side_by_side(old, new, tab_width); let hunk_starts = super::diff_algo::find_hunk_starts(&lines); let sections = vec![FileSection { @@ -451,7 +480,12 @@ impl DiffViewState { } /// Parse raw diff output into a ParsedDiff on any thread (no &self needed). - pub fn parse_diff_output(filename: &str, diff_output: &str, tab_width: usize, file_exists_on_disk: bool) -> ParsedDiff { + pub fn parse_diff_output( + filename: &str, + diff_output: &str, + tab_width: usize, + file_exists_on_disk: bool, + ) -> ParsedDiff { let file_diffs = parse_multi_file_diff(diff_output); if file_diffs.len() <= 1 { @@ -706,14 +740,12 @@ impl DiffViewState { // Compute hunk line offsets for this section let hunks = parse_hunk_headers(file_diff); - let section_offsets = build_hunk_line_offsets( - &hunks, - &self.lines[section_start..], - 0, - ); + let section_offsets = + build_hunk_line_offsets(&hunks, &self.lines[section_start..], 0); // Adjust indices to be global (relative to self.lines) for (idx, old_off, new_off) in section_offsets { - self.hunk_line_offsets.push((section_start + idx, old_off, new_off)); + self.hunk_line_offsets + .push((section_start + idx, old_off, new_off)); } // Create highlighters for this section @@ -763,11 +795,7 @@ impl DiffViewState { } pub fn next_hunk(&mut self) { - if let Some(next) = self - .hunk_starts - .iter() - .find(|&&h| h > self.scroll_offset) - { + if let Some(next) = self.hunk_starts.iter().find(|&&h| h > self.scroll_offset) { self.scroll_offset = *next; } } @@ -823,9 +851,7 @@ impl DiffViewState { ) -> Option<(Option<(usize, usize)>, Option<(usize, usize)>)> { let start = *self.hunk_starts.get(block_idx)?; let mut end = start; - while end < self.lines.len() - && !matches!(self.lines[end].change_type, ChangeType::Equal) - { + while end < self.lines.len() && !matches!(self.lines[end].change_type, ChangeType::Equal) { end += 1; } // DiffLine line numbers are content-relative (positions inside the @@ -902,7 +928,10 @@ impl DiffViewState { } /// Get the highlighters for a given section index. - fn highlighters_for_section(&self, section_index: usize) -> Option<(&FileHighlighter, &FileHighlighter)> { + fn highlighters_for_section( + &self, + section_index: usize, + ) -> Option<(&FileHighlighter, &FileHighlighter)> { self.sections .get(section_index) .map(|s| (&s.old_highlighter, &s.new_highlighter)) @@ -952,11 +981,36 @@ pub fn render_diff( format!(" {}{}", state.filename, side_label) }; - let block = Block::default() + let mut block = Block::default() .title(title) .borders(Borders::ALL) .border_style(border_style); + // Bottom-right footnote: revert-hunk undo indicator. Only shown when + // there's something to undo; the denominator is the peak stack depth + // since it last drained, so a streak reads `1/1`, `2/2`, ... and undos + // walk it back down to `1/3` etc. + let undo_n = state.revert_undo_stack.len(); + if undo_n > 0 { + let undo_m = state.revert_undo_high_water.max(undo_n); + block = block.title_bottom( + Line::from(vec![ + Span::styled(" ", Style::default().fg(theme.text_dimmed)), + Span::styled( + "u", + Style::default() + .fg(theme.accent_secondary) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" undo revert ({}/{}) ", undo_n, undo_m), + Style::default().fg(theme.text_dimmed), + ), + ]) + .alignment(ratatui::layout::Alignment::Right), + ); + } + let inner = block.inner(area); frame.render_widget(block, area); @@ -1101,10 +1155,26 @@ pub fn render_diff( theme, content_width as usize, ); - buf_write_spans(buf, inner.x + gutter_width, y, &spans, content_width, state.horizontal_scroll); + buf_write_spans( + buf, + inner.x + gutter_width, + y, + &spans, + content_width, + state.horizontal_scroll, + ); } else { - let fill: String = std::iter::repeat(' ').take(content_width as usize).collect(); - buf_write_str(buf, inner.x + gutter_width, y, &fill, Style::default().bg(bg), content_width); + let fill: String = std::iter::repeat(' ') + .take(content_width as usize) + .collect(); + buf_write_str( + buf, + inner.x + gutter_width, + y, + &fill, + Style::default().bg(bg), + content_width, + ); } row += 1; } @@ -1213,21 +1283,50 @@ pub fn render_diff( } let y = inner.y + row as u16; - let left_gutter_text = if chunk_idx == 0 { left_num.clone() } else { " · ".to_string() }; - let right_gutter_text = if chunk_idx == 0 { right_num.clone() } else { " · ".to_string() }; + let left_gutter_text = if chunk_idx == 0 { + left_num.clone() + } else { + " · ".to_string() + }; + let right_gutter_text = if chunk_idx == 0 { + right_num.clone() + } else { + " · ".to_string() + }; // Left gutter + content - buf_write_str(buf, inner.x, y, &left_gutter_text, gutter_style, gutter_width); + buf_write_str( + buf, + inner.x, + y, + &left_gutter_text, + gutter_style, + gutter_width, + ); if is_insert { - let slash: String = std::iter::repeat('/').take(panel_width as usize).collect(); - buf_write_str(buf, inner.x + gutter_width, y, &slash, - Style::default().fg(theme.diff_line_number).bg(left_bg), panel_width); + let slash: String = + std::iter::repeat('/').take(panel_width as usize).collect(); + buf_write_str( + buf, + inner.x + gutter_width, + y, + &slash, + Style::default().fg(theme.diff_line_number).bg(left_bg), + panel_width, + ); } else if let Some(chunk) = left_wrapped.get(chunk_idx) { buf_write_spans(buf, inner.x + gutter_width, y, chunk, panel_width, 0); } else { - let fill: String = std::iter::repeat(' ').take(panel_width as usize).collect(); - buf_write_str(buf, inner.x + gutter_width, y, &fill, - Style::default().bg(left_bg), panel_width); + let fill: String = + std::iter::repeat(' ').take(panel_width as usize).collect(); + buf_write_str( + buf, + inner.x + gutter_width, + y, + &fill, + Style::default().bg(left_bg), + panel_width, + ); } // Divider or revert marker (first visual row of a hunk only). @@ -1268,17 +1367,40 @@ pub fn render_diff( } // Right gutter + content - buf_write_str(buf, right_gutter_x, y, &right_gutter_text, right_gutter_style, gutter_width); + buf_write_str( + buf, + right_gutter_x, + y, + &right_gutter_text, + right_gutter_style, + gutter_width, + ); if is_delete { - let slash: String = std::iter::repeat('/').take(right_content_width as usize).collect(); - buf_write_str(buf, right_content_x, y, &slash, - Style::default().fg(theme.diff_line_number).bg(right_bg), right_content_width); + let slash: String = std::iter::repeat('/') + .take(right_content_width as usize) + .collect(); + buf_write_str( + buf, + right_content_x, + y, + &slash, + Style::default().fg(theme.diff_line_number).bg(right_bg), + right_content_width, + ); } else if let Some(chunk) = right_wrapped.get(chunk_idx) { buf_write_spans(buf, right_content_x, y, chunk, right_content_width, 0); } else { - let fill: String = std::iter::repeat(' ').take(right_content_width as usize).collect(); - buf_write_str(buf, right_content_x, y, &fill, - Style::default().bg(right_bg), right_content_width); + let fill: String = std::iter::repeat(' ') + .take(right_content_width as usize) + .collect(); + buf_write_str( + buf, + right_content_x, + y, + &fill, + Style::default().bg(right_bg), + right_content_width, + ); } row += 1; @@ -1291,7 +1413,8 @@ pub fn render_diff( // Left content let left_spans = if is_insert { - let slash_fill: String = std::iter::repeat('/').take(panel_width as usize).collect(); + let slash_fill: String = + std::iter::repeat('/').take(panel_width as usize).collect(); vec![Span::styled( slash_fill, Style::default().fg(theme.diff_line_number).bg(left_bg), @@ -1308,7 +1431,14 @@ pub fn render_diff( panel_width as usize, ) }; - buf_write_spans(buf, inner.x + gutter_width, y, &left_spans, panel_width, state.horizontal_scroll); + buf_write_spans( + buf, + inner.x + gutter_width, + y, + &left_spans, + panel_width, + state.horizontal_scroll, + ); // Divider or revert marker. let show_marker = @@ -1344,11 +1474,19 @@ pub fn render_diff( } // Right gutter - buf_write_str(buf, right_gutter_x, y, &right_num, right_gutter_style, gutter_width); + buf_write_str( + buf, + right_gutter_x, + y, + &right_num, + right_gutter_style, + gutter_width, + ); // Right content let right_spans = if is_delete { - let slash_fill: String = std::iter::repeat('/').take(panel_width as usize).collect(); + let slash_fill: String = + std::iter::repeat('/').take(panel_width as usize).collect(); vec![Span::styled( slash_fill, Style::default().fg(theme.diff_line_number).bg(right_bg), @@ -1365,7 +1503,14 @@ pub fn render_diff( panel_width as usize, ) }; - buf_write_spans(buf, right_content_x, y, &right_spans, right_content_width, state.horizontal_scroll); + buf_write_spans( + buf, + right_content_x, + y, + &right_spans, + right_content_width, + state.horizontal_scroll, + ); row += 1; } @@ -1374,15 +1519,27 @@ pub fn render_diff( if let Some(y) = hover_tooltip_y { let show_key = state.hovered_revert_hunk.is_some() && state.hovered_revert_hunk == state.selected_revert_hunk; - render_revert_tooltip(buf, div_x + divider_width, y, right_content_width + gutter_width, theme, show_key); + render_revert_tooltip( + buf, + div_x + divider_width, + y, + right_content_width + gutter_width, + theme, + show_key, + ); } } } -fn render_revert_tooltip(buf: &mut Buffer, x: u16, y: u16, max_width: u16, theme: &Theme, show_key: bool) { - let tip_style = Style::default() - .bg(theme.selected_bg) - .fg(theme.text_strong); +fn render_revert_tooltip( + buf: &mut Buffer, + x: u16, + y: u16, + max_width: u16, + theme: &Theme, + show_key: bool, +) { + let tip_style = Style::default().bg(theme.selected_bg).fg(theme.text_strong); let key_style = Style::default() .bg(theme.selected_bg) .fg(theme.accent_secondary) @@ -1469,7 +1626,14 @@ fn buf_write_str(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style, max /// Write styled spans directly to the buffer at (x, y), clamped to max_width. /// `h_scroll` skips the first N display columns of content. #[inline] -fn buf_write_spans(buf: &mut Buffer, x: u16, y: u16, spans: &[Span<'_>], max_width: u16, h_scroll: usize) { +fn buf_write_spans( + buf: &mut Buffer, + x: u16, + y: u16, + spans: &[Span<'_>], + max_width: u16, + h_scroll: usize, +) { let buf_area = buf.area(); if y < buf_area.y || y >= buf_area.y + buf_area.height { return; @@ -1521,7 +1685,11 @@ fn wrap_spans<'a>(spans: &[Span<'a>], width: usize) -> Vec>> { .flat_map(|sp| { let style = sp.style; sp.content.chars().filter_map(move |ch| { - if unicode_display_width(ch) > 0 { Some((ch, style)) } else { None } + if unicode_display_width(ch) > 0 { + Some((ch, style)) + } else { + None + } }) }) .collect(); @@ -1680,7 +1848,10 @@ fn build_word_diff_spans<'a>( .add_modifier(Modifier::BOLD), ) } else { - Span::styled(seg.text.clone(), Style::default().bg(bg).fg(theme.syntax_default)) + Span::styled( + seg.text.clone(), + Style::default().bg(bg).fg(theme.syntax_default), + ) } }) .collect() @@ -1723,7 +1894,9 @@ pub fn render_diff_search_highlights( }; let visible_height = area.height.saturating_sub(2) as usize; // -2 for borders - let highlight_style = Style::default().bg(theme.diff_search_highlight_bg).fg(theme.diff_search_highlight_fg); + let highlight_style = Style::default() + .bg(theme.diff_search_highlight_bg) + .fg(theme.diff_search_highlight_fg); let current_highlight_style = Style::default() .bg(theme.diff_search_cursor_bg) .fg(theme.diff_search_cursor_fg) @@ -1776,12 +1949,7 @@ pub fn render_diff_search_highlights( } /// Render a search bar at the bottom of the diff panel area. -pub fn render_diff_search_bar( - frame: &mut Frame, - area: Rect, - state: &DiffViewState, - theme: &Theme, -) { +pub fn render_diff_search_bar(frame: &mut Frame, area: Rect, state: &DiffViewState, theme: &Theme) { // Only render if search is active (typing) or has a query (dismissed but results shown) if !state.search_active && state.search_query.is_empty() { return; @@ -1828,7 +1996,9 @@ pub fn render_diff_search_bar( let prefix_rect = Rect::new(bar_rect.x, bar_y, prefix_width, 1); let prefix = Paragraph::new(Span::styled( " /", - Style::default().fg(theme.diff_grid_fg).bg(theme.diff_grid_bg), + Style::default() + .fg(theme.diff_grid_fg) + .bg(theme.diff_grid_bg), )); frame.render_widget(prefix, prefix_rect); @@ -1838,17 +2008,22 @@ pub fn render_diff_search_bar( } if !match_info.is_empty() { - let suffix_rect = Rect::new(bar_rect.x + prefix_width + ta_width, bar_y, suffix_width, 1); + let suffix_rect = + Rect::new(bar_rect.x + prefix_width + ta_width, bar_y, suffix_width, 1); let suffix = Paragraph::new(Span::styled( match_info, - Style::default().fg(theme.diff_grid_fg).bg(theme.diff_grid_bg), + Style::default() + .fg(theme.diff_grid_fg) + .bg(theme.diff_grid_bg), )); frame.render_widget(suffix, suffix_rect); } } else { // Dismissed search — show query + match info let text = format!(" /{}{}", state.search_query, match_info); - let style = Style::default().fg(theme.diff_grid_fg).bg(theme.diff_grid_bg); + let style = Style::default() + .fg(theme.diff_grid_fg) + .bg(theme.diff_grid_bg); buf_write_str( frame.buffer_mut(), bar_rect.x, @@ -2010,7 +2185,8 @@ fn build_hunk_line_offsets( .as_ref() .map(|(n, _)| *n >= content_old_start) .unwrap_or(false) - || dl.new_line + || dl + .new_line .as_ref() .map(|(n, _)| *n >= content_new_start) .unwrap_or(false) From 80ecfdf87add7930f2e0456d378b57ef032a41b2 Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 06:34:26 -0300 Subject: [PATCH 07/18] fix(diff): remove redundant ctrl-j/k and sync hunk selection with navigation - Removed non-functional Ctrl+J/K hunk navigation in favor of configurable { and } - Updated { and } navigation to automatically select the target hunk, enabling keyboard-only revert without mouse hover - Removed fixed KeyCode::Char('{')/('}') handlers in favor of keybinding config --- src/config/keybindings.rs | 4 ++-- src/gui/controller/diff_mode.rs | 6 ------ src/gui/mod.rs | 7 ------- src/pager/side_by_side.rs | 2 ++ 4 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 5aa9fb3..9e75c21 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -165,8 +165,8 @@ impl Default for UniversalKeybinding { next_screen_mode: "+".into(), prev_screen_mode: "_".into(), create_patch_options_menu: "".into(), - prev_revert_block: "".into(), - next_revert_block: "".into(), + prev_revert_block: "{".into(), + next_revert_block: "}".into(), revert_block: "".into(), undo_revert_block: "u".into(), } diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index d020eb4..a09e4ba 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -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 { diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 4b89150..3144387 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -2094,13 +2094,6 @@ impl Gui { KeyCode::Char('l') | KeyCode::Right => { self.diff_view.scroll_right(4); } - // { and } jump between hunks - KeyCode::Char('}') => { - self.diff_view.next_hunk(); - } - KeyCode::Char('{') => { - self.diff_view.prev_hunk(); - } // [ and ] toggle old-only / new-only view KeyCode::Char(']') => { use crate::pager::side_by_side::DiffSideView; diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index 762edcd..21d617c 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -797,6 +797,7 @@ impl DiffViewState { pub fn next_hunk(&mut self) { if let Some(next) = self.hunk_starts.iter().find(|&&h| h > self.scroll_offset) { self.scroll_offset = *next; + self.selected_revert_hunk = self.hunk_index_for_start_line(*next); } } @@ -808,6 +809,7 @@ impl DiffViewState { .find(|&&h| h < self.scroll_offset) { self.scroll_offset = *prev; + self.selected_revert_hunk = self.hunk_index_for_start_line(*prev); } } From 5f448fbd0679c178beddcb47398093f796d8627a Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 06:56:32 -0300 Subject: [PATCH 08/18] feat(revert-hunk): keep selection after revert and style marker Keep keyboard-only revert flow intact after applying a hunk by auto-selecting the nearest remaining hunk on refresh. Also let users override the revert marker icon, weight, and colors from config without changing the active theme. --- src/config/mod.rs | 2 +- src/config/user_config.rs | 53 +++++++++++++++++++++++++++ src/gui/mod.rs | 15 ++++++++ src/gui/presentation/diff_mode.rs | 26 ++++++++++++-- src/gui/views.rs | 25 +++++++++++-- src/pager/side_by_side.rs | 60 +++++++++++++++++++++++++------ 6 files changed, 165 insertions(+), 16 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 35bfede..01b813d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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::{RevertHunkMarkerConfig, UserConfig, parse_optional_color}; pub fn config_dir_candidates() -> Vec { let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); diff --git a/src/config/user_config.rs b/src/config/user_config.rs index 913d013..b1f681d 100644 --- a/src/config/user_config.rs +++ b/src/config/user_config.rs @@ -1,6 +1,7 @@ use std::path::Path; use anyhow::Result; +use ratatui::style::Color; use serde::{Deserialize, Serialize}; use super::keybindings::KeybindingConfig; @@ -91,6 +92,8 @@ pub struct GuiConfig { pub show_bottom_line: bool, #[serde(rename = "nerdFontsVersion")] pub nerd_fonts_version: String, + #[serde(rename = "revertHunkMarker")] + pub revert_hunk_marker: RevertHunkMarkerConfig, } impl Default for GuiConfig { @@ -106,10 +109,60 @@ impl Default for GuiConfig { show_command_log: true, show_bottom_line: true, nerd_fonts_version: "3".to_string(), + revert_hunk_marker: RevertHunkMarkerConfig::default(), } } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RevertHunkMarkerConfig { + pub icon: String, + pub bold: Option, + pub color: Option, + #[serde(rename = "selectedColor")] + pub selected_color: Option, + #[serde(rename = "hoverColor")] + pub hover_color: Option, +} + +impl Default for RevertHunkMarkerConfig { + 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 { + 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 { diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 3144387..8750e20 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -521,11 +521,15 @@ impl Gui { let diff_loading_show = self.diff_loading && self.diff_loading_since .map(|t| t.elapsed() >= std::time::Duration::from_millis(50)) .unwrap_or(false); + let revert_marker_style = crate::pager::side_by_side::RevertHunkMarkerStyle::from_config( + &self.config.user_config.gui.revert_hunk_marker, + ); presentation::diff_mode::render( frame, &mut self.diff_mode, &mut self.diff_view, &theme, + &revert_marker_style, self.diff_loading, diff_loading_show, ); @@ -5117,6 +5121,16 @@ impl Gui { self.git .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; + let next_selection = if self.diff_view.hunk_starts.is_empty() { + None + } else if hunk_idx > 0 { + Some(hunk_idx - 1) + } else if hunk_idx + 1 < self.diff_view.hunk_starts.len() { + Some(hunk_idx) + } else { + None + }; + if let Some(bytes) = pre_bytes { let stack = &mut self.diff_view.revert_undo_stack; if stack.len() >= crate::pager::side_by_side::REVERT_UNDO_STACK_CAP { @@ -5131,6 +5145,7 @@ impl Gui { } self.diff_view.selection = None; + self.diff_view.preferred_revert_hunk = next_selection; self.needs_files_refresh = true; self.needs_diff_refresh = true; Ok(()) diff --git a/src/gui/presentation/diff_mode.rs b/src/gui/presentation/diff_mode.rs index 1c524fe..0e7a8f1 100644 --- a/src/gui/presentation/diff_mode.rs +++ b/src/gui/presentation/diff_mode.rs @@ -8,7 +8,7 @@ use crate::config::Theme; use crate::gui::modes::diff_mode::{DiffModeFocus, DiffModeState, RefKind}; use crate::model::{CommitFile, FileChangeStatus}; use crate::model::file_tree::CommitFileTreeNode; -use crate::pager::side_by_side::{self, DiffViewState}; +use crate::pager::side_by_side::{self, DiffViewState, RevertHunkMarkerStyle}; /// Max items visible in the dropdown at once. const DROPDOWN_MAX_VISIBLE: usize = 10; @@ -18,6 +18,7 @@ pub fn render( state: &mut DiffModeState, diff_view: &mut DiffViewState, theme: &Theme, + revert_marker_style: &RevertHunkMarkerStyle, diff_loading: bool, diff_loading_show: bool, ) { @@ -49,7 +50,16 @@ pub fn render( render_commit_files(frame, sidebar[2], state, theme); // Right panel: diff exploration - render_diff_panel(frame, content[1], state, diff_view, theme, diff_loading, diff_loading_show); + render_diff_panel( + frame, + content[1], + state, + diff_view, + theme, + revert_marker_style, + diff_loading, + diff_loading_show, + ); // Text selection highlight overlay and tooltip (must be before popups/dropdowns) crate::gui::views::render_selection_overlay(frame, diff_view, content[1], theme); @@ -277,13 +287,23 @@ fn render_diff_panel( state: &DiffModeState, diff_view: &mut DiffViewState, theme: &Theme, + revert_marker_style: &RevertHunkMarkerStyle, diff_loading: bool, diff_loading_show: bool, ) { let focused = state.focus == DiffModeFocus::DiffExploration; if !diff_view.is_empty() { - side_by_side::render_diff(frame, area, diff_view, theme, focused, diff_loading, false); + side_by_side::render_diff( + frame, + area, + diff_view, + theme, + revert_marker_style, + focused, + diff_loading, + false, + ); side_by_side::render_diff_search_highlights(frame, area, diff_view, theme); side_by_side::render_diff_search_bar(frame, area, diff_view, theme); } else { diff --git a/src/gui/views.rs b/src/gui/views.rs index a20cc36..74cd285 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -68,6 +68,9 @@ pub fn render( ai_button_hovered: bool, ai_configured: bool, ) { + let revert_marker_style = side_by_side::RevertHunkMarkerStyle::from_config( + &config.user_config.gui.revert_hunk_marker, + ); let area = frame.area(); let panel_count = SideWindow::ALL.len(); @@ -99,7 +102,16 @@ pub fn render( // Diff is focused: show diff fullscreen if !diff_view.is_empty() { let show_revert_markers = ctx_mgr.active() == ContextId::Files; - side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, true, diff_loading_show, show_revert_markers); + side_by_side::render_diff( + frame, + fl.main_panel, + diff_view, + theme, + &revert_marker_style, + true, + diff_loading_show, + show_revert_markers, + ); side_by_side::render_diff_search_highlights(frame, fl.main_panel, diff_view, theme); side_by_side::render_diff_search_bar(frame, fl.main_panel, diff_view, theme); } else if diff_loading { @@ -586,7 +598,16 @@ pub fn render( render_status_main(frame, fl.main_panel, model, config, theme, status_block); } else if !diff_view.is_empty() { let show_revert_markers = ctx_mgr.active() == ContextId::Files; - side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, diff_focused, diff_loading_show, show_revert_markers); + side_by_side::render_diff( + frame, + fl.main_panel, + diff_view, + theme, + &revert_marker_style, + diff_focused, + diff_loading_show, + show_revert_markers, + ); side_by_side::render_diff_search_highlights(frame, fl.main_panel, diff_view, theme); side_by_side::render_diff_search_bar(frame, fl.main_panel, diff_view, theme); } else if diff_loading { diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index 21d617c..df520ca 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -5,7 +5,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; -use crate::config::Theme; +use crate::config::{RevertHunkMarkerConfig, Theme, parse_optional_color}; use super::highlight::FileHighlighter; use super::{ChangeType, DiffLine, InlineSegment}; @@ -224,6 +224,32 @@ pub struct RevertUndoEntry { pub pre_revert_bytes: Vec, } +#[derive(Clone, Debug)] +pub struct RevertHunkMarkerStyle { + pub icon: String, + pub bold: bool, + pub color: Option, + pub selected_color: Option, + pub hover_color: Option, +} + +impl RevertHunkMarkerStyle { + pub fn from_config(config: &RevertHunkMarkerConfig) -> Self { + let icon = if config.icon.is_empty() { + "󰧛".to_string() + } else { + config.icon.clone() + }; + Self { + icon, + bold: config.bold.unwrap_or(true), + color: parse_optional_color(config.color.as_deref()), + selected_color: parse_optional_color(config.selected_color.as_deref()), + hover_color: parse_optional_color(config.hover_color.as_deref()), + } + } +} + /// Maximum entries kept in the revert-hunk undo stack. pub const REVERT_UNDO_STACK_CAP: usize = 20; @@ -274,6 +300,7 @@ pub struct DiffViewState { /// `n/m` denominator in the bottom-right footnote; resets to 0 once the /// stack drains so a fresh streak starts at `1/1`. pub revert_undo_high_water: usize, + pub preferred_revert_hunk: Option, } impl Default for DiffViewState { @@ -302,6 +329,7 @@ impl Default for DiffViewState { hovered_revert_hunk: None, revert_undo_stack: Vec::new(), revert_undo_high_water: 0, + preferred_revert_hunk: None, } } } @@ -582,10 +610,13 @@ impl DiffViewState { self.sections = parsed.sections; self.file_exists_on_disk = parsed.file_exists_on_disk; self.selected_revert_hunk = if same_file { - prev_selected_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + self.preferred_revert_hunk + .or(prev_selected_revert_hunk) + .filter(|&i| i < self.hunk_starts.len()) } else { None }; + self.preferred_revert_hunk = None; self.hovered_revert_hunk = if same_file { prev_hovered_revert_hunk.filter(|&i| i < self.hunk_starts.len()) } else { @@ -947,6 +978,7 @@ pub fn render_diff( area: Rect, state: &DiffViewState, theme: &Theme, + marker_cfg: &RevertHunkMarkerStyle, focused: bool, diff_loading: bool, show_revert_markers: bool, @@ -1349,13 +1381,17 @@ pub fn render_diff( // Hover wins over selection so the hover state is always // visible — even on a hunk that's currently selected. let fg = if marker_is_hovered { - theme.accent_secondary + marker_cfg.hover_color.unwrap_or(theme.accent_secondary) } else if is_selected { - theme.accent + marker_cfg.selected_color.unwrap_or(theme.accent) } else { - theme.separator + marker_cfg.color.unwrap_or(theme.separator) }; - ("󰧛", Style::default().fg(fg).add_modifier(Modifier::BOLD)) + let mut style = Style::default().fg(fg); + if marker_cfg.bold { + style = style.add_modifier(Modifier::BOLD); + } + (marker_cfg.icon.as_str(), style) } else { ("│", divider_style) }; @@ -1456,13 +1492,17 @@ pub fn render_diff( let (divider_char, marker_style) = if show_marker { let is_selected = marker_hunk_idx == state.selected_revert_hunk; let fg = if marker_is_hovered { - theme.accent_secondary + marker_cfg.hover_color.unwrap_or(theme.accent_secondary) } else if is_selected { - theme.accent + marker_cfg.selected_color.unwrap_or(theme.accent) } else { - theme.separator + marker_cfg.color.unwrap_or(theme.separator) }; - ("󰧛", Style::default().fg(fg).add_modifier(Modifier::BOLD)) + let mut style = Style::default().fg(fg); + if marker_cfg.bold { + style = style.add_modifier(Modifier::BOLD); + } + (marker_cfg.icon.as_str(), style) } else { ("│", divider_style) }; From 0ba76ef86c8213079ee7c2e78fd75c0c7fe09277 Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 08:33:59 -0300 Subject: [PATCH 09/18] fix(revert-hunk): recenter selected hunk after refresh When a revert refresh selects the nearest remaining hunk, recenter it if it lands outside the current viewport so keyboard-only revert keeps the active target visible. --- src/gui/mod.rs | 17 +- src/pager/side_by_side.rs | 20 ++- tests/smoke.rs | 367 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 388 insertions(+), 16 deletions(-) create mode 100644 tests/smoke.rs diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 8750e20..3599d17 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -709,6 +709,10 @@ impl Gui { } DiffPayload::Parsed(parsed) => { self.diff_view.apply_parsed(parsed); + if self.diff_view.center_selected_revert_hunk_on_refresh { + self.center_selected_revert_block(); + self.diff_view.center_selected_revert_hunk_on_refresh = false; + } } DiffPayload::Empty => { self.diff_view = DiffViewState::new(); @@ -5121,15 +5125,7 @@ impl Gui { self.git .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; - let next_selection = if self.diff_view.hunk_starts.is_empty() { - None - } else if hunk_idx > 0 { - Some(hunk_idx - 1) - } else if hunk_idx + 1 < self.diff_view.hunk_starts.len() { - Some(hunk_idx) - } else { - None - }; + let preferred_hunk_line = self.diff_view.hunk_starts.get(hunk_idx).copied(); if let Some(bytes) = pre_bytes { let stack = &mut self.diff_view.revert_undo_stack; @@ -5145,7 +5141,8 @@ impl Gui { } self.diff_view.selection = None; - self.diff_view.preferred_revert_hunk = next_selection; + self.diff_view.preferred_revert_hunk_line = preferred_hunk_line; + self.diff_view.center_selected_revert_hunk_on_refresh = true; self.needs_files_refresh = true; self.needs_diff_refresh = true; Ok(()) diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index df520ca..045d258 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -300,7 +300,8 @@ pub struct DiffViewState { /// `n/m` denominator in the bottom-right footnote; resets to 0 once the /// stack drains so a fresh streak starts at `1/1`. pub revert_undo_high_water: usize, - pub preferred_revert_hunk: Option, + pub preferred_revert_hunk_line: Option, + pub center_selected_revert_hunk_on_refresh: bool, } impl Default for DiffViewState { @@ -329,7 +330,8 @@ impl Default for DiffViewState { hovered_revert_hunk: None, revert_undo_stack: Vec::new(), revert_undo_high_water: 0, - preferred_revert_hunk: None, + preferred_revert_hunk_line: None, + center_selected_revert_hunk_on_refresh: false, } } } @@ -610,13 +612,19 @@ impl DiffViewState { self.sections = parsed.sections; self.file_exists_on_disk = parsed.file_exists_on_disk; self.selected_revert_hunk = if same_file { - self.preferred_revert_hunk - .or(prev_selected_revert_hunk) - .filter(|&i| i < self.hunk_starts.len()) + if let Some(anchor_line) = self.preferred_revert_hunk_line { + self.hunk_starts + .iter() + .enumerate() + .min_by_key(|(_, line_idx)| (**line_idx).abs_diff(anchor_line)) + .map(|(idx, _)| idx) + } else { + prev_selected_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + } } else { None }; - self.preferred_revert_hunk = None; + self.preferred_revert_hunk_line = None; self.hovered_revert_hunk = if same_file { prev_hovered_revert_hunk.filter(|&i| i < self.hunk_starts.len()) } else { diff --git a/tests/smoke.rs b/tests/smoke.rs new file mode 100644 index 0000000..c312a84 --- /dev/null +++ b/tests/smoke.rs @@ -0,0 +1,367 @@ +use std::path::Path; + +#[test] +fn repository_has_readme() { + let readme = Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"); + assert!(readme.exists(), "README.md should exist at repo root"); +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +si + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +add + + +use + + + + +hihihihi + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +aaaaaaa + + + + + + + + + + + + + + +hookoko oooooooooooooooo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +llllk From 900815293cf4e928bd38b8fd0ccbb20c0cacce70 Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 08:50:41 -0300 Subject: [PATCH 10/18] feat(diff): implement sticky revert hunk marker and visual line span calculation --- src/gui/mod.rs | 3 +- src/pager/side_by_side.rs | 67 +++++++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 3599d17..5aa7f33 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -5054,7 +5054,8 @@ impl Gui { return None; } let line_idx = self.diff_view.line_index_at_row(row, layout)?; - self.diff_view.hunk_index_for_start_line(line_idx) + let visible_height = (layout.inner_end_y.saturating_sub(layout.inner_y)) as usize; + self.diff_view.sticky_revert_hunk_at_line(line_idx, visible_height) } fn try_handle_revert_block_click( diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index 045d258..da5db91 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -926,6 +926,43 @@ impl DiffViewState { Some((old_range, new_range)) } + /// Return the inclusive visual line range for a revertable hunk in the diff buffer. + pub fn visual_block_line_span(&self, block_idx: usize) -> Option<(usize, usize)> { + let start = *self.hunk_starts.get(block_idx)?; + let mut end = start; + while end < self.lines.len() && !matches!(self.lines[end].change_type, ChangeType::Equal) { + end += 1; + } + Some((start, end.saturating_sub(1))) + } + + /// Return the line index where the sticky revert marker should render for this hunk, + /// or `None` when no part of the hunk is visible in the current viewport. + pub fn sticky_revert_marker_line( + &self, + block_idx: usize, + visible_height: usize, + ) -> Option { + if visible_height == 0 { + return None; + } + let (start, end) = self.visual_block_line_span(block_idx)?; + let view_start = self.scroll_offset; + let view_end = self + .scroll_offset + .saturating_add(visible_height) + .saturating_sub(1); + if end < view_start || start > view_end { + return None; + } + + let visible_start = start.max(view_start); + let visible_end = end.min(view_end); + let block_mid = start + (end.saturating_sub(start) / 2); + + Some(block_mid.clamp(visible_start, visible_end)) + } + /// Find the `@@` hunk that owns the DiffLine at `line_idx` and return /// its (old, new) content→file line-number offsets. Returns (0, 0) when /// no hunk metadata is available (e.g. the `load(...)` raw-content path, @@ -968,6 +1005,20 @@ impl DiffViewState { self.selected_revert_hunk = Some(prev); } + /// Return the sticky revert-marker hunk rendered at `line_idx` for the current viewport. + pub fn sticky_revert_hunk_at_line( + &self, + line_idx: usize, + visible_height: usize, + ) -> Option { + if self.wrap { + return None; + } + self.hunk_starts.iter().enumerate().find_map(|(idx, _)| { + (self.sticky_revert_marker_line(idx, visible_height) == Some(line_idx)).then_some(idx) + }) + } + /// Get the highlighters for a given section index. fn highlighters_for_section( &self, @@ -1372,15 +1423,12 @@ pub fn render_diff( } // Divider or revert marker (first visual row of a hunk only). - let show_marker = show_revert_markers - && !state.wrap - && chunk_idx == 0 - && state.is_hunk_start_line(line_idx); - let marker_hunk_idx = if show_marker { - state.hunk_index_for_start_line(line_idx) + let marker_hunk_idx = if show_revert_markers && !state.wrap && chunk_idx == 0 { + state.sticky_revert_hunk_at_line(line_idx, visible_height) } else { None }; + let show_marker = marker_hunk_idx.is_some(); let marker_is_hovered = show_marker && marker_hunk_idx.is_some() && marker_hunk_idx == state.hovered_revert_hunk; @@ -1487,13 +1535,12 @@ pub fn render_diff( ); // Divider or revert marker. - let show_marker = - show_revert_markers && !state.wrap && state.is_hunk_start_line(line_idx); - let marker_hunk_idx = if show_marker { - state.hunk_index_for_start_line(line_idx) + let marker_hunk_idx = if show_revert_markers && !state.wrap { + state.sticky_revert_hunk_at_line(line_idx, visible_height) } else { None }; + let show_marker = marker_hunk_idx.is_some(); let marker_is_hovered = show_marker && marker_hunk_idx.is_some() && marker_hunk_idx == state.hovered_revert_hunk; From 566d70675d5c709ae4c4a71be70289ee7c15c562 Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 09:07:51 -0300 Subject: [PATCH 11/18] fix(gui): center selected revert block when navigating hunks --- src/gui/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 5aa7f33..c40024e 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1658,10 +1658,12 @@ impl Gui { // Next/prev hunk with { and } if key.code == KeyCode::Char('{') { self.diff_view.prev_hunk(); + self.center_selected_revert_block(); return Ok(()); } if key.code == KeyCode::Char('}') { self.diff_view.next_hunk(); + self.center_selected_revert_block(); return Ok(()); } From 87e365ae99c5e0c208b006b3897ec61e23f4e353 Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 09:42:17 -0300 Subject: [PATCH 12/18] fix(gui): improve line index calculation for selected revert hunk --- src/gui/mod.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index c40024e..580dc30 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -5172,9 +5172,10 @@ impl Gui { let Some(sel) = self.diff_view.selected_revert_hunk else { return; }; - let Some(&line_idx) = self.diff_view.hunk_starts.get(sel) else { + let Some((start_line, end_line)) = self.diff_view.visual_block_line_span(sel) else { return; }; + let line_idx = start_line + (end_line.saturating_sub(start_line) / 2); let main_panel = self.compute_main_panel_rect(); let pl = DiffPanelLayout::compute(main_panel, &self.diff_view); @@ -5183,13 +5184,6 @@ impl Gui { return; } - let scroll = self.diff_view.scroll_offset; - // Already in viewport? Don't scroll. The marker glyph sits on the - // hunk's first row, so only that row needs to be visible. - if line_idx >= scroll && line_idx < scroll + visible_rows { - return; - } - let desired = line_idx.saturating_sub(visible_rows / 2); let max_start = self.diff_view.lines.len().saturating_sub(visible_rows); self.diff_view.scroll_offset = desired.min(max_start); From 0600a4b47e14367d3b66b97703de532120c85474 Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 10:34:42 -0300 Subject: [PATCH 13/18] fix(gui): remove cycling hints for revert blocks from help entries --- src/gui/mod.rs | 1 - src/gui/views.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 580dc30..4d045c4 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -3508,7 +3508,6 @@ impl Gui { HelpEntry { key: "PgUp/PgDn".into(), description: "Page up / down".into() }, HelpEntry { key: "/".into(), description: "Search in diff".into() }, HelpEntry { key: "n/N".into(), description: "Next / previous search match".into() }, - HelpEntry { key: "/".into(), description: "Cycle next / previous revert block (Files)".into() }, HelpEntry { key: "".into(), description: "Revert selected block (Files)".into() }, HelpEntry { key: "click 󰧛".into(), description: "Click revert icon to revert that block".into() }, HelpEntry { diff --git a/src/gui/views.rs b/src/gui/views.rs index 74cd285..fc3c84d 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -1398,7 +1398,6 @@ fn render_status_bar( emphasized.push("enter"); idx += 1; } - hints.insert(idx, ("c-j/c-k", "cycle revert")); idx += 1; if has_undo { hints.insert(idx, ("u", "undo revert")); From 7f3100714722f222092b817308d9a0e9797626f7 Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 12:28:45 -0300 Subject: [PATCH 14/18] feat(gui): add hunk mode with navigation and help hints --- src/gui/controller/diff_mode.rs | 4 ++- src/gui/mod.rs | 59 +++++++++++++++++++++------------ src/gui/views.rs | 13 ++++++-- src/pager/side_by_side.rs | 2 ++ 4 files changed, 53 insertions(+), 25 deletions(-) diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index a09e4ba..4973cdf 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -706,7 +706,9 @@ fn show_diff_mode_help(gui: &mut Gui) { HelpEntry { key: "".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() }, diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 4d045c4..ee65acb 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1645,6 +1645,19 @@ impl Gui { return Ok(()); } + // Enter hunk mode + if key.code == KeyCode::Char('H') && self.context_mgr.active() == ContextId::Files { + if !self.diff_view.is_empty() { + self.diff_focused = true; + } + self.diff_view.hunk_mode = true; + if self.diff_view.selected_revert_hunk.is_none() && !self.diff_view.hunk_starts.is_empty() { + self.diff_view.select_next_revert_hunk(); + self.center_selected_revert_block(); + } + return Ok(()); + } + // Horizontal scroll (H/L) if matches_key(key, &keybindings.universal.scroll_left) { self.diff_view.scroll_left(4); @@ -1655,18 +1668,6 @@ impl Gui { return Ok(()); } - // Next/prev hunk with { and } - if key.code == KeyCode::Char('{') { - self.diff_view.prev_hunk(); - self.center_selected_revert_block(); - return Ok(()); - } - if key.code == KeyCode::Char('}') { - self.diff_view.next_hunk(); - self.center_selected_revert_block(); - return Ok(()); - } - // Refresh if matches_key(key, &keybindings.universal.refresh) { self.needs_refresh = true; @@ -1993,14 +1994,14 @@ impl Gui { } if matches_key(key, &keybindings.universal.next_revert_block) { - if self.context_mgr.active() == ContextId::Files { + if self.context_mgr.active() == ContextId::Files && self.diff_view.hunk_mode { self.diff_view.select_next_revert_hunk(); self.center_selected_revert_block(); } return Ok(()); } if matches_key(key, &keybindings.universal.prev_revert_block) { - if self.context_mgr.active() == ContextId::Files { + if self.context_mgr.active() == ContextId::Files && self.diff_view.hunk_mode { self.diff_view.select_prev_revert_hunk(); self.center_selected_revert_block(); } @@ -2078,7 +2079,10 @@ impl Gui { match key.code { // Escape: clear revert-hunk selection first, then search, then unfocus diff KeyCode::Esc => { - if self.diff_view.selected_revert_hunk.is_some() { + if self.diff_view.hunk_mode { + self.diff_view.hunk_mode = false; + self.diff_view.selected_revert_hunk = None; + } else if self.diff_view.selected_revert_hunk.is_some() { self.diff_view.selected_revert_hunk = None; } else if !self.diff_view.search_query.is_empty() { self.diff_view.clear_search(); @@ -2092,10 +2096,20 @@ impl Gui { } // j/k/up/down scroll line by line KeyCode::Char('j') | KeyCode::Down => { - self.diff_view.scroll_down(1); + if self.diff_view.hunk_mode { + self.diff_view.select_next_revert_hunk(); + self.center_selected_revert_block(); + } else { + self.diff_view.scroll_down(1); + } } KeyCode::Char('k') | KeyCode::Up => { - self.diff_view.scroll_up(1); + if self.diff_view.hunk_mode { + self.diff_view.select_prev_revert_hunk(); + self.center_selected_revert_block(); + } else { + self.diff_view.scroll_up(1); + } } // h/l/left/right scroll horizontally KeyCode::Char('h') | KeyCode::Left => { @@ -3285,7 +3299,7 @@ impl Gui { HelpEntry { key: kb.universal.prev_screen_mode.clone(), description: "Shrink panel".into() }, HelpEntry { key: kb.universal.create_rebase_options_menu.clone(), description: "Rebase options".into() }, HelpEntry { key: kb.universal.create_patch_options_menu.clone(), description: "Patch options".into() }, - HelpEntry { key: "{/}".into(), description: "Previous/next hunk".into() }, + HelpEntry { key: "H".into(), description: "Enter hunk mode".into() }, HelpEntry { key: ";".into(), description: "Toggle command log".into() }, HelpEntry { key: "W".into(), description: "Compare / Diff mode".into() }, HelpEntry { key: "I".into(), description: "Interactive rebase onto...".into() }, @@ -3316,8 +3330,9 @@ impl Gui { HelpEntry { key: kb.universal.edit.clone(), description: "Open in editor".into() }, HelpEntry { key: kb.universal.open_file.clone(), description: "Open in default program".into() }, HelpEntry { key: "y".into(), description: "Copy to clipboard menu".into() }, - HelpEntry { key: kb.universal.next_revert_block.clone(), description: "Next revert block in diff".into() }, - HelpEntry { key: kb.universal.prev_revert_block.clone(), description: "Previous revert block in diff".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: kb.universal.revert_block.clone(), description: "Revert selected block".into() }, HelpEntry { key: kb.universal.undo_revert_block.clone(), description: "Undo last revert (session)".into() }, ], @@ -3500,7 +3515,9 @@ impl Gui { entries: vec![ HelpEntry { key: "j/k".into(), description: "Scroll down / up".into() }, HelpEntry { key: "h/l".into(), description: "Scroll left / right".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-only view".into() }, HelpEntry { key: "]".into(), description: "Toggle new-only view".into() }, HelpEntry { key: "z".into(), description: "Toggle line wrap".into() }, diff --git a/src/gui/views.rs b/src/gui/views.rs index fc3c84d..e332e9a 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -1393,17 +1393,24 @@ fn render_status_bar( let has_selection = diff_view.selected_revert_hunk.is_some(); let has_undo = !diff_view.revert_undo_stack.is_empty(); let mut idx = 0; + if diff_view.hunk_mode { + hints.insert(idx, ("esc", "exit hunk mode")); + idx += 1; + hints.insert(idx, ("j/k", "cycle hunks")); + idx += 1; + } else { + hints.insert(idx, ("H", "enter hunk mode")); + idx += 1; + } if has_selection { hints.insert(idx, ("enter", "revert hunk")); emphasized.push("enter"); idx += 1; } - idx += 1; if has_undo { hints.insert(idx, ("u", "undo revert")); } } - hints.push(("{/}", "prev/next hunk")); hints.push(("[/]", "side view")); } else { // Sidebar-focused: context-specific hints. @@ -1439,7 +1446,7 @@ fn render_status_bar( } if !diff_view.is_empty() { hints.push(("J/K", "scroll diff")); - hints.push(("{/}", "hunks")); + hints.push(("H", "hunk mode")); } } diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index da5db91..f657037 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -302,6 +302,7 @@ pub struct DiffViewState { pub revert_undo_high_water: usize, pub preferred_revert_hunk_line: Option, pub center_selected_revert_hunk_on_refresh: bool, + pub hunk_mode: bool, } impl Default for DiffViewState { @@ -332,6 +333,7 @@ impl Default for DiffViewState { revert_undo_high_water: 0, preferred_revert_hunk_line: None, center_selected_revert_hunk_on_refresh: false, + hunk_mode: false, } } } From 0db65e2e0b8179f65b8903c21e00c54e309b83ad Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 12:42:49 -0300 Subject: [PATCH 15/18] refactor(revert-hunk): bind revert action to r Use a dedicated revert key in hunk mode so Enter is no longer overloaded in the diff view. Update the files help and status hints to advertise r as the single keyboard action for reverting the selected hunk. --- src/config/keybindings.rs | 2 +- src/gui/mod.rs | 2 +- src/gui/views.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 9e75c21..acbd84d 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -167,7 +167,7 @@ impl Default for UniversalKeybinding { create_patch_options_menu: "".into(), prev_revert_block: "{".into(), next_revert_block: "}".into(), - revert_block: "".into(), + revert_block: "r".into(), undo_revert_block: "u".into(), } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index ee65acb..b26a7ef 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -3525,7 +3525,7 @@ impl Gui { HelpEntry { key: "PgUp/PgDn".into(), description: "Page up / down".into() }, HelpEntry { key: "/".into(), description: "Search in diff".into() }, HelpEntry { key: "n/N".into(), description: "Next / previous search match".into() }, - HelpEntry { key: "".into(), description: "Revert selected block (Files)".into() }, + HelpEntry { key: "r".into(), description: "Revert selected block (Files)".into() }, HelpEntry { key: "click 󰧛".into(), description: "Click revert icon to revert that block".into() }, HelpEntry { key: "u".into(), diff --git a/src/gui/views.rs b/src/gui/views.rs index e332e9a..ed6443d 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -1403,8 +1403,8 @@ fn render_status_bar( idx += 1; } if has_selection { - hints.insert(idx, ("enter", "revert hunk")); - emphasized.push("enter"); + hints.insert(idx, ("r", "revert hunk")); + emphasized.push("r"); idx += 1; } if has_undo { From 352a8349f014e48c864cea903b05e0d6225eca19 Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 13:16:55 -0300 Subject: [PATCH 16/18] fix(gui): update revert icon to new design in help entries and config --- src/config/user_config.rs | 2 +- src/gui/mod.rs | 2 +- src/pager/side_by_side.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/user_config.rs b/src/config/user_config.rs index b1f681d..7c4dae9 100644 --- a/src/config/user_config.rs +++ b/src/config/user_config.rs @@ -129,7 +129,7 @@ pub struct RevertHunkMarkerConfig { impl Default for RevertHunkMarkerConfig { fn default() -> Self { Self { - icon: "󰧛".to_string(), + icon: "".to_string(), bold: None, color: None, selected_color: None, diff --git a/src/gui/mod.rs b/src/gui/mod.rs index b26a7ef..e969af7 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -3526,7 +3526,7 @@ impl Gui { HelpEntry { key: "/".into(), description: "Search in diff".into() }, HelpEntry { key: "n/N".into(), description: "Next / previous search match".into() }, HelpEntry { key: "r".into(), description: "Revert selected block (Files)".into() }, - HelpEntry { key: "click 󰧛".into(), description: "Click revert icon to revert that block".into() }, + HelpEntry { key: "click ".into(), description: "Click revert icon to revert that block".into() }, HelpEntry { key: "u".into(), description: if self.diff_view.revert_undo_stack.is_empty() { diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index f657037..4a28c0e 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -236,7 +236,7 @@ pub struct RevertHunkMarkerStyle { impl RevertHunkMarkerStyle { pub fn from_config(config: &RevertHunkMarkerConfig) -> Self { let icon = if config.icon.is_empty() { - "󰧛".to_string() + "".to_string() } else { config.icon.clone() }; From cf69f2bea66271fc247933faa53dad77c3b295c9 Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 20:22:46 -0300 Subject: [PATCH 17/18] Refactor hunk marker handling and rename related structures - Renamed `RevertHunkMarkerConfig` to `HunkMarkerConfig` and updated all references. - Updated `GuiConfig` to use the new `HunkMarkerConfig`. - Refactored `GitCommands` to use `build_hunk_patch` instead of `build_patch`. - Changed GUI logic to handle hunk actions instead of revert actions, including renaming variables and methods for clarity. - Updated rendering logic to display hunk markers instead of revert markers. - Adjusted undo functionality to track hunk actions instead of revert actions. - Modified tooltip rendering to reflect hunk actions for staging and reverting. --- src/config/mod.rs | 2 +- src/config/user_config.rs | 8 +- src/git/staging.rs | 31 +++- src/gui/mod.rs | 252 +++++++++++++++++------------- src/gui/presentation/diff_mode.rs | 10 +- src/gui/views.rs | 17 +- src/pager/side_by_side.rs | 207 +++++++++++++----------- 7 files changed, 308 insertions(+), 219 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index 01b813d..518c364 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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::{RevertHunkMarkerConfig, UserConfig, parse_optional_color}; +pub use user_config::{HunkMarkerConfig, UserConfig, parse_optional_color}; pub fn config_dir_candidates() -> Vec { let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); diff --git a/src/config/user_config.rs b/src/config/user_config.rs index 7c4dae9..c4aa388 100644 --- a/src/config/user_config.rs +++ b/src/config/user_config.rs @@ -93,7 +93,7 @@ pub struct GuiConfig { #[serde(rename = "nerdFontsVersion")] pub nerd_fonts_version: String, #[serde(rename = "revertHunkMarker")] - pub revert_hunk_marker: RevertHunkMarkerConfig, + pub hunk_marker: HunkMarkerConfig, } impl Default for GuiConfig { @@ -109,14 +109,14 @@ impl Default for GuiConfig { show_command_log: true, show_bottom_line: true, nerd_fonts_version: "3".to_string(), - revert_hunk_marker: RevertHunkMarkerConfig::default(), + hunk_marker: HunkMarkerConfig::default(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] -pub struct RevertHunkMarkerConfig { +pub struct HunkMarkerConfig { pub icon: String, pub bold: Option, pub color: Option, @@ -126,7 +126,7 @@ pub struct RevertHunkMarkerConfig { pub hover_color: Option, } -impl Default for RevertHunkMarkerConfig { +impl Default for HunkMarkerConfig { fn default() -> Self { Self { icon: "".to_string(), diff --git a/src/git/staging.rs b/src/git/staging.rs index 326153f..1f668f2 100644 --- a/src/git/staging.rs +++ b/src/git/staging.rs @@ -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) @@ -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) @@ -100,6 +100,33 @@ impl GitCommands { .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 { + 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( diff --git a/src/gui/mod.rs b/src/gui/mod.rs index e969af7..edf39a5 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -26,7 +26,7 @@ use crate::config::keybindings::parse_key; use crate::git::{GitCommands, ModelPart, MODEL_PART_COUNT}; use crate::model::Model; use crate::model::file_tree::{build_file_tree, CommitFileTreeNode, FileTreeNode}; -use crate::pager::side_by_side::{DiffPanel, DiffPanelLayout, DiffViewState, TextSelection}; +use crate::pager::side_by_side::{DiffPanel, DiffPanelLayout, DiffViewState, HunkActionKind, TextSelection}; use self::context::{ContextId, ContextManager, SideWindow}; use self::layout::LayoutState; @@ -521,15 +521,15 @@ impl Gui { let diff_loading_show = self.diff_loading && self.diff_loading_since .map(|t| t.elapsed() >= std::time::Duration::from_millis(50)) .unwrap_or(false); - let revert_marker_style = crate::pager::side_by_side::RevertHunkMarkerStyle::from_config( - &self.config.user_config.gui.revert_hunk_marker, + let hunk_marker_style = crate::pager::side_by_side::HunkMarkerStyle::from_config( + &self.config.user_config.gui.hunk_marker, ); presentation::diff_mode::render( frame, &mut self.diff_mode, &mut self.diff_view, &theme, - &revert_marker_style, + &hunk_marker_style, self.diff_loading, diff_loading_show, ); @@ -709,9 +709,9 @@ impl Gui { } DiffPayload::Parsed(parsed) => { self.diff_view.apply_parsed(parsed); - if self.diff_view.center_selected_revert_hunk_on_refresh { - self.center_selected_revert_block(); - self.diff_view.center_selected_revert_hunk_on_refresh = false; + if self.diff_view.center_selected_hunk_on_refresh { + self.center_selected_hunk(); + self.diff_view.center_selected_hunk_on_refresh = false; } } DiffPayload::Empty => { @@ -1651,9 +1651,9 @@ impl Gui { self.diff_focused = true; } self.diff_view.hunk_mode = true; - if self.diff_view.selected_revert_hunk.is_none() && !self.diff_view.hunk_starts.is_empty() { - self.diff_view.select_next_revert_hunk(); - self.center_selected_revert_block(); + if self.diff_view.selected_hunk.is_none() && !self.diff_view.hunk_starts.is_empty() { + self.diff_view.select_next_hunk(); + self.center_selected_hunk(); } return Ok(()); } @@ -1995,27 +1995,40 @@ impl Gui { if matches_key(key, &keybindings.universal.next_revert_block) { if self.context_mgr.active() == ContextId::Files && self.diff_view.hunk_mode { - self.diff_view.select_next_revert_hunk(); - self.center_selected_revert_block(); + self.diff_view.select_next_hunk(); + self.center_selected_hunk(); } return Ok(()); } if matches_key(key, &keybindings.universal.prev_revert_block) { if self.context_mgr.active() == ContextId::Files && self.diff_view.hunk_mode { - self.diff_view.select_prev_revert_hunk(); - self.center_selected_revert_block(); + self.diff_view.select_prev_hunk(); + self.center_selected_hunk(); + } + return Ok(()); + } + if key.code == KeyCode::Char('a') { + if self.context_mgr.active() == ContextId::Files { + let hunk_idx = self.diff_view.selected_hunk.or(self.diff_view.hovered_hunk); + if let Some(hunk_idx) = hunk_idx { + self.diff_view.selected_hunk = Some(hunk_idx); + if let Err(err) = self.apply_selected_file_hunk_action(hunk_idx, HunkActionKind::Stage) { + self.popup = PopupState::Message { + title: "Stage block failed".to_string(), + message: format!("{}", err), + kind: MessageKind::Error, + }; + } + } } return Ok(()); } if matches_key(key, &keybindings.universal.revert_block) { if self.context_mgr.active() == ContextId::Files { - let hunk_idx = self - .diff_view - .selected_revert_hunk - .or(self.diff_view.hovered_revert_hunk); + let hunk_idx = self.diff_view.selected_hunk.or(self.diff_view.hovered_hunk); if let Some(hunk_idx) = hunk_idx { - self.diff_view.selected_revert_hunk = Some(hunk_idx); - if let Err(err) = self.revert_selected_file_hunk(hunk_idx) { + self.diff_view.selected_hunk = Some(hunk_idx); + if let Err(err) = self.apply_selected_file_hunk_action(hunk_idx, HunkActionKind::Revert) { self.popup = PopupState::Message { title: "Revert block failed".to_string(), message: format!("{}", err), @@ -2028,11 +2041,11 @@ impl Gui { } if matches_key(key, &keybindings.universal.undo_revert_block) { if self.context_mgr.active() == ContextId::Files - && !self.diff_view.revert_undo_stack.is_empty() + && !self.diff_view.hunk_action_undo_stack.is_empty() { - if let Err(err) = self.undo_last_revert_block() { + if let Err(err) = self.undo_last_hunk_action() { self.popup = PopupState::Message { - title: "Undo revert failed".to_string(), + title: "Undo hunk action failed".to_string(), message: format!("{}", err), kind: MessageKind::Error, }; @@ -2081,9 +2094,9 @@ impl Gui { KeyCode::Esc => { if self.diff_view.hunk_mode { self.diff_view.hunk_mode = false; - self.diff_view.selected_revert_hunk = None; - } else if self.diff_view.selected_revert_hunk.is_some() { - self.diff_view.selected_revert_hunk = None; + self.diff_view.selected_hunk = None; + } else if self.diff_view.selected_hunk.is_some() { + self.diff_view.selected_hunk = None; } else if !self.diff_view.search_query.is_empty() { self.diff_view.clear_search(); } else { @@ -2097,16 +2110,16 @@ impl Gui { // j/k/up/down scroll line by line KeyCode::Char('j') | KeyCode::Down => { if self.diff_view.hunk_mode { - self.diff_view.select_next_revert_hunk(); - self.center_selected_revert_block(); + self.diff_view.select_next_hunk(); + self.center_selected_hunk(); } else { self.diff_view.scroll_down(1); } } KeyCode::Char('k') | KeyCode::Up => { if self.diff_view.hunk_mode { - self.diff_view.select_prev_revert_hunk(); - self.center_selected_revert_block(); + self.diff_view.select_prev_hunk(); + self.center_selected_hunk(); } else { self.diff_view.scroll_up(1); } @@ -3333,8 +3346,9 @@ impl Gui { 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: "a".into(), description: "Stage selected block".into() }, HelpEntry { key: kb.universal.revert_block.clone(), description: "Revert selected block".into() }, - HelpEntry { key: kb.universal.undo_revert_block.clone(), description: "Undo last revert (session)".into() }, + HelpEntry { key: kb.universal.undo_revert_block.clone(), description: "Undo last hunk action (session)".into() }, ], }, ContextId::Worktrees => HelpSection { @@ -3525,17 +3539,18 @@ impl Gui { HelpEntry { key: "PgUp/PgDn".into(), description: "Page up / down".into() }, HelpEntry { key: "/".into(), description: "Search in diff".into() }, HelpEntry { key: "n/N".into(), description: "Next / previous search match".into() }, + HelpEntry { key: "a".into(), description: "Stage selected block (Files)".into() }, HelpEntry { key: "r".into(), description: "Revert selected block (Files)".into() }, - HelpEntry { key: "click ".into(), description: "Click revert icon to revert that block".into() }, + HelpEntry { key: "click ".into(), description: "Select block marker, then use a/r".into() }, HelpEntry { key: "u".into(), - description: if self.diff_view.revert_undo_stack.is_empty() { - "Undo last revert (nothing to undo)".into() + description: if self.diff_view.hunk_action_undo_stack.is_empty() { + "Undo last hunk action (nothing to undo)".into() } else { format!( - "Undo last revert ({}/{})", - self.diff_view.revert_undo_stack.len(), - self.diff_view.revert_undo_high_water, + "Undo last hunk action ({}/{})", + self.diff_view.hunk_action_undo_stack.len(), + self.diff_view.hunk_action_undo_high_water, ) }, }, @@ -4390,12 +4405,11 @@ impl Gui { let main_panel = self.compute_main_panel_rect(); let pl = DiffPanelLayout::compute(main_panel, &self.diff_view); - // Track mouse hover over the revert-block marker (for tooltip). + // Track mouse hover over the hunk marker (for tooltip). if !self.diff_mode.active { - let new_hover = - self.revert_hunk_at_position(main_panel, &pl, mouse.column, mouse.row); - if self.diff_view.hovered_revert_hunk != new_hover { - self.diff_view.hovered_revert_hunk = new_hover; + let new_hover = self.hunk_at_position(main_panel, &pl, mouse.column, mouse.row); + if self.diff_view.hovered_hunk != new_hover { + self.diff_view.hovered_hunk = new_hover; } } @@ -4412,7 +4426,7 @@ impl Gui { let full_sidebar = self.screen_mode == ScreenMode::Full && !self.diff_focused; if in_main && !self.diff_view.is_empty() && !full_sidebar { - if self.try_handle_revert_block_click(main_panel, pl, mouse.column, mouse.row) { + if self.try_handle_hunk_click(main_panel, pl, mouse.column, mouse.row) { self.diff_focused = true; return; } @@ -4750,7 +4764,7 @@ impl Gui { // Check if click is in the diff panel — start text selection if rect_contains(diff_rect, col, row) && !self.diff_view.is_empty() { let pl = DiffPanelLayout::compute(diff_rect, &self.diff_view); - if self.try_handle_revert_block_click(diff_rect, pl, col, row) { + if self.try_handle_hunk_click(diff_rect, pl, col, row) { self.diff_mode.focus = DiffModeFocus::DiffExploration; return; } @@ -5051,7 +5065,7 @@ impl Gui { self.compute_current_frame_layout().main_panel } - fn revert_hunk_at_position( + fn hunk_at_position( &self, panel_rect: ratatui::layout::Rect, layout: &DiffPanelLayout, @@ -5068,15 +5082,15 @@ impl Gui { return None; } let divider_x = layout.divider_x()?; - if col != divider_x { + if col < divider_x || col >= divider_x.saturating_add(2) { return None; } let line_idx = self.diff_view.line_index_at_row(row, layout)?; let visible_height = (layout.inner_end_y.saturating_sub(layout.inner_y)) as usize; - self.diff_view.sticky_revert_hunk_at_line(line_idx, visible_height) + self.diff_view.sticky_hunk_at_line(line_idx, visible_height) } - fn try_handle_revert_block_click( + fn try_handle_hunk_click( &mut self, panel_rect: ratatui::layout::Rect, layout: DiffPanelLayout, @@ -5086,21 +5100,17 @@ impl Gui { if self.diff_mode.active { return false; } - let Some(hunk_idx) = self.revert_hunk_at_position(panel_rect, &layout, col, row) else { + let Some(hunk_idx) = self.hunk_at_position(panel_rect, &layout, col, row) else { return false; }; - self.diff_view.selected_revert_hunk = Some(hunk_idx); - if let Err(err) = self.revert_selected_file_hunk(hunk_idx) { - self.popup = PopupState::Message { - title: "Revert block failed".to_string(), - message: format!("{}", err), - kind: MessageKind::Error, - }; + if self.diff_view.selected_hunk == Some(hunk_idx) { + return true; } + self.diff_view.selected_hunk = Some(hunk_idx); true } - fn revert_selected_file_hunk(&mut self, hunk_idx: usize) -> Result<()> { + fn apply_selected_file_hunk_action(&mut self, hunk_idx: usize, action: HunkActionKind) -> Result<()> { let Some(file_idx) = self.selected_file_index() else { return Ok(()); }; @@ -5110,82 +5120,112 @@ impl Gui { return Ok(()); }; - if !file.has_unstaged_changes { - self.popup = PopupState::Message { - title: "Revert block".to_string(), - message: "Block revert is available only for unstaged changes.".to_string(), - kind: MessageKind::Info, - }; - return Ok(()); - } - let file_name = file.name.clone(); + let has_staged = file.has_staged_changes; + let has_unstaged = file.has_unstaged_changes; drop(model); - let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) - else { - return Ok(()); - }; - if want_old.is_none() && want_new.is_none() { - return Ok(()); - } + let mut undo_entry: Option = None; - let diff = self.git.diff_file(&file_name)?; - if diff.is_empty() { - return Ok(()); + match action { + HunkActionKind::Revert => { + if !has_unstaged { + self.popup = PopupState::Message { + title: "Revert block".to_string(), + message: "Block revert is available only for unstaged changes.".to_string(), + kind: MessageKind::Info, + }; + return Ok(()); + } + let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) else { + return Ok(()); + }; + if want_old.is_none() && want_new.is_none() { + return Ok(()); + } + let diff = self.git.diff_file(&file_name)?; + if diff.is_empty() { + return Ok(()); + } + let patch = self + .git + .build_visual_block_patch_text(&file_name, &diff, want_old, want_new)?; + undo_entry = Some(crate::pager::side_by_side::HunkActionUndoEntry { + action, + patch, + apply_cached: false, + reverse: false, + }); + self.git + .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; + } + HunkActionKind::Stage => { + let staged_diff = !(has_unstaged || !has_staged); + let diff = if staged_diff { + self.git.diff_file_staged(&file_name)? + } else { + self.git.diff_file(&file_name)? + }; + if diff.is_empty() { + return Ok(()); + } + let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) else { + return Ok(()); + }; + if want_old.is_none() && want_new.is_none() { + return Ok(()); + } + let patch = self + .git + .build_visual_block_patch_text(&file_name, &diff, want_old, want_new)?; + undo_entry = Some(crate::pager::side_by_side::HunkActionUndoEntry { + action, + patch: patch.clone(), + apply_cached: true, + reverse: !staged_diff, + }); + self.git.apply_patch_text(patch, true, staged_diff)?; + } } - // Snapshot the working-tree file before reverting so the user can undo - // (`u`) within this session. Only keep the snapshot if the revert - // actually succeeds; otherwise we'd leak unrelated state into the stack. - let abs_path = self.git.repo_path().join(&file_name); - let pre_bytes = std::fs::read(&abs_path).ok(); - - self.git - .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; - let preferred_hunk_line = self.diff_view.hunk_starts.get(hunk_idx).copied(); - if let Some(bytes) = pre_bytes { - let stack = &mut self.diff_view.revert_undo_stack; + if let Some(entry) = undo_entry { + let stack = &mut self.diff_view.hunk_action_undo_stack; if stack.len() >= crate::pager::side_by_side::REVERT_UNDO_STACK_CAP { stack.remove(0); } - stack.push(crate::pager::side_by_side::RevertUndoEntry { - file_path: file_name.clone(), - pre_revert_bytes: bytes, - }); - self.diff_view.revert_undo_high_water = - self.diff_view.revert_undo_high_water.max(stack.len()); + stack.push(entry); + self.diff_view.hunk_action_undo_high_water = + self.diff_view.hunk_action_undo_high_water.max(stack.len()); } self.diff_view.selection = None; - self.diff_view.preferred_revert_hunk_line = preferred_hunk_line; - self.diff_view.center_selected_revert_hunk_on_refresh = true; + self.diff_view.preferred_selected_hunk_line = preferred_hunk_line; + self.diff_view.center_selected_hunk_on_refresh = true; self.needs_files_refresh = true; self.needs_diff_refresh = true; Ok(()) } - fn undo_last_revert_block(&mut self) -> Result<()> { - let Some(entry) = self.diff_view.revert_undo_stack.pop() else { + fn undo_last_hunk_action(&mut self) -> Result<()> { + let Some(entry) = self.diff_view.hunk_action_undo_stack.pop() else { return Ok(()); }; - let abs_path = self.git.repo_path().join(&entry.file_path); - std::fs::write(&abs_path, &entry.pre_revert_bytes).with_context(|| { - format!("failed to restore {}", entry.file_path) - })?; - if self.diff_view.revert_undo_stack.is_empty() { - self.diff_view.revert_undo_high_water = 0; + self.git + .apply_patch_text(entry.patch, entry.apply_cached, entry.reverse) + .with_context(|| format!("failed to undo {} hunk action", entry.action.verb()))?; + if self.diff_view.hunk_action_undo_stack.is_empty() { + self.diff_view.hunk_action_undo_high_water = 0; } self.needs_files_refresh = true; self.needs_diff_refresh = true; Ok(()) } - /// Keep the selected revert marker around the vertical middle of the visible diff area. - fn center_selected_revert_block(&mut self) { - let Some(sel) = self.diff_view.selected_revert_hunk else { + /// Keep the selected hunk marker around the vertical middle of the visible diff area. + fn center_selected_hunk(&mut self) { + let Some(sel) = self.diff_view.selected_hunk else { return; }; let Some((start_line, end_line)) = self.diff_view.visual_block_line_span(sel) else { diff --git a/src/gui/presentation/diff_mode.rs b/src/gui/presentation/diff_mode.rs index 0e7a8f1..eff7f74 100644 --- a/src/gui/presentation/diff_mode.rs +++ b/src/gui/presentation/diff_mode.rs @@ -8,7 +8,7 @@ use crate::config::Theme; use crate::gui::modes::diff_mode::{DiffModeFocus, DiffModeState, RefKind}; use crate::model::{CommitFile, FileChangeStatus}; use crate::model::file_tree::CommitFileTreeNode; -use crate::pager::side_by_side::{self, DiffViewState, RevertHunkMarkerStyle}; +use crate::pager::side_by_side::{self, DiffViewState, HunkMarkerStyle}; /// Max items visible in the dropdown at once. const DROPDOWN_MAX_VISIBLE: usize = 10; @@ -18,7 +18,7 @@ pub fn render( state: &mut DiffModeState, diff_view: &mut DiffViewState, theme: &Theme, - revert_marker_style: &RevertHunkMarkerStyle, + hunk_marker_style: &HunkMarkerStyle, diff_loading: bool, diff_loading_show: bool, ) { @@ -56,7 +56,7 @@ pub fn render( state, diff_view, theme, - revert_marker_style, + hunk_marker_style, diff_loading, diff_loading_show, ); @@ -287,7 +287,7 @@ fn render_diff_panel( state: &DiffModeState, diff_view: &mut DiffViewState, theme: &Theme, - revert_marker_style: &RevertHunkMarkerStyle, + hunk_marker_style: &HunkMarkerStyle, diff_loading: bool, diff_loading_show: bool, ) { @@ -299,7 +299,7 @@ fn render_diff_panel( area, diff_view, theme, - revert_marker_style, + hunk_marker_style, focused, diff_loading, false, diff --git a/src/gui/views.rs b/src/gui/views.rs index ed6443d..51c848e 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -68,8 +68,8 @@ pub fn render( ai_button_hovered: bool, ai_configured: bool, ) { - let revert_marker_style = side_by_side::RevertHunkMarkerStyle::from_config( - &config.user_config.gui.revert_hunk_marker, + let hunk_marker_style = side_by_side::HunkMarkerStyle::from_config( + &config.user_config.gui.hunk_marker, ); let area = frame.area(); let panel_count = SideWindow::ALL.len(); @@ -107,7 +107,7 @@ pub fn render( fl.main_panel, diff_view, theme, - &revert_marker_style, + &hunk_marker_style, true, diff_loading_show, show_revert_markers, @@ -603,7 +603,7 @@ pub fn render( fl.main_panel, diff_view, theme, - &revert_marker_style, + &hunk_marker_style, diff_focused, diff_loading_show, show_revert_markers, @@ -1390,8 +1390,8 @@ fn render_status_bar( // enter right next to its cycle keys. enter itself only appears when a // hunk is actually selected (pressing it otherwise is a no-op). if ctx_mgr.active() == ContextId::Files { - let has_selection = diff_view.selected_revert_hunk.is_some(); - let has_undo = !diff_view.revert_undo_stack.is_empty(); + let has_selection = diff_view.selected_hunk.is_some(); + let has_undo = !diff_view.hunk_action_undo_stack.is_empty(); let mut idx = 0; if diff_view.hunk_mode { hints.insert(idx, ("esc", "exit hunk mode")); @@ -1403,12 +1403,15 @@ fn render_status_bar( idx += 1; } if has_selection { + hints.insert(idx, ("a", "stage hunk")); + emphasized.push("a"); + idx += 1; hints.insert(idx, ("r", "revert hunk")); emphasized.push("r"); idx += 1; } if has_undo { - hints.insert(idx, ("u", "undo revert")); + hints.insert(idx, ("u", "undo hunk action")); } } hints.push(("[/]", "side view")); diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index 4a28c0e..9bbc130 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -5,7 +5,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; -use crate::config::{RevertHunkMarkerConfig, Theme, parse_optional_color}; +use crate::config::{HunkMarkerConfig, Theme, parse_optional_color}; use super::highlight::FileHighlighter; use super::{ChangeType, DiffLine, InlineSegment}; @@ -218,14 +218,31 @@ pub struct DiffSearchMatch { pub col: usize, } -/// One entry in the diff view's revert-hunk undo stack. -pub struct RevertUndoEntry { - pub file_path: String, - pub pre_revert_bytes: Vec, +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HunkActionKind { + Stage, + Revert, +} + +impl HunkActionKind { + pub fn verb(self) -> &'static str { + match self { + Self::Stage => "stage", + Self::Revert => "revert", + } + } +} + +/// One entry in the diff view's hunk-action undo stack. +pub struct HunkActionUndoEntry { + pub action: HunkActionKind, + pub patch: String, + pub apply_cached: bool, + pub reverse: bool, } #[derive(Clone, Debug)] -pub struct RevertHunkMarkerStyle { +pub struct HunkMarkerStyle { pub icon: String, pub bold: bool, pub color: Option, @@ -233,8 +250,8 @@ pub struct RevertHunkMarkerStyle { pub hover_color: Option, } -impl RevertHunkMarkerStyle { - pub fn from_config(config: &RevertHunkMarkerConfig) -> Self { +impl HunkMarkerStyle { + pub fn from_config(config: &HunkMarkerConfig) -> Self { let icon = if config.icon.is_empty() { "".to_string() } else { @@ -288,20 +305,19 @@ pub struct DiffViewState { pub search_match_idx: usize, /// Textarea widget for search input. pub search_textarea: Option>, - /// Currently selected revert-button hunk index (for keyboard cycling). - pub selected_revert_hunk: Option, + /// Currently selected hunk-action marker index (for keyboard cycling). + pub selected_hunk: Option, /// Hunk index currently under the mouse cursor (for tooltip rendering). - pub hovered_revert_hunk: Option, - /// Pre-revert file snapshots, most-recent last. Bounded by - /// `REVERT_UNDO_STACK_CAP`. Used to undo revert-hunk actions within a - /// session. - pub revert_undo_stack: Vec, - /// Peak `revert_undo_stack.len()` since it was last empty. Drives the + pub hovered_hunk: Option, + /// Pre-action file snapshots, most-recent last. Bounded by + /// `REVERT_UNDO_STACK_CAP`. Used to undo hunk actions within a session. + pub hunk_action_undo_stack: Vec, + /// Peak `hunk_action_undo_stack.len()` since it was last empty. Drives the /// `n/m` denominator in the bottom-right footnote; resets to 0 once the /// stack drains so a fresh streak starts at `1/1`. - pub revert_undo_high_water: usize, - pub preferred_revert_hunk_line: Option, - pub center_selected_revert_hunk_on_refresh: bool, + pub hunk_action_undo_high_water: usize, + pub preferred_selected_hunk_line: Option, + pub center_selected_hunk_on_refresh: bool, pub hunk_mode: bool, } @@ -327,12 +343,12 @@ impl Default for DiffViewState { search_matches: Vec::new(), search_match_idx: 0, search_textarea: None, - selected_revert_hunk: None, - hovered_revert_hunk: None, - revert_undo_stack: Vec::new(), - revert_undo_high_water: 0, - preferred_revert_hunk_line: None, - center_selected_revert_hunk_on_refresh: false, + selected_hunk: None, + hovered_hunk: None, + hunk_action_undo_stack: Vec::new(), + hunk_action_undo_high_water: 0, + preferred_selected_hunk_line: None, + center_selected_hunk_on_refresh: false, hunk_mode: false, } } @@ -603,8 +619,8 @@ impl DiffViewState { /// Apply a pre-parsed diff result, preserving scroll position for same-file reloads. pub fn apply_parsed(&mut self, parsed: ParsedDiff) { let same_file = self.filename == parsed.filename; - let prev_selected_revert_hunk = self.selected_revert_hunk; - let prev_hovered_revert_hunk = self.hovered_revert_hunk; + let prev_selected_hunk = self.selected_hunk; + let prev_hovered_hunk = self.hovered_hunk; self.filename = parsed.filename; self.old_content = parsed.old_content; self.new_content = parsed.new_content; @@ -613,22 +629,22 @@ impl DiffViewState { self.hunk_line_offsets = parsed.hunk_line_offsets; self.sections = parsed.sections; self.file_exists_on_disk = parsed.file_exists_on_disk; - self.selected_revert_hunk = if same_file { - if let Some(anchor_line) = self.preferred_revert_hunk_line { + self.selected_hunk = if same_file { + if let Some(anchor_line) = self.preferred_selected_hunk_line { self.hunk_starts .iter() .enumerate() .min_by_key(|(_, line_idx)| (**line_idx).abs_diff(anchor_line)) .map(|(idx, _)| idx) } else { - prev_selected_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + prev_selected_hunk.filter(|&i| i < self.hunk_starts.len()) } } else { None }; - self.preferred_revert_hunk_line = None; - self.hovered_revert_hunk = if same_file { - prev_hovered_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + self.preferred_selected_hunk_line = None; + self.hovered_hunk = if same_file { + prev_hovered_hunk.filter(|&i| i < self.hunk_starts.len()) } else { None }; @@ -647,8 +663,8 @@ impl DiffViewState { pub fn load(&mut self, filename: &str, old: &str, new: &str) { // Preserve scroll position when reloading the same file (e.g. periodic refresh) let same_file = self.filename == filename; - let prev_selected_revert_hunk = self.selected_revert_hunk; - let prev_hovered_revert_hunk = self.hovered_revert_hunk; + let prev_selected_hunk = self.selected_hunk; + let prev_hovered_hunk = self.hovered_hunk; self.filename = filename.to_string(); self.old_content = old.to_string(); self.new_content = new.to_string(); @@ -665,13 +681,13 @@ impl DiffViewState { self.selection = None; self.clear_search(); } - self.selected_revert_hunk = if same_file { - prev_selected_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + self.selected_hunk = if same_file { + prev_selected_hunk.filter(|&i| i < self.hunk_starts.len()) } else { None }; - self.hovered_revert_hunk = if same_file { - prev_hovered_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + self.hovered_hunk = if same_file { + prev_hovered_hunk.filter(|&i| i < self.hunk_starts.len()) } else { None }; @@ -727,8 +743,8 @@ impl DiffViewState { let file_count = file_diffs.len(); let new_filename = format!("{} ({} files)", filename, file_count); let same_file = self.filename == new_filename; - let prev_selected_revert_hunk = self.selected_revert_hunk; - let prev_hovered_revert_hunk = self.hovered_revert_hunk; + let prev_selected_hunk = self.selected_hunk; + let prev_hovered_hunk = self.hovered_hunk; self.filename = new_filename; self.old_content = String::new(); self.new_content = String::new(); @@ -740,13 +756,13 @@ impl DiffViewState { self.selection = None; self.clear_search(); } - self.selected_revert_hunk = if same_file { - prev_selected_revert_hunk + self.selected_hunk = if same_file { + prev_selected_hunk } else { None }; - self.hovered_revert_hunk = if same_file { - prev_hovered_revert_hunk + self.hovered_hunk = if same_file { + prev_hovered_hunk } else { None }; @@ -797,14 +813,14 @@ impl DiffViewState { } self.hunk_starts = super::diff_algo::find_hunk_starts(&self.lines); - self.selected_revert_hunk = if same_file { - self.selected_revert_hunk + self.selected_hunk = if same_file { + self.selected_hunk .filter(|&i| i < self.hunk_starts.len()) } else { None }; - self.hovered_revert_hunk = if same_file { - self.hovered_revert_hunk + self.hovered_hunk = if same_file { + self.hovered_hunk .filter(|&i| i < self.hunk_starts.len()) } else { None @@ -838,7 +854,7 @@ impl DiffViewState { pub fn next_hunk(&mut self) { if let Some(next) = self.hunk_starts.iter().find(|&&h| h > self.scroll_offset) { self.scroll_offset = *next; - self.selected_revert_hunk = self.hunk_index_for_start_line(*next); + self.selected_hunk = self.hunk_index_for_start_line(*next); } } @@ -850,7 +866,7 @@ impl DiffViewState { .find(|&&h| h < self.scroll_offset) { self.scroll_offset = *prev; - self.selected_revert_hunk = self.hunk_index_for_start_line(*prev); + self.selected_hunk = self.hunk_index_for_start_line(*prev); } } @@ -938,9 +954,9 @@ impl DiffViewState { Some((start, end.saturating_sub(1))) } - /// Return the line index where the sticky revert marker should render for this hunk, + /// Return the line index where the sticky hunk marker should render for this hunk, /// or `None` when no part of the hunk is visible in the current viewport. - pub fn sticky_revert_marker_line( + pub fn sticky_hunk_marker_line( &self, block_idx: usize, visible_height: usize, @@ -981,34 +997,34 @@ impl DiffViewState { offsets } - /// Cycle to the next revertable hunk marker. - pub fn select_next_revert_hunk(&mut self) { + /// Cycle to the next hunk marker. + pub fn select_next_hunk(&mut self) { if self.hunk_starts.is_empty() { - self.selected_revert_hunk = None; + self.selected_hunk = None; return; } - let next = match self.selected_revert_hunk { + let next = match self.selected_hunk { Some(i) => (i + 1) % self.hunk_starts.len(), None => 0, }; - self.selected_revert_hunk = Some(next); + self.selected_hunk = Some(next); } - /// Cycle to the previous revertable hunk marker. - pub fn select_prev_revert_hunk(&mut self) { + /// Cycle to the previous hunk marker. + pub fn select_prev_hunk(&mut self) { if self.hunk_starts.is_empty() { - self.selected_revert_hunk = None; + self.selected_hunk = None; return; } - let prev = match self.selected_revert_hunk { + let prev = match self.selected_hunk { Some(0) | None => self.hunk_starts.len() - 1, Some(i) => i.saturating_sub(1), }; - self.selected_revert_hunk = Some(prev); + self.selected_hunk = Some(prev); } - /// Return the sticky revert-marker hunk rendered at `line_idx` for the current viewport. - pub fn sticky_revert_hunk_at_line( + /// Return the sticky hunk marker rendered at `line_idx` for the current viewport. + pub fn sticky_hunk_at_line( &self, line_idx: usize, visible_height: usize, @@ -1017,7 +1033,7 @@ impl DiffViewState { return None; } self.hunk_starts.iter().enumerate().find_map(|(idx, _)| { - (self.sticky_revert_marker_line(idx, visible_height) == Some(line_idx)).then_some(idx) + (self.sticky_hunk_marker_line(idx, visible_height) == Some(line_idx)).then_some(idx) }) } @@ -1039,7 +1055,7 @@ pub fn render_diff( area: Rect, state: &DiffViewState, theme: &Theme, - marker_cfg: &RevertHunkMarkerStyle, + marker_cfg: &HunkMarkerStyle, focused: bool, diff_loading: bool, show_revert_markers: bool, @@ -1081,13 +1097,13 @@ pub fn render_diff( .borders(Borders::ALL) .border_style(border_style); - // Bottom-right footnote: revert-hunk undo indicator. Only shown when + // Bottom-right footnote: hunk-action undo indicator. Only shown when // there's something to undo; the denominator is the peak stack depth // since it last drained, so a streak reads `1/1`, `2/2`, ... and undos // walk it back down to `1/3` etc. - let undo_n = state.revert_undo_stack.len(); + let undo_n = state.hunk_action_undo_stack.len(); if undo_n > 0 { - let undo_m = state.revert_undo_high_water.max(undo_n); + let undo_m = state.hunk_action_undo_high_water.max(undo_n); block = block.title_bottom( Line::from(vec![ Span::styled(" ", Style::default().fg(theme.text_dimmed)), @@ -1098,7 +1114,7 @@ pub fn render_diff( .add_modifier(Modifier::BOLD), ), Span::styled( - format!(" undo revert ({}/{}) ", undo_n, undo_m), + format!(" undo hunk action ({}/{}) ", undo_n, undo_m), Style::default().fg(theme.text_dimmed), ), ]) @@ -1424,18 +1440,18 @@ pub fn render_diff( ); } - // Divider or revert marker (first visual row of a hunk only). + // Divider or hunk marker (first visual row of a hunk only). let marker_hunk_idx = if show_revert_markers && !state.wrap && chunk_idx == 0 { - state.sticky_revert_hunk_at_line(line_idx, visible_height) + state.sticky_hunk_at_line(line_idx, visible_height) } else { None }; let show_marker = marker_hunk_idx.is_some(); let marker_is_hovered = show_marker && marker_hunk_idx.is_some() - && marker_hunk_idx == state.hovered_revert_hunk; - let (divider_char, marker_style) = if show_marker { - let is_selected = marker_hunk_idx == state.selected_revert_hunk; + && marker_hunk_idx == state.hovered_hunk; + let (divider_chars, marker_style) = if show_marker { + let is_selected = marker_hunk_idx == state.selected_hunk; // Hover wins over selection so the hover state is always // visible — even on a hunk that's currently selected. let fg = if marker_is_hovered { @@ -1449,15 +1465,16 @@ pub fn render_diff( if marker_cfg.bold { style = style.add_modifier(Modifier::BOLD); } - (marker_cfg.icon.as_str(), style) + let chars = if is_selected { "󱐌" } else { marker_cfg.icon.as_str() }; + (chars, style) } else { ("│", divider_style) }; buf_write_str(buf, div_x, y, " ", divider_style, divider_width); - buf_write_str(buf, div_x, y, divider_char, marker_style, divider_width); + buf_write_str(buf, div_x, y, divider_chars, marker_style, divider_width); if show_marker && marker_hunk_idx.is_some() - && marker_hunk_idx == state.hovered_revert_hunk + && marker_hunk_idx == state.hovered_hunk { hover_tooltip_y = Some(y); } @@ -1536,18 +1553,18 @@ pub fn render_diff( state.horizontal_scroll, ); - // Divider or revert marker. + // Divider or hunk marker. let marker_hunk_idx = if show_revert_markers && !state.wrap { - state.sticky_revert_hunk_at_line(line_idx, visible_height) + state.sticky_hunk_at_line(line_idx, visible_height) } else { None }; let show_marker = marker_hunk_idx.is_some(); let marker_is_hovered = show_marker && marker_hunk_idx.is_some() - && marker_hunk_idx == state.hovered_revert_hunk; - let (divider_char, marker_style) = if show_marker { - let is_selected = marker_hunk_idx == state.selected_revert_hunk; + && marker_hunk_idx == state.hovered_hunk; + let (divider_chars, marker_style) = if show_marker { + let is_selected = marker_hunk_idx == state.selected_hunk; let fg = if marker_is_hovered { marker_cfg.hover_color.unwrap_or(theme.accent_secondary) } else if is_selected { @@ -1559,15 +1576,16 @@ pub fn render_diff( if marker_cfg.bold { style = style.add_modifier(Modifier::BOLD); } - (marker_cfg.icon.as_str(), style) + let chars = if is_selected { "󱐌" } else { marker_cfg.icon.as_str() }; + (chars, style) } else { ("│", divider_style) }; buf_write_str(buf, div_x, y, " ", divider_style, divider_width); - buf_write_str(buf, div_x, y, divider_char, marker_style, divider_width); + buf_write_str(buf, div_x, y, divider_chars, marker_style, divider_width); if show_marker && marker_hunk_idx.is_some() - && marker_hunk_idx == state.hovered_revert_hunk + && marker_hunk_idx == state.hovered_hunk { hover_tooltip_y = Some(y); } @@ -1616,9 +1634,8 @@ pub fn render_diff( } if let Some(y) = hover_tooltip_y { - let show_key = state.hovered_revert_hunk.is_some() - && state.hovered_revert_hunk == state.selected_revert_hunk; - render_revert_tooltip( + let show_key = state.hovered_hunk.is_some() && state.hovered_hunk == state.selected_hunk; + render_hunk_action_tooltip( buf, div_x + divider_width, y, @@ -1630,7 +1647,7 @@ pub fn render_diff( } } -fn render_revert_tooltip( +fn render_hunk_action_tooltip( buf: &mut Buffer, x: u16, y: u16, @@ -1647,11 +1664,13 @@ fn render_revert_tooltip( let parts: Vec<(&str, Style)> = if show_key { vec![ (" ", tip_style), - ("enter", key_style), - (" Revert hunk ", tip_style), + ("a", key_style), + (" stage ", tip_style), + ("r", key_style), + (" revert ", tip_style), ] } else { - vec![(" Revert hunk ", tip_style)] + vec![(" Stage/revert hunk ", tip_style)] }; let buf_area = buf.area(); From bb2f672551de7784fc047c353d8d4f22a9c6772e Mon Sep 17 00:00:00 2001 From: felipe Date: Fri, 15 May 2026 21:12:43 -0300 Subject: [PATCH 18/18] feat(gui): enhance hunk mode entry and update status bar hints --- src/gui/mod.rs | 16 ++++++++++++++-- src/gui/views.rs | 5 +---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index edf39a5..ad49211 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1645,8 +1645,8 @@ impl Gui { return Ok(()); } - // Enter hunk mode - if key.code == KeyCode::Char('H') && self.context_mgr.active() == ContextId::Files { + // Enter hunk mode from the files panel before diff focus. + if key.code == KeyCode::Char('H') && self.context_mgr.active() == ContextId::Files && !self.diff_focused { if !self.diff_view.is_empty() { self.diff_focused = true; } @@ -1993,6 +1993,18 @@ impl Gui { } } + if key.code == KeyCode::Char('H') + && self.context_mgr.active() == ContextId::Files + && !self.diff_view.hunk_mode + { + self.diff_view.hunk_mode = true; + if self.diff_view.selected_hunk.is_none() && !self.diff_view.hunk_starts.is_empty() { + self.diff_view.select_next_hunk(); + self.center_selected_hunk(); + } + return Ok(()); + } + if matches_key(key, &keybindings.universal.next_revert_block) { if self.context_mgr.active() == ContextId::Files && self.diff_view.hunk_mode { self.diff_view.select_next_hunk(); diff --git a/src/gui/views.rs b/src/gui/views.rs index 51c848e..c334a35 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -1386,9 +1386,6 @@ fn render_status_bar( if diff_focused && !diff_view.is_empty() { // Diff-focused hint set: only the diff-relevant keys, kept tight. - // Revert-related keys are grouped together at the front so users see - // enter right next to its cycle keys. enter itself only appears when a - // hunk is actually selected (pressing it otherwise is a no-op). if ctx_mgr.active() == ContextId::Files { let has_selection = diff_view.selected_hunk.is_some(); let has_undo = !diff_view.hunk_action_undo_stack.is_empty(); @@ -1449,7 +1446,7 @@ fn render_status_bar( } if !diff_view.is_empty() { hints.push(("J/K", "scroll diff")); - hints.push(("H", "hunk mode")); + hints.push(("H", "enter hunk mode")); } }