From 57e118d1d6caacf41310c39053f25c5ad1af6b10 Mon Sep 17 00:00:00 2001 From: felipe cavalcante miranda Date: Thu, 30 Apr 2026 14:24:21 +0800 Subject: [PATCH 1/9] 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 8226688..95b28d5 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(()) @@ -3267,6 +3289,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 { @@ -3455,6 +3479,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() }, @@ -4319,6 +4346,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, @@ -4653,6 +4684,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, @@ -4950,6 +4985,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 955b1b6..b724e93 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, } } } @@ -532,6 +544,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; @@ -540,6 +553,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); @@ -555,6 +573,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(); @@ -571,6 +590,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 { @@ -623,6 +647,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(); @@ -634,6 +659,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(); @@ -683,6 +713,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 @@ -734,6 +770,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 @@ -751,6 +837,7 @@ pub fn render_diff( theme: &Theme, focused: bool, diff_loading: bool, + show_revert_markers: bool, ) { let border_style = if focused { theme.active_border @@ -797,7 +884,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; @@ -1060,8 +1147,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); @@ -1106,8 +1214,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 e595c08063d9b87386766859a573a6b17de48b9d Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 30 Apr 2026 15:02:20 +0800 Subject: [PATCH 2/9] 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 95b28d5..62475a0 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -4333,6 +4333,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 @@ -4985,35 +4994,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 b724e93..3541c24 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, } } } @@ -1044,6 +1047,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 { @@ -1152,9 +1157,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) @@ -1170,6 +1179,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); @@ -1217,9 +1232,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) @@ -1235,6 +1254,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); @@ -1263,6 +1288,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 f6102a01495091db59579b85adf3bfd84ae44cd3 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 30 Apr 2026 15:05:10 +0800 Subject: [PATCH 3/9] 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 62475a0..ce0a3cb 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(()) @@ -3291,6 +3303,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 { @@ -5093,6 +5106,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 3541c24..68ce0ae 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -1162,18 +1162,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) }; @@ -1237,18 +1240,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) }; @@ -1290,12 +1294,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); @@ -1304,11 +1310,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 381270d428fd2d52097a0b0b3ca3d8fcf2c40ffd Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 30 Apr 2026 19:28:32 +0800 Subject: [PATCH 4/9] 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 ce0a3cb..ccd25e3 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 68ce0ae..a3a3919 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -1313,7 +1313,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 58df517f184ea6f4ae1a35dc322c731a011c1727 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 30 Apr 2026 20:02:09 +0800 Subject: [PATCH 5/9] 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 ccd25e3..bbdccc6 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -5079,13 +5079,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 a3a3919..7e07870 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -548,6 +548,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; @@ -561,6 +562,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); @@ -577,6 +583,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(); @@ -598,6 +605,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 { @@ -651,6 +663,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(); @@ -667,6 +680,11 @@ impl DiffViewState { } else { None }; + self.hovered_revert_hunk = if same_file { + prev_hovered_revert_hunk + } else { + None + }; self.hunk_line_offsets = Vec::new(); @@ -722,6 +740,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 @@ -797,6 +821,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 f9183386dda9a2417c482278d21a36ee0d5b5a21 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Thu, 30 Apr 2026 21:10:50 +0800 Subject: [PATCH 6/9] 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 bbdccc6..e9cb23e 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(';') { @@ -3306,6 +3320,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 { @@ -3497,6 +3512,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() }, @@ -5092,14 +5119,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 7e07870..9d1a416 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, } } } @@ -441,7 +464,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 { @@ -461,7 +490,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 { @@ -716,14 +750,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 @@ -773,11 +805,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; } } @@ -833,9 +861,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 @@ -912,7 +938,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)) @@ -962,11 +991,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); @@ -1111,10 +1165,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; } @@ -1223,21 +1293,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). @@ -1278,17 +1377,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; @@ -1301,7 +1423,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), @@ -1318,7 +1441,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 = @@ -1354,11 +1484,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), @@ -1375,7 +1513,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; } @@ -1384,15 +1529,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) @@ -1479,7 +1636,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; @@ -1531,7 +1695,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(); @@ -1690,7 +1858,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() @@ -1733,7 +1904,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) @@ -1786,12 +1959,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; @@ -1838,7 +2006,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); @@ -1848,17 +2018,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, @@ -2020,7 +2195,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 06e49ad17bd666535d49c408b9b2c76dc440d80b Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 17 May 2026 00:45:21 +0800 Subject: [PATCH 7/9] fix: fixed for wrap mode (revert shows). --- README.md | 9 -- TODOS.md | 2 + src/gui/mod.rs | 21 +++-- src/pager/side_by_side.rs | 178 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 184 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 21bb39d..785404e 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,15 @@ A faster, memory-safe, more ergonomic slopfork of lazygit (🦀 rust btw). -1 - This is mostly a "for me" tool — built for my own workflow. Not saying you shouldn't use it, but don't expect it to be a community project. But hey, it works for me! **Why fork?** PRs were sitting too long, or the upstream direction didn't match how I wanted to work. -2 -2.1 - The goal: everything lazygit does, but faster and with opinions I actually agree with. (I can't promise backwards-compat w/ lazygit's config since it'll eventually drift w/ my own opinions, but I made sure to do that) ![demo1](https://raw.githubusercontent.com/Blankeos/lazygitrs/main/_docs/demo1.webp) ![demo2](https://raw.githubusercontent.com/Blankeos/lazygitrs/main/_docs/demo2.webp) -3 - ### Install > Make sure you have: @@ -127,5 +120,3 @@ Summary MIT Feel free to fork and give it your own spin. -last -1 diff --git a/TODOS.md b/TODOS.md index 1d2e7ed..9b5f29b 100644 --- a/TODOS.md +++ b/TODOS.md @@ -237,3 +237,5 @@ - [ ] While 'generating commit messages' or 'pushing' the UI is blocked. Let's not block it. Let us move around a bit. The question is.. where to put this indicator. - [ ] Use 'check' and 'checkmark' for staged and unstaged (not like lazygit), more like zed. + +- [ ] 123 diff --git a/src/gui/mod.rs b/src/gui/mod.rs index e9cb23e..942ebf3 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -5046,7 +5046,7 @@ impl Gui { if self.context_mgr.active() != ContextId::Files { return None; } - if self.diff_view.wrap || self.diff_view.is_empty() { + if self.diff_view.is_empty() { return None; } if !rect_contains(panel_rect, col, row) { @@ -5056,7 +5056,10 @@ impl Gui { if col != divider_x { return None; } - let line_idx = self.diff_view.line_index_at_row(row, layout)?; + let (line_idx, chunk_idx) = self.diff_view.line_chunk_at_row(row, layout)?; + if chunk_idx != 0 { + return None; + } self.diff_view.hunk_index_for_start_line(line_idx) } @@ -5179,16 +5182,18 @@ 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 { + // hunk's first wrapped chunk, so only that visual row needs to be visible. + if self + .diff_view + .visual_offset_of_line(line_idx, &pl) + .is_some() + { 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); + self.diff_view.scroll_offset = + self.diff_view.scroll_offset_to_center_line(line_idx, &pl); } /// Approximate visible height of the active sidebar panel (inner area minus borders). diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index 9d1a416..e0f3850 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -825,18 +825,111 @@ 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 { + /// Map a terminal row within the diff panel inner area to `(line_idx, chunk_idx)`. + /// `chunk_idx` is the wrapped-chunk position within the line (0 = first visual + /// row). Always 0 when wrapping is off. + pub fn line_chunk_at_row( + &self, + row: u16, + layout: &DiffPanelLayout, + ) -> Option<(usize, usize)> { 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 + let target_off = (row - layout.inner_y) as usize; + + if !self.wrap { + let idx = self.scroll_offset + target_off; + return if idx < self.lines.len() { + Some((idx, 0)) + } else { + None + }; + } + + let panel_width = layout + .old_content_end_x + .saturating_sub(layout.old_content_x) as usize; + let right_content_width = layout + .new_content_end_x + .saturating_sub(layout.new_content_x) as usize; + + let mut acc = 0usize; + for (offset, diff_line) in self.lines[self.scroll_offset..].iter().enumerate() { + let line_idx = self.scroll_offset + offset; + let num_rows = line_visual_height(diff_line, panel_width, right_content_width); + if target_off < acc + num_rows { + return Some((line_idx, target_off - acc)); + } + acc += num_rows; + } + None + } + + /// Visual row offset (from `layout.inner_y`) of `target_line`'s first wrapped + /// chunk. Returns `None` if the target is above `scroll_offset` or past the + /// visible window. + pub fn visual_offset_of_line( + &self, + target_line: usize, + layout: &DiffPanelLayout, + ) -> Option { + if target_line < self.scroll_offset || target_line >= self.lines.len() { + return None; + } + let visible = layout.inner_end_y.saturating_sub(layout.inner_y) as usize; + if !self.wrap { + let off = target_line - self.scroll_offset; + return if off < visible { Some(off) } else { None }; + } + let panel_width = layout + .old_content_end_x + .saturating_sub(layout.old_content_x) as usize; + let right_content_width = layout + .new_content_end_x + .saturating_sub(layout.new_content_x) as usize; + let mut acc = 0usize; + for line_idx in self.scroll_offset..target_line { + acc += line_visual_height(&self.lines[line_idx], panel_width, right_content_width); + if acc >= visible { + return None; + } + } + Some(acc) + } + + /// Pick a `scroll_offset` that places `target_line` roughly at the visual + /// middle of the diff panel. Walks back from the target accumulating + /// per-line wrap heights so wrap mode lands the target in view. + pub fn scroll_offset_to_center_line( + &self, + target_line: usize, + layout: &DiffPanelLayout, + ) -> usize { + let visible = layout.inner_end_y.saturating_sub(layout.inner_y) as usize; + let max_start = self.lines.len().saturating_sub(visible.max(1)); + if !self.wrap { + return target_line.saturating_sub(visible / 2).min(max_start); + } + let panel_width = layout + .old_content_end_x + .saturating_sub(layout.old_content_x) as usize; + let right_content_width = layout + .new_content_end_x + .saturating_sub(layout.new_content_x) as usize; + let half = visible / 2; + let mut scroll = target_line.min(self.lines.len()); + let mut acc = 0usize; + while scroll > 0 { + let prev = scroll - 1; + let h = line_visual_height(&self.lines[prev], panel_width, right_content_width); + if acc + h > half { + break; + } + acc += h; + scroll = prev; } + scroll.min(max_start) } /// Return true when the given line index is the first line of a diff hunk. @@ -1341,7 +1434,6 @@ 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 { @@ -1684,6 +1776,74 @@ fn unicode_display_width(ch: char) -> usize { /// Split a list of styled spans into visual rows of at most `width` display columns each. /// Used by wrap mode to soft-wrap long diff lines. +/// Count how many visual rows a single line of `text` would occupy when wrapped +/// at `width` display columns. Mirrors the row count produced by `wrap_spans`, +/// without building styled spans. +fn wrap_row_count(text: &str, width: usize) -> usize { + if width == 0 { + return 1; + } + let widths: Vec = text + .chars() + .filter_map(|ch| { + let w = unicode_display_width(ch); + if w > 0 { Some(w) } else { None } + }) + .collect(); + if widths.is_empty() { + return 1; + } + let mut rows = 0usize; + let mut i = 0usize; + while i < widths.len() { + let mut col_w = 0usize; + let mut end = i; + while end < widths.len() { + let w = widths[end]; + if col_w + w > width { + break; + } + col_w += w; + end += 1; + } + if end == i { + end = i + 1; + } + i = end; + rows += 1; + } + rows +} + +/// Visual height (in panel rows) of a diff line, matching the renderer's +/// `num_rows` calculation in side-by-side wrap mode. +fn line_visual_height(diff_line: &DiffLine, panel_width: usize, right_content_width: usize) -> usize { + if diff_line.file_header.is_some() { + return 1; + } + let is_insert = diff_line.change_type == ChangeType::Insert; + let is_delete = diff_line.change_type == ChangeType::Delete; + let left_rows = if is_insert { + 0 + } else { + diff_line + .old_line + .as_ref() + .map(|(_, t)| wrap_row_count(t, panel_width)) + .unwrap_or(1) + }; + let right_rows = if is_delete { + 0 + } else { + diff_line + .new_line + .as_ref() + .map(|(_, t)| wrap_row_count(t, right_content_width)) + .unwrap_or(1) + }; + left_rows.max(right_rows).max(1) +} + fn wrap_spans<'a>(spans: &[Span<'a>], width: usize) -> Vec>> { if width == 0 { return vec![vec![]]; From dbd694121f5a4e7b66073860d3c411e7f83c656f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 17 May 2026 01:04:11 +0800 Subject: [PATCH 8/9] feat: replace dedicated revert keybindings with {/} hunk cycling and context menu. Remove the `prevRevertBlock`/`nextRevertBlock` keybindings (/) and replace them with {/} which now cycle revert hunks (with wrap) in Files context. Enter opens a hunk context menu ("Cancel" / "Revert hunk") instead of reverting immediately, preventing accidental reverts. Drop the now-unused `center_selected_revert_block` centering logic and related `visual_offset_of_line` / `scroll_offset_to_center_line` helpers. --- src/config/keybindings.rs | 6 -- src/gui/mod.rs | 119 +++++++++++++++++++------------------- src/gui/views.rs | 7 ++- src/pager/side_by_side.rs | 95 +++++++----------------------- 4 files changed, 85 insertions(+), 142 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 5aa9fb3..9d47ce2 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -110,10 +110,6 @@ pub struct UniversalKeybinding { pub prev_screen_mode: String, #[serde(rename = "createPatchOptionsMenu")] pub create_patch_options_menu: String, - #[serde(rename = "prevRevertBlock")] - pub prev_revert_block: String, - #[serde(rename = "nextRevertBlock")] - pub next_revert_block: String, #[serde(rename = "revertBlock")] pub revert_block: String, #[serde(rename = "undoRevertBlock")] @@ -165,8 +161,6 @@ 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(), revert_block: "".into(), undo_revert_block: "u".into(), } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 942ebf3..68970fa 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1982,20 +1982,6 @@ 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(()); - } if matches_key(key, &keybindings.universal.revert_block) { if self.context_mgr.active() == ContextId::Files { let hunk_idx = self @@ -2004,13 +1990,7 @@ impl Gui { .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, - }; - } + self.show_hunk_context_menu(hunk_idx); } } return Ok(()); @@ -2094,12 +2074,23 @@ impl Gui { KeyCode::Char('l') | KeyCode::Right => { self.diff_view.scroll_right(4); } - // { and } jump between hunks + // { and } jump between hunks. In Files context they also select + // the hunk as the revert target so the marker glyph turns + // accent-coloured; the scroll motion stays the same as plain + // hunk navigation (always jumps, even if already in viewport). KeyCode::Char('}') => { - self.diff_view.next_hunk(); + if self.context_mgr.active() == ContextId::Files { + self.diff_view.cycle_next_revert_hunk(); + } else { + self.diff_view.next_hunk(); + } } KeyCode::Char('{') => { - self.diff_view.prev_hunk(); + if self.context_mgr.active() == ContextId::Files { + self.diff_view.cycle_prev_revert_hunk(); + } else { + self.diff_view.prev_hunk(); + } } // [ and ] toggle old-only / new-only view KeyCode::Char(']') => { @@ -3317,9 +3308,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() }, - HelpEntry { key: kb.universal.revert_block.clone(), description: "Revert selected block".into() }, + HelpEntry { key: "{/}".into(), description: "Cycle prev/next revert block in diff".into() }, + HelpEntry { key: kb.universal.revert_block.clone(), description: "Open hunk menu (revert selected block)".into() }, HelpEntry { key: kb.universal.undo_revert_block.clone(), description: "Undo last revert (session)".into() }, ], }, @@ -3501,7 +3491,7 @@ 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: "{/}".into(), description: "Cycle prev / next hunk (selects revert block in Files)".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() }, @@ -3509,8 +3499,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: "Cycle next / previous revert block (Files)".into() }, - HelpEntry { key: "".into(), description: "Revert selected block (Files)".into() }, + HelpEntry { key: "".into(), description: "Open hunk menu on selected block (Files)".into() }, HelpEntry { key: "click 󰧛".into(), description: "Click revert icon to revert that block".into() }, HelpEntry { key: "u".into(), @@ -5087,6 +5076,46 @@ impl Gui { true } + /// Open the hunk action menu (shown when Enter is pressed on a selected + /// or hovered revert hunk). Cancel is focused first so an accidental + /// Enter doesn't revert anything. + fn show_hunk_context_menu(&mut self, hunk_idx: usize) { + let items = vec![ + popup::MenuItem { + label: "Cancel".to_string(), + description: String::new(), + key: None, + // No-op: execute_menu_action already drops the menu popup + // before invoking the action, so returning Ok leaves the + // menu closed. Esc also closes the menu via the universal + // menu Esc handler. + action: Some(Box::new(|_gui| Ok(()))), + }, + popup::MenuItem { + label: "Revert hunk".to_string(), + description: String::new(), + key: None, + action: Some(Box::new(move |gui| { + if let Err(err) = gui.revert_selected_file_hunk(hunk_idx) { + gui.popup = PopupState::Message { + title: "Revert block failed".to_string(), + message: format!("{}", err), + kind: MessageKind::Error, + }; + } + Ok(()) + })), + }, + ]; + + self.popup = PopupState::Menu { + title: "Hunk".to_string(), + items, + selected: 0, + loading_index: None, + }; + } + fn revert_selected_file_hunk(&mut self, hunk_idx: usize) -> Result<()> { let Some(file_idx) = self.selected_file_index() else { return Ok(()); @@ -5166,36 +5195,6 @@ impl Gui { 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; - } - - // Already in viewport? Don't scroll. The marker glyph sits on the - // hunk's first wrapped chunk, so only that visual row needs to be visible. - if self - .diff_view - .visual_offset_of_line(line_idx, &pl) - .is_some() - { - return; - } - - self.diff_view.scroll_offset = - self.diff_view.scroll_offset_to_center_line(line_idx, &pl); - } - /// 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/views.rs b/src/gui/views.rs index a20cc36..e05de86 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -1373,17 +1373,18 @@ fn render_status_bar( let has_undo = !diff_view.revert_undo_stack.is_empty(); let mut idx = 0; if has_selection { - hints.insert(idx, ("enter", "revert hunk")); + hints.insert(idx, ("enter", "hunk menu")); emphasized.push("enter"); idx += 1; } - hints.insert(idx, ("c-j/c-k", "cycle revert")); + hints.insert(idx, ("{/}", "cycle hunks")); idx += 1; if has_undo { hints.insert(idx, ("u", "undo revert")); } + } else { + hints.push(("{/}", "prev/next hunk")); } - hints.push(("{/}", "prev/next hunk")); hints.push(("[/]", "side view")); } else { // Sidebar-focused: context-specific hints. diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index e0f3850..d940d19 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -866,72 +866,6 @@ impl DiffViewState { None } - /// Visual row offset (from `layout.inner_y`) of `target_line`'s first wrapped - /// chunk. Returns `None` if the target is above `scroll_offset` or past the - /// visible window. - pub fn visual_offset_of_line( - &self, - target_line: usize, - layout: &DiffPanelLayout, - ) -> Option { - if target_line < self.scroll_offset || target_line >= self.lines.len() { - return None; - } - let visible = layout.inner_end_y.saturating_sub(layout.inner_y) as usize; - if !self.wrap { - let off = target_line - self.scroll_offset; - return if off < visible { Some(off) } else { None }; - } - let panel_width = layout - .old_content_end_x - .saturating_sub(layout.old_content_x) as usize; - let right_content_width = layout - .new_content_end_x - .saturating_sub(layout.new_content_x) as usize; - let mut acc = 0usize; - for line_idx in self.scroll_offset..target_line { - acc += line_visual_height(&self.lines[line_idx], panel_width, right_content_width); - if acc >= visible { - return None; - } - } - Some(acc) - } - - /// Pick a `scroll_offset` that places `target_line` roughly at the visual - /// middle of the diff panel. Walks back from the target accumulating - /// per-line wrap heights so wrap mode lands the target in view. - pub fn scroll_offset_to_center_line( - &self, - target_line: usize, - layout: &DiffPanelLayout, - ) -> usize { - let visible = layout.inner_end_y.saturating_sub(layout.inner_y) as usize; - let max_start = self.lines.len().saturating_sub(visible.max(1)); - if !self.wrap { - return target_line.saturating_sub(visible / 2).min(max_start); - } - let panel_width = layout - .old_content_end_x - .saturating_sub(layout.old_content_x) as usize; - let right_content_width = layout - .new_content_end_x - .saturating_sub(layout.new_content_x) as usize; - let half = visible / 2; - let mut scroll = target_line.min(self.lines.len()); - let mut acc = 0usize; - while scroll > 0 { - let prev = scroll - 1; - let h = line_visual_height(&self.lines[prev], panel_width, right_content_width); - if acc + h > half { - break; - } - acc += h; - scroll = prev; - } - scroll.min(max_start) - } - /// 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() @@ -1004,30 +938,45 @@ impl DiffViewState { offsets } - /// Cycle to the next revertable hunk marker. - pub fn select_next_revert_hunk(&mut self) { + /// Jump to the next hunk and select it as the revert target. Always + /// scrolls to the hunk's start line — same motion as `next_hunk` — + /// even if it's already in the viewport. Wraps to the first hunk + /// after the last. + pub fn cycle_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, + None => self + .hunk_starts + .iter() + .position(|&h| h > self.scroll_offset) + .unwrap_or(0), }; self.selected_revert_hunk = Some(next); + self.scroll_offset = self.hunk_starts[next]; } - /// Cycle to the previous revertable hunk marker. - pub fn select_prev_revert_hunk(&mut self) { + /// Jump to the previous hunk and select it as the revert target. + /// Wraps to the last hunk before the first. + pub fn cycle_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), + Some(0) => self.hunk_starts.len() - 1, + Some(i) => i - 1, + None => self + .hunk_starts + .iter() + .rposition(|&h| h < self.scroll_offset) + .unwrap_or(self.hunk_starts.len() - 1), }; self.selected_revert_hunk = Some(prev); + self.scroll_offset = self.hunk_starts[prev]; } /// Get the highlighters for a given section index. From 5fa155388edac502fde71859d532c0d0f94ffb19 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sun, 17 May 2026 01:16:08 +0800 Subject: [PATCH 9/9] chore: cleanup before merge. --- README.md | 4 +++- TODOS.md | 2 -- npm/README.md | 5 +++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 785404e..a26d64c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ The goal: everything lazygit does, but faster and with opinions I actually agree ### Install > Make sure you have: -> gh +> +> - [git](https://git-scm.com) +> - [gh](https://cli.github.com) ```sh npm install -g lazygitrs # npm diff --git a/TODOS.md b/TODOS.md index 9b5f29b..1d2e7ed 100644 --- a/TODOS.md +++ b/TODOS.md @@ -237,5 +237,3 @@ - [ ] While 'generating commit messages' or 'pushing' the UI is blocked. Let's not block it. Let us move around a bit. The question is.. where to put this indicator. - [ ] Use 'check' and 'checkmark' for staged and unstaged (not like lazygit), more like zed. - -- [ ] 123 diff --git a/npm/README.md b/npm/README.md index ae0d2c2..a26d64c 100644 --- a/npm/README.md +++ b/npm/README.md @@ -13,6 +13,11 @@ The goal: everything lazygit does, but faster and with opinions I actually agree ### Install +> Make sure you have: +> +> - [git](https://git-scm.com) +> - [gh](https://cli.github.com) + ```sh npm install -g lazygitrs # npm bun install -g lazygitrs # or bun