diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index de24421..acbd84d 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -110,6 +110,14 @@ pub struct UniversalKeybinding { pub prev_screen_mode: String, #[serde(rename = "createPatchOptionsMenu")] pub create_patch_options_menu: String, + #[serde(rename = "prevRevertBlock")] + pub prev_revert_block: String, + #[serde(rename = "nextRevertBlock")] + pub next_revert_block: String, + #[serde(rename = "revertBlock")] + pub revert_block: String, + #[serde(rename = "undoRevertBlock")] + pub undo_revert_block: String, } impl Default for UniversalKeybinding { @@ -157,6 +165,10 @@ 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: "r".into(), + undo_revert_block: "u".into(), } } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 35bfede..518c364 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -10,7 +10,7 @@ use anyhow::Result; pub use app_state::AppState; pub use keybindings::KeybindingConfig; pub use theme::{Theme, ColorTheme, COLOR_THEMES}; -pub use user_config::UserConfig; +pub use user_config::{HunkMarkerConfig, UserConfig, parse_optional_color}; pub fn config_dir_candidates() -> Vec { let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); diff --git a/src/config/user_config.rs b/src/config/user_config.rs index 913d013..c4aa388 100644 --- a/src/config/user_config.rs +++ b/src/config/user_config.rs @@ -1,6 +1,7 @@ use std::path::Path; use anyhow::Result; +use ratatui::style::Color; use serde::{Deserialize, Serialize}; use super::keybindings::KeybindingConfig; @@ -91,6 +92,8 @@ pub struct GuiConfig { pub show_bottom_line: bool, #[serde(rename = "nerdFontsVersion")] pub nerd_fonts_version: String, + #[serde(rename = "revertHunkMarker")] + pub hunk_marker: HunkMarkerConfig, } impl Default for GuiConfig { @@ -106,10 +109,60 @@ impl Default for GuiConfig { show_command_log: true, show_bottom_line: true, nerd_fonts_version: "3".to_string(), + hunk_marker: HunkMarkerConfig::default(), } } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HunkMarkerConfig { + pub icon: String, + pub bold: Option, + pub color: Option, + #[serde(rename = "selectedColor")] + pub selected_color: Option, + #[serde(rename = "hoverColor")] + pub hover_color: Option, +} + +impl Default for HunkMarkerConfig { + fn default() -> Self { + Self { + icon: "".to_string(), + bold: None, + color: None, + selected_color: None, + hover_color: None, + } + } +} + +pub fn parse_optional_color(value: Option<&str>) -> Option { + let value = value?.trim(); + if value.is_empty() { + return None; + } + match value.to_lowercase().as_str() { + "default" => None, + "black" => Some(Color::Black), + "red" => Some(Color::Red), + "green" => Some(Color::Green), + "yellow" => Some(Color::Yellow), + "blue" => Some(Color::Blue), + "magenta" => Some(Color::Magenta), + "cyan" => Some(Color::Cyan), + "white" => Some(Color::White), + s if s.starts_with('#') && s.len() == 7 => { + let r = u8::from_str_radix(&s[1..3], 16).ok()?; + let g = u8::from_str_radix(&s[3..5], 16).ok()?; + let b = u8::from_str_radix(&s[5..7], 16).ok()?; + Some(Color::Rgb(r, g, b)) + } + _ => None, + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct ThemeConfig { diff --git a/src/git/staging.rs b/src/git/staging.rs index c2141bb..1f668f2 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; @@ -50,7 +50,7 @@ impl GitCommands { /// Stage a specific hunk by applying it as a patch. pub fn stage_hunk(&self, file_path: &str, hunk: &DiffHunk) -> Result<()> { - let patch = build_patch(file_path, hunk); + let patch = self.build_hunk_patch(file_path, hunk); self.git() .args(&["apply", "--cached", "--unidiff-zero", "-"]) .stdin(patch) @@ -60,7 +60,7 @@ impl GitCommands { /// Unstage a specific hunk by reverse-applying it as a patch. pub fn unstage_hunk(&self, file_path: &str, hunk: &DiffHunk) -> Result<()> { - let patch = build_patch(file_path, hunk); + let patch = self.build_hunk_patch(file_path, hunk); self.git() .args(&["apply", "--cached", "--reverse", "--unidiff-zero", "-"]) .stdin(patch) @@ -77,6 +77,175 @@ impl GitCommands { }; Ok(self.parse_diff_hunks(&diff)) } + + /// Reverse-apply only the lines of a single visual change block to the + /// working tree copy of `file_path`. `want_old` and `want_new` are the + /// inclusive old-file and new-file line-number ranges the visual block + /// covers; either may be `None` for pure insertion or pure deletion. + /// The block typically lives inside one of the `@@` hunks of + /// `unified_diff`, but may be narrower than that `@@` — visual blocks + /// can be split by 1–3 lines of context within a single `@@`. + pub fn revert_visual_block_in_worktree( + &self, + file_path: &str, + unified_diff: &str, + want_old: Option<(usize, usize)>, + want_new: Option<(usize, usize)>, + ) -> Result<()> { + let patch = build_visual_block_patch(file_path, unified_diff, want_old, want_new)?; + self.git() + .args(&["apply", "--reverse", "--unidiff-zero", "-"]) + .stdin(patch) + .run_expecting_success() + .with_context(|| format!("failed to revert hunk in {}", file_path))?; + Ok(()) + } + + pub fn build_visual_block_patch_text( + &self, + file_path: &str, + unified_diff: &str, + want_old: Option<(usize, usize)>, + want_new: Option<(usize, usize)>, + ) -> Result { + build_visual_block_patch(file_path, unified_diff, want_old, want_new) + } + + pub fn build_hunk_patch(&self, file_path: &str, hunk: &DiffHunk) -> String { + build_patch(file_path, hunk) + } + + pub fn apply_patch_text(&self, patch: String, cached: bool, reverse: bool) -> Result<()> { + let mut args = vec!["apply"]; + if cached { + args.push("--cached"); + } + if reverse { + args.push("--reverse"); + } + args.extend(["--unidiff-zero", "-"]); + self.git().args(&args).stdin(patch).run_expecting_success()?; + Ok(()) + } +} + +fn build_visual_block_patch( + file_path: &str, + unified_diff: &str, + want_old: Option<(usize, usize)>, + want_new: Option<(usize, usize)>, +) -> Result { + 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) { diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index d020eb4..4973cdf 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -515,12 +515,6 @@ fn handle_diff_exploration_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { KeyCode::Char('l') | KeyCode::Right => { gui.diff_view.scroll_right(4); } - KeyCode::Char('}') => { - gui.diff_view.next_hunk(); - } - KeyCode::Char('{') => { - gui.diff_view.prev_hunk(); - } KeyCode::Char(']') => { use crate::pager::side_by_side::DiffSideView; gui.diff_view.side_view = match gui.diff_view.side_view { @@ -712,7 +706,9 @@ fn show_diff_mode_help(gui: &mut Gui) { HelpEntry { key: "".into(), description: "Edit selector / Focus diff".into() }, HelpEntry { key: "`".into(), description: "Toggle file tree view".into() }, HelpEntry { key: "j/k".into(), description: "Navigate files / Scroll diff".into() }, - HelpEntry { key: "{/}".into(), description: "Previous / next hunk".into() }, + HelpEntry { key: "H".into(), description: "Enter hunk mode".into() }, + HelpEntry { key: "j/k".into(), description: "Cycle hunks in hunk mode".into() }, + HelpEntry { key: "esc".into(), description: "Exit hunk mode".into() }, HelpEntry { key: "[/]".into(), description: "Toggle old / new only view".into() }, HelpEntry { key: "z".into(), description: "Toggle line wrap".into() }, HelpEntry { key: "g/G".into(), description: "Go to top / bottom".into() }, diff --git a/src/gui/mod.rs b/src/gui/mod.rs index c015d91..ad49211 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}; @@ -26,7 +26,7 @@ use crate::config::keybindings::parse_key; use crate::git::{GitCommands, ModelPart, MODEL_PART_COUNT}; use crate::model::Model; use crate::model::file_tree::{build_file_tree, CommitFileTreeNode, FileTreeNode}; -use crate::pager::side_by_side::{DiffPanel, DiffPanelLayout, DiffViewState, TextSelection}; +use crate::pager::side_by_side::{DiffPanel, DiffPanelLayout, DiffViewState, HunkActionKind, TextSelection}; use self::context::{ContextId, ContextManager, SideWindow}; use self::layout::LayoutState; @@ -521,11 +521,15 @@ impl Gui { let diff_loading_show = self.diff_loading && self.diff_loading_since .map(|t| t.elapsed() >= std::time::Duration::from_millis(50)) .unwrap_or(false); + let hunk_marker_style = crate::pager::side_by_side::HunkMarkerStyle::from_config( + &self.config.user_config.gui.hunk_marker, + ); presentation::diff_mode::render( frame, &mut self.diff_mode, &mut self.diff_view, &theme, + &hunk_marker_style, self.diff_loading, diff_loading_show, ); @@ -705,6 +709,10 @@ impl Gui { } DiffPayload::Parsed(parsed) => { self.diff_view.apply_parsed(parsed); + if self.diff_view.center_selected_hunk_on_refresh { + self.center_selected_hunk(); + self.diff_view.center_selected_hunk_on_refresh = false; + } } DiffPayload::Empty => { self.diff_view = DiffViewState::new(); @@ -1637,6 +1645,19 @@ impl Gui { return Ok(()); } + // Enter hunk mode from the files panel before diff focus. + if key.code == KeyCode::Char('H') && self.context_mgr.active() == ContextId::Files && !self.diff_focused { + if !self.diff_view.is_empty() { + self.diff_focused = true; + } + self.diff_view.hunk_mode = true; + if self.diff_view.selected_hunk.is_none() && !self.diff_view.hunk_starts.is_empty() { + self.diff_view.select_next_hunk(); + self.center_selected_hunk(); + } + return Ok(()); + } + // Horizontal scroll (H/L) if matches_key(key, &keybindings.universal.scroll_left) { self.diff_view.scroll_left(4); @@ -1647,16 +1668,6 @@ impl Gui { return Ok(()); } - // Next/prev hunk with { and } - if key.code == KeyCode::Char('{') { - self.diff_view.prev_hunk(); - return Ok(()); - } - if key.code == KeyCode::Char('}') { - self.diff_view.next_hunk(); - return Ok(()); - } - // Refresh if matches_key(key, &keybindings.universal.refresh) { self.needs_refresh = true; @@ -1982,6 +1993,79 @@ impl Gui { } } + if key.code == KeyCode::Char('H') + && self.context_mgr.active() == ContextId::Files + && !self.diff_view.hunk_mode + { + self.diff_view.hunk_mode = true; + if self.diff_view.selected_hunk.is_none() && !self.diff_view.hunk_starts.is_empty() { + self.diff_view.select_next_hunk(); + self.center_selected_hunk(); + } + return Ok(()); + } + + if matches_key(key, &keybindings.universal.next_revert_block) { + if self.context_mgr.active() == ContextId::Files && self.diff_view.hunk_mode { + self.diff_view.select_next_hunk(); + self.center_selected_hunk(); + } + return Ok(()); + } + if matches_key(key, &keybindings.universal.prev_revert_block) { + if self.context_mgr.active() == ContextId::Files && self.diff_view.hunk_mode { + self.diff_view.select_prev_hunk(); + self.center_selected_hunk(); + } + return Ok(()); + } + if key.code == KeyCode::Char('a') { + if self.context_mgr.active() == ContextId::Files { + let hunk_idx = self.diff_view.selected_hunk.or(self.diff_view.hovered_hunk); + if let Some(hunk_idx) = hunk_idx { + self.diff_view.selected_hunk = Some(hunk_idx); + if let Err(err) = self.apply_selected_file_hunk_action(hunk_idx, HunkActionKind::Stage) { + self.popup = PopupState::Message { + title: "Stage block failed".to_string(), + message: format!("{}", err), + kind: MessageKind::Error, + }; + } + } + } + return Ok(()); + } + if matches_key(key, &keybindings.universal.revert_block) { + if self.context_mgr.active() == ContextId::Files { + let hunk_idx = self.diff_view.selected_hunk.or(self.diff_view.hovered_hunk); + if let Some(hunk_idx) = hunk_idx { + self.diff_view.selected_hunk = Some(hunk_idx); + if let Err(err) = self.apply_selected_file_hunk_action(hunk_idx, HunkActionKind::Revert) { + self.popup = PopupState::Message { + title: "Revert block failed".to_string(), + message: format!("{}", err), + kind: MessageKind::Error, + }; + } + } + } + return Ok(()); + } + if matches_key(key, &keybindings.universal.undo_revert_block) { + if self.context_mgr.active() == ContextId::Files + && !self.diff_view.hunk_action_undo_stack.is_empty() + { + if let Err(err) = self.undo_last_hunk_action() { + self.popup = PopupState::Message { + title: "Undo hunk action failed".to_string(), + message: format!("{}", err), + kind: MessageKind::Error, + }; + } + } + return Ok(()); + } + // Toggle command log (;) if key.code == KeyCode::Char(';') { self.show_command_log = !self.show_command_log; @@ -2018,9 +2102,14 @@ 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.hunk_mode { + self.diff_view.hunk_mode = false; + self.diff_view.selected_hunk = None; + } else if self.diff_view.selected_hunk.is_some() { + self.diff_view.selected_hunk = None; + } else if !self.diff_view.search_query.is_empty() { self.diff_view.clear_search(); } else { self.diff_focused = false; @@ -2032,10 +2121,20 @@ impl Gui { } // j/k/up/down scroll line by line KeyCode::Char('j') | KeyCode::Down => { - self.diff_view.scroll_down(1); + if self.diff_view.hunk_mode { + self.diff_view.select_next_hunk(); + self.center_selected_hunk(); + } else { + self.diff_view.scroll_down(1); + } } KeyCode::Char('k') | KeyCode::Up => { - self.diff_view.scroll_up(1); + if self.diff_view.hunk_mode { + self.diff_view.select_prev_hunk(); + self.center_selected_hunk(); + } else { + self.diff_view.scroll_up(1); + } } // h/l/left/right scroll horizontally KeyCode::Char('h') | KeyCode::Left => { @@ -2044,13 +2143,6 @@ impl Gui { KeyCode::Char('l') | KeyCode::Right => { self.diff_view.scroll_right(4); } - // { and } jump between hunks - KeyCode::Char('}') => { - self.diff_view.next_hunk(); - } - KeyCode::Char('{') => { - self.diff_view.prev_hunk(); - } // [ and ] toggle old-only / new-only view KeyCode::Char(']') => { use crate::pager::side_by_side::DiffSideView; @@ -3232,7 +3324,7 @@ impl Gui { HelpEntry { key: kb.universal.prev_screen_mode.clone(), description: "Shrink panel".into() }, HelpEntry { key: kb.universal.create_rebase_options_menu.clone(), description: "Rebase options".into() }, HelpEntry { key: kb.universal.create_patch_options_menu.clone(), description: "Patch options".into() }, - HelpEntry { key: "{/}".into(), description: "Previous/next hunk".into() }, + HelpEntry { key: "H".into(), description: "Enter hunk mode".into() }, HelpEntry { key: ";".into(), description: "Toggle command log".into() }, HelpEntry { key: "W".into(), description: "Compare / Diff mode".into() }, HelpEntry { key: "I".into(), description: "Interactive rebase onto...".into() }, @@ -3263,6 +3355,12 @@ 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: "H".into(), description: "Enter hunk mode".into() }, + HelpEntry { key: "j/k".into(), description: "Cycle hunks in hunk mode".into() }, + HelpEntry { key: "esc".into(), description: "Exit hunk mode".into() }, + HelpEntry { key: "a".into(), description: "Stage selected block".into() }, + HelpEntry { key: kb.universal.revert_block.clone(), description: "Revert selected block".into() }, + HelpEntry { key: kb.universal.undo_revert_block.clone(), description: "Undo last hunk action (session)".into() }, ], }, ContextId::Worktrees => HelpSection { @@ -3443,7 +3541,9 @@ impl Gui { entries: vec![ HelpEntry { key: "j/k".into(), description: "Scroll down / up".into() }, HelpEntry { key: "h/l".into(), description: "Scroll left / right".into() }, - HelpEntry { key: "{/}".into(), description: "Previous / next hunk".into() }, + HelpEntry { key: "H".into(), description: "Enter hunk mode".into() }, + HelpEntry { key: "j/k".into(), description: "Cycle hunks in hunk mode".into() }, + HelpEntry { key: "esc".into(), description: "Exit hunk mode".into() }, HelpEntry { key: "[".into(), description: "Toggle old-only view".into() }, HelpEntry { key: "]".into(), description: "Toggle new-only view".into() }, HelpEntry { key: "z".into(), description: "Toggle line wrap".into() }, @@ -3451,6 +3551,21 @@ impl Gui { HelpEntry { key: "PgUp/PgDn".into(), description: "Page up / down".into() }, HelpEntry { key: "/".into(), description: "Search in diff".into() }, HelpEntry { key: "n/N".into(), description: "Next / previous search match".into() }, + HelpEntry { key: "a".into(), description: "Stage selected block (Files)".into() }, + HelpEntry { key: "r".into(), description: "Revert selected block (Files)".into() }, + HelpEntry { key: "click ".into(), description: "Select block marker, then use a/r".into() }, + HelpEntry { + key: "u".into(), + description: if self.diff_view.hunk_action_undo_stack.is_empty() { + "Undo last hunk action (nothing to undo)".into() + } else { + format!( + "Undo last hunk action ({}/{})", + self.diff_view.hunk_action_undo_stack.len(), + self.diff_view.hunk_action_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() }, @@ -4302,6 +4417,14 @@ impl Gui { let main_panel = self.compute_main_panel_rect(); let pl = DiffPanelLayout::compute(main_panel, &self.diff_view); + // Track mouse hover over the hunk marker (for tooltip). + if !self.diff_mode.active { + let new_hover = self.hunk_at_position(main_panel, &pl, mouse.column, mouse.row); + if self.diff_view.hovered_hunk != new_hover { + self.diff_view.hovered_hunk = new_hover; + } + } + match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { let in_main = main_panel.x <= mouse.column @@ -4315,6 +4438,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_hunk_click(main_panel, pl, mouse.column, mouse.row) { + self.diff_focused = true; + return; + } if let Some(panel) = pl.panel_at_x(mouse.column) { self.diff_view.selection = Some(TextSelection { panel, @@ -4649,6 +4776,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_hunk_click(diff_rect, pl, col, row) { + self.diff_mode.focus = DiffModeFocus::DiffExploration; + return; + } if let Some(panel) = pl.panel_at_x(col) { self.diff_view.selection = Some(TextSelection { panel, @@ -4946,6 +5077,186 @@ impl Gui { self.compute_current_frame_layout().main_panel } + fn hunk_at_position( + &self, + panel_rect: ratatui::layout::Rect, + layout: &DiffPanelLayout, + col: u16, + row: u16, + ) -> Option { + if self.context_mgr.active() != ContextId::Files { + return None; + } + if self.diff_view.wrap || self.diff_view.is_empty() { + return None; + } + if !rect_contains(panel_rect, col, row) { + return None; + } + let divider_x = layout.divider_x()?; + if col < divider_x || col >= divider_x.saturating_add(2) { + return None; + } + let line_idx = self.diff_view.line_index_at_row(row, layout)?; + let visible_height = (layout.inner_end_y.saturating_sub(layout.inner_y)) as usize; + self.diff_view.sticky_hunk_at_line(line_idx, visible_height) + } + + fn try_handle_hunk_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.hunk_at_position(panel_rect, &layout, col, row) else { + return false; + }; + if self.diff_view.selected_hunk == Some(hunk_idx) { + return true; + } + self.diff_view.selected_hunk = Some(hunk_idx); + true + } + + fn apply_selected_file_hunk_action(&mut self, hunk_idx: usize, action: HunkActionKind) -> Result<()> { + let Some(file_idx) = self.selected_file_index() else { + return Ok(()); + }; + + let model = self.model.lock().unwrap(); + let Some(file) = model.files.get(file_idx) else { + return Ok(()); + }; + + let file_name = file.name.clone(); + let has_staged = file.has_staged_changes; + let has_unstaged = file.has_unstaged_changes; + drop(model); + + let mut undo_entry: Option = None; + + match action { + HunkActionKind::Revert => { + if !has_unstaged { + self.popup = PopupState::Message { + title: "Revert block".to_string(), + message: "Block revert is available only for unstaged changes.".to_string(), + kind: MessageKind::Info, + }; + return Ok(()); + } + let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) else { + return Ok(()); + }; + if want_old.is_none() && want_new.is_none() { + return Ok(()); + } + let diff = self.git.diff_file(&file_name)?; + if diff.is_empty() { + return Ok(()); + } + let patch = self + .git + .build_visual_block_patch_text(&file_name, &diff, want_old, want_new)?; + undo_entry = Some(crate::pager::side_by_side::HunkActionUndoEntry { + action, + patch, + apply_cached: false, + reverse: false, + }); + self.git + .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; + } + HunkActionKind::Stage => { + let staged_diff = !(has_unstaged || !has_staged); + let diff = if staged_diff { + self.git.diff_file_staged(&file_name)? + } else { + self.git.diff_file(&file_name)? + }; + if diff.is_empty() { + return Ok(()); + } + let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) else { + return Ok(()); + }; + if want_old.is_none() && want_new.is_none() { + return Ok(()); + } + let patch = self + .git + .build_visual_block_patch_text(&file_name, &diff, want_old, want_new)?; + undo_entry = Some(crate::pager::side_by_side::HunkActionUndoEntry { + action, + patch: patch.clone(), + apply_cached: true, + reverse: !staged_diff, + }); + self.git.apply_patch_text(patch, true, staged_diff)?; + } + } + + let preferred_hunk_line = self.diff_view.hunk_starts.get(hunk_idx).copied(); + + if let Some(entry) = undo_entry { + let stack = &mut self.diff_view.hunk_action_undo_stack; + if stack.len() >= crate::pager::side_by_side::REVERT_UNDO_STACK_CAP { + stack.remove(0); + } + stack.push(entry); + self.diff_view.hunk_action_undo_high_water = + self.diff_view.hunk_action_undo_high_water.max(stack.len()); + } + + self.diff_view.selection = None; + self.diff_view.preferred_selected_hunk_line = preferred_hunk_line; + self.diff_view.center_selected_hunk_on_refresh = true; + self.needs_files_refresh = true; + self.needs_diff_refresh = true; + Ok(()) + } + + fn undo_last_hunk_action(&mut self) -> Result<()> { + let Some(entry) = self.diff_view.hunk_action_undo_stack.pop() else { + return Ok(()); + }; + self.git + .apply_patch_text(entry.patch, entry.apply_cached, entry.reverse) + .with_context(|| format!("failed to undo {} hunk action", entry.action.verb()))?; + if self.diff_view.hunk_action_undo_stack.is_empty() { + self.diff_view.hunk_action_undo_high_water = 0; + } + self.needs_files_refresh = true; + self.needs_diff_refresh = true; + Ok(()) + } + + /// Keep the selected hunk marker around the vertical middle of the visible diff area. + fn center_selected_hunk(&mut self) { + let Some(sel) = self.diff_view.selected_hunk else { + return; + }; + let Some((start_line, end_line)) = self.diff_view.visual_block_line_span(sel) else { + return; + }; + let line_idx = start_line + (end_line.saturating_sub(start_line) / 2); + + let main_panel = self.compute_main_panel_rect(); + let pl = DiffPanelLayout::compute(main_panel, &self.diff_view); + 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..eff7f74 100644 --- a/src/gui/presentation/diff_mode.rs +++ b/src/gui/presentation/diff_mode.rs @@ -8,7 +8,7 @@ use crate::config::Theme; use crate::gui::modes::diff_mode::{DiffModeFocus, DiffModeState, RefKind}; use crate::model::{CommitFile, FileChangeStatus}; use crate::model::file_tree::CommitFileTreeNode; -use crate::pager::side_by_side::{self, DiffViewState}; +use crate::pager::side_by_side::{self, DiffViewState, HunkMarkerStyle}; /// Max items visible in the dropdown at once. const DROPDOWN_MAX_VISIBLE: usize = 10; @@ -18,6 +18,7 @@ pub fn render( state: &mut DiffModeState, diff_view: &mut DiffViewState, theme: &Theme, + hunk_marker_style: &HunkMarkerStyle, diff_loading: bool, diff_loading_show: bool, ) { @@ -49,7 +50,16 @@ pub fn render( render_commit_files(frame, sidebar[2], state, theme); // Right panel: diff exploration - render_diff_panel(frame, content[1], state, diff_view, theme, diff_loading, diff_loading_show); + render_diff_panel( + frame, + content[1], + state, + diff_view, + theme, + hunk_marker_style, + diff_loading, + diff_loading_show, + ); // Text selection highlight overlay and tooltip (must be before popups/dropdowns) crate::gui::views::render_selection_overlay(frame, diff_view, content[1], theme); @@ -277,13 +287,23 @@ fn render_diff_panel( state: &DiffModeState, diff_view: &mut DiffViewState, theme: &Theme, + hunk_marker_style: &HunkMarkerStyle, diff_loading: bool, diff_loading_show: bool, ) { let focused = state.focus == DiffModeFocus::DiffExploration; if !diff_view.is_empty() { - side_by_side::render_diff(frame, area, diff_view, theme, focused, diff_loading); + side_by_side::render_diff( + frame, + area, + diff_view, + theme, + hunk_marker_style, + focused, + diff_loading, + false, + ); side_by_side::render_diff_search_highlights(frame, area, diff_view, theme); side_by_side::render_diff_search_bar(frame, area, diff_view, theme); } else { diff --git a/src/gui/views.rs b/src/gui/views.rs index c51cadd..c334a35 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -68,6 +68,9 @@ pub fn render( ai_button_hovered: bool, ai_configured: bool, ) { + let hunk_marker_style = side_by_side::HunkMarkerStyle::from_config( + &config.user_config.gui.hunk_marker, + ); let area = frame.area(); let panel_count = SideWindow::ALL.len(); @@ -98,7 +101,17 @@ 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, + &hunk_marker_style, + true, + diff_loading_show, + show_revert_markers, + ); side_by_side::render_diff_search_highlights(frame, fl.main_panel, diff_view, theme); side_by_side::render_diff_search_bar(frame, fl.main_panel, diff_view, theme); } else if diff_loading { @@ -242,7 +255,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 { @@ -584,7 +597,17 @@ 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, + &hunk_marker_style, + diff_focused, + diff_loading_show, + show_revert_markers, + ); side_by_side::render_diff_search_highlights(frame, fl.main_panel, diff_view, theme); side_by_side::render_diff_search_bar(frame, fl.main_panel, diff_view, theme); } else if diff_loading { @@ -685,7 +708,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 @@ -1347,8 +1370,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 { @@ -1359,53 +1384,84 @@ 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. + if ctx_mgr.active() == ContextId::Files { + let has_selection = diff_view.selected_hunk.is_some(); + let has_undo = !diff_view.hunk_action_undo_stack.is_empty(); + let mut idx = 0; + if diff_view.hunk_mode { + hints.insert(idx, ("esc", "exit hunk mode")); + idx += 1; + hints.insert(idx, ("j/k", "cycle hunks")); + idx += 1; + } else { + hints.insert(idx, ("H", "enter hunk mode")); + idx += 1; + } + if has_selection { + hints.insert(idx, ("a", "stage hunk")); + emphasized.push("a"); + idx += 1; + hints.insert(idx, ("r", "revert hunk")); + emphasized.push("r"); + idx += 1; + } + if has_undo { + hints.insert(idx, ("u", "undo hunk action")); + } } - ContextId::Worktrees => { - hints.extend([("space", "switch"), ("n", "new"), ("d", "remove")]); + 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(("H", "enter hunk mode")); } - _ => {} } - // 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 ccff1e2..9bbc130 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -1,11 +1,11 @@ +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; +use crate::config::{HunkMarkerConfig, Theme, parse_optional_color}; use super::highlight::FileHighlighter; use super::{ChangeType, DiffLine, InlineSegment}; @@ -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; @@ -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; @@ -184,6 +188,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. @@ -205,6 +218,58 @@ pub struct DiffSearchMatch { pub col: usize, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HunkActionKind { + Stage, + Revert, +} + +impl HunkActionKind { + pub fn verb(self) -> &'static str { + match self { + Self::Stage => "stage", + Self::Revert => "revert", + } + } +} + +/// One entry in the diff view's hunk-action undo stack. +pub struct HunkActionUndoEntry { + pub action: HunkActionKind, + pub patch: String, + pub apply_cached: bool, + pub reverse: bool, +} + +#[derive(Clone, Debug)] +pub struct HunkMarkerStyle { + pub icon: String, + pub bold: bool, + pub color: Option, + pub selected_color: Option, + pub hover_color: Option, +} + +impl HunkMarkerStyle { + pub fn from_config(config: &HunkMarkerConfig) -> Self { + let icon = if config.icon.is_empty() { + "".to_string() + } else { + config.icon.clone() + }; + Self { + icon, + bold: config.bold.unwrap_or(true), + color: parse_optional_color(config.color.as_deref()), + selected_color: parse_optional_color(config.selected_color.as_deref()), + hover_color: parse_optional_color(config.hover_color.as_deref()), + } + } +} + +/// Maximum entries kept in the revert-hunk undo stack. +pub const REVERT_UNDO_STACK_CAP: usize = 20; + /// State for the diff view panel. pub struct DiffViewState { pub scroll_offset: usize, @@ -240,6 +305,20 @@ pub struct DiffViewState { pub search_match_idx: usize, /// Textarea widget for search input. pub search_textarea: Option>, + /// Currently selected hunk-action marker index (for keyboard cycling). + pub selected_hunk: Option, + /// Hunk index currently under the mouse cursor (for tooltip rendering). + pub hovered_hunk: Option, + /// Pre-action file snapshots, most-recent last. Bounded by + /// `REVERT_UNDO_STACK_CAP`. Used to undo hunk actions within a session. + pub hunk_action_undo_stack: Vec, + /// Peak `hunk_action_undo_stack.len()` since it was last empty. Drives the + /// `n/m` denominator in the bottom-right footnote; resets to 0 once the + /// stack drains so a fresh streak starts at `1/1`. + pub hunk_action_undo_high_water: usize, + pub preferred_selected_hunk_line: Option, + pub center_selected_hunk_on_refresh: bool, + pub hunk_mode: bool, } impl Default for DiffViewState { @@ -264,6 +343,13 @@ impl Default for DiffViewState { search_matches: Vec::new(), search_match_idx: 0, search_textarea: None, + selected_hunk: None, + hovered_hunk: None, + hunk_action_undo_stack: Vec::new(), + hunk_action_undo_high_water: 0, + preferred_selected_hunk_line: None, + center_selected_hunk_on_refresh: false, + hunk_mode: false, } } } @@ -416,7 +502,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 { @@ -436,7 +528,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 { @@ -522,6 +619,8 @@ impl DiffViewState { /// Apply a pre-parsed diff result, preserving scroll position for same-file reloads. pub fn apply_parsed(&mut self, parsed: ParsedDiff) { let same_file = self.filename == parsed.filename; + let prev_selected_hunk = self.selected_hunk; + let prev_hovered_hunk = self.hovered_hunk; self.filename = parsed.filename; self.old_content = parsed.old_content; self.new_content = parsed.new_content; @@ -530,6 +629,25 @@ 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_hunk = if same_file { + if let Some(anchor_line) = self.preferred_selected_hunk_line { + self.hunk_starts + .iter() + .enumerate() + .min_by_key(|(_, line_idx)| (**line_idx).abs_diff(anchor_line)) + .map(|(idx, _)| idx) + } else { + prev_selected_hunk.filter(|&i| i < self.hunk_starts.len()) + } + } else { + None + }; + self.preferred_selected_hunk_line = None; + self.hovered_hunk = if same_file { + prev_hovered_hunk.filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; if same_file { let max = self.lines.len().saturating_sub(1); self.scroll_offset = self.scroll_offset.min(max); @@ -545,6 +663,8 @@ impl DiffViewState { pub fn load(&mut self, filename: &str, old: &str, new: &str) { // Preserve scroll position when reloading the same file (e.g. periodic refresh) let same_file = self.filename == filename; + let prev_selected_hunk = self.selected_hunk; + let prev_hovered_hunk = self.hovered_hunk; self.filename = filename.to_string(); self.old_content = old.to_string(); self.new_content = new.to_string(); @@ -561,6 +681,16 @@ impl DiffViewState { self.selection = None; self.clear_search(); } + self.selected_hunk = if same_file { + prev_selected_hunk.filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; + self.hovered_hunk = if same_file { + prev_hovered_hunk.filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; // Preserve side_view across reloads so periodic refresh doesn't reset it // Single section with index 0 self.sections = vec![FileSection { @@ -613,6 +743,8 @@ impl DiffViewState { let file_count = file_diffs.len(); let new_filename = format!("{} ({} files)", filename, file_count); let same_file = self.filename == new_filename; + let prev_selected_hunk = self.selected_hunk; + let prev_hovered_hunk = self.hovered_hunk; self.filename = new_filename; self.old_content = String::new(); self.new_content = String::new(); @@ -624,6 +756,16 @@ impl DiffViewState { self.selection = None; self.clear_search(); } + self.selected_hunk = if same_file { + prev_selected_hunk + } else { + None + }; + self.hovered_hunk = if same_file { + prev_hovered_hunk + } else { + None + }; self.hunk_line_offsets = Vec::new(); @@ -655,14 +797,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 @@ -673,6 +813,18 @@ impl DiffViewState { } self.hunk_starts = super::diff_algo::find_hunk_starts(&self.lines); + self.selected_hunk = if same_file { + self.selected_hunk + .filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; + self.hovered_hunk = if same_file { + self.hovered_hunk + .filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; if same_file { // Clamp scroll in case the diff got shorter @@ -700,12 +852,9 @@ 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; + self.selected_hunk = self.hunk_index_for_start_line(*next); } } @@ -717,6 +866,7 @@ impl DiffViewState { .find(|&&h| h < self.scroll_offset) { self.scroll_offset = *prev; + self.selected_hunk = self.hunk_index_for_start_line(*prev); } } @@ -724,8 +874,174 @@ 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() + } + + /// 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)) + } + + /// Return the inclusive visual line range for a revertable hunk in the diff buffer. + pub fn visual_block_line_span(&self, block_idx: usize) -> Option<(usize, usize)> { + let start = *self.hunk_starts.get(block_idx)?; + let mut end = start; + while end < self.lines.len() && !matches!(self.lines[end].change_type, ChangeType::Equal) { + end += 1; + } + Some((start, end.saturating_sub(1))) + } + + /// Return the line index where the sticky hunk marker should render for this hunk, + /// or `None` when no part of the hunk is visible in the current viewport. + pub fn sticky_hunk_marker_line( + &self, + block_idx: usize, + visible_height: usize, + ) -> Option { + if visible_height == 0 { + return None; + } + let (start, end) = self.visual_block_line_span(block_idx)?; + let view_start = self.scroll_offset; + let view_end = self + .scroll_offset + .saturating_add(visible_height) + .saturating_sub(1); + if end < view_start || start > view_end { + return None; + } + + let visible_start = start.max(view_start); + let visible_end = end.min(view_end); + let block_mid = start + (end.saturating_sub(start) / 2); + + Some(block_mid.clamp(visible_start, visible_end)) + } + + /// Find the `@@` hunk that owns the DiffLine at `line_idx` and return + /// its (old, new) content→file line-number offsets. Returns (0, 0) when + /// no hunk metadata is available (e.g. the `load(...)` raw-content path, + /// 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 hunk marker. + pub fn select_next_hunk(&mut self) { + if self.hunk_starts.is_empty() { + self.selected_hunk = None; + return; + } + let next = match self.selected_hunk { + Some(i) => (i + 1) % self.hunk_starts.len(), + None => 0, + }; + self.selected_hunk = Some(next); + } + + /// Cycle to the previous hunk marker. + pub fn select_prev_hunk(&mut self) { + if self.hunk_starts.is_empty() { + self.selected_hunk = None; + return; + } + let prev = match self.selected_hunk { + Some(0) | None => self.hunk_starts.len() - 1, + Some(i) => i.saturating_sub(1), + }; + self.selected_hunk = Some(prev); + } + + /// Return the sticky hunk marker rendered at `line_idx` for the current viewport. + pub fn sticky_hunk_at_line( + &self, + line_idx: usize, + visible_height: usize, + ) -> Option { + if self.wrap { + return None; + } + self.hunk_starts.iter().enumerate().find_map(|(idx, _)| { + (self.sticky_hunk_marker_line(idx, visible_height) == Some(line_idx)).then_some(idx) + }) + } + /// 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)) @@ -739,8 +1055,10 @@ pub fn render_diff( area: Rect, state: &DiffViewState, theme: &Theme, + marker_cfg: &HunkMarkerStyle, focused: bool, diff_loading: bool, + show_revert_markers: bool, ) { let border_style = if focused { theme.active_border @@ -774,11 +1092,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: hunk-action undo indicator. Only shown when + // there's something to undo; the denominator is the peak stack depth + // since it last drained, so a streak reads `1/1`, `2/2`, ... and undos + // walk it back down to `1/3` etc. + let undo_n = state.hunk_action_undo_stack.len(); + if undo_n > 0 { + let undo_m = state.hunk_action_undo_high_water.max(undo_n); + block = block.title_bottom( + Line::from(vec![ + Span::styled(" ", Style::default().fg(theme.text_dimmed)), + Span::styled( + "u", + Style::default() + .fg(theme.accent_secondary) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" undo hunk action ({}/{}) ", 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); @@ -787,7 +1130,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; @@ -923,10 +1266,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; } @@ -947,6 +1306,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 { @@ -1033,38 +1394,126 @@ 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 - buf_write_str(buf, div_x, y, "│", divider_style, divider_width); + // Divider or hunk marker (first visual row of a hunk only). + let marker_hunk_idx = if show_revert_markers && !state.wrap && chunk_idx == 0 { + state.sticky_hunk_at_line(line_idx, visible_height) + } else { + None + }; + let show_marker = marker_hunk_idx.is_some(); + let marker_is_hovered = show_marker + && marker_hunk_idx.is_some() + && marker_hunk_idx == state.hovered_hunk; + let (divider_chars, marker_style) = if show_marker { + let is_selected = marker_hunk_idx == state.selected_hunk; + // Hover wins over selection so the hover state is always + // visible — even on a hunk that's currently selected. + let fg = if marker_is_hovered { + marker_cfg.hover_color.unwrap_or(theme.accent_secondary) + } else if is_selected { + marker_cfg.selected_color.unwrap_or(theme.accent) + } else { + marker_cfg.color.unwrap_or(theme.separator) + }; + let mut style = Style::default().fg(fg); + if marker_cfg.bold { + style = style.add_modifier(Modifier::BOLD); + } + let chars = if is_selected { "󱐌" } else { marker_cfg.icon.as_str() }; + (chars, style) + } else { + ("│", divider_style) + }; + buf_write_str(buf, div_x, y, " ", divider_style, divider_width); + buf_write_str(buf, div_x, y, divider_chars, marker_style, divider_width); + if show_marker + && marker_hunk_idx.is_some() + && marker_hunk_idx == state.hovered_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); + 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; @@ -1077,7 +1526,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), @@ -1094,17 +1544,66 @@ 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 - buf_write_str(buf, div_x, y, "│", divider_style, divider_width); + // Divider or hunk marker. + let marker_hunk_idx = if show_revert_markers && !state.wrap { + state.sticky_hunk_at_line(line_idx, visible_height) + } else { + None + }; + let show_marker = marker_hunk_idx.is_some(); + let marker_is_hovered = show_marker + && marker_hunk_idx.is_some() + && marker_hunk_idx == state.hovered_hunk; + let (divider_chars, marker_style) = if show_marker { + let is_selected = marker_hunk_idx == state.selected_hunk; + let fg = if marker_is_hovered { + marker_cfg.hover_color.unwrap_or(theme.accent_secondary) + } else if is_selected { + marker_cfg.selected_color.unwrap_or(theme.accent) + } else { + marker_cfg.color.unwrap_or(theme.separator) + }; + let mut style = Style::default().fg(fg); + if marker_cfg.bold { + style = style.add_modifier(Modifier::BOLD); + } + let chars = if is_selected { "󱐌" } else { marker_cfg.icon.as_str() }; + (chars, style) + } else { + ("│", divider_style) + }; + buf_write_str(buf, div_x, y, " ", divider_style, divider_width); + buf_write_str(buf, div_x, y, divider_chars, marker_style, divider_width); + if show_marker + && marker_hunk_idx.is_some() + && marker_hunk_idx == state.hovered_hunk + { + hover_tooltip_y = Some(y); + } // 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), @@ -1121,11 +1620,77 @@ 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; } } + + if let Some(y) = hover_tooltip_y { + let show_key = state.hovered_hunk.is_some() && state.hovered_hunk == state.selected_hunk; + render_hunk_action_tooltip( + buf, + div_x + divider_width, + y, + right_content_width + gutter_width, + theme, + show_key, + ); + } + } +} + +fn render_hunk_action_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) + .add_modifier(Modifier::BOLD); + + let parts: Vec<(&str, Style)> = if show_key { + vec![ + (" ", tip_style), + ("a", key_style), + (" stage ", tip_style), + ("r", key_style), + (" revert ", tip_style), + ] + } else { + vec![(" Stage/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; + } } } @@ -1179,7 +1744,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; @@ -1231,7 +1803,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(); @@ -1390,7 +1966,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() @@ -1433,7 +2012,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) @@ -1486,12 +2067,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; @@ -1538,7 +2114,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); @@ -1548,17 +2126,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, @@ -1720,7 +2303,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) diff --git a/tests/smoke.rs b/tests/smoke.rs new file mode 100644 index 0000000..c312a84 --- /dev/null +++ b/tests/smoke.rs @@ -0,0 +1,367 @@ +use std::path::Path; + +#[test] +fn repository_has_readme() { + let readme = Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"); + assert!(readme.exists(), "README.md should exist at repo root"); +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +si + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +add + + +use + + + + +hihihihi + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +aaaaaaa + + + + + + + + + + + + + + +hookoko oooooooooooooooo + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +llllk