diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 9d47ce2..3518d14 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -110,10 +110,16 @@ 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, + #[serde(rename = "stageBlock")] + pub stage_block: String, } impl Default for UniversalKeybinding { @@ -161,8 +167,11 @@ impl Default for UniversalKeybinding { next_screen_mode: "+".into(), prev_screen_mode: "_".into(), create_patch_options_menu: "".into(), - revert_block: "".into(), + prev_revert_block: "{".into(), + next_revert_block: "}".into(), + revert_block: "r".into(), undo_revert_block: "u".into(), + stage_block: "a".into(), } } } @@ -393,10 +402,7 @@ pub fn parse_key(s: &str) -> Option { // Ctrl modifier if let Some(key) = inner.strip_prefix("c-") { let ch = key.chars().next()?; - return Some(KeyEvent::new( - KeyCode::Char(ch), - KeyModifiers::CONTROL, - )); + return Some(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)); } // Alt modifier @@ -411,9 +417,7 @@ pub fn parse_key(s: &str) -> Option { "escape" | "esc" => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), "tab" => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), "backtab" | "shift-tab" => Some(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)), - "backspace" | "bs" => { - Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)) - } + "backspace" | "bs" => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), "delete" | "del" => Some(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)), "space" => Some(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)), "up" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)), 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..acbe2b1 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: Some(true), + 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 326153f..1f668f2 100644 --- a/src/git/staging.rs +++ b/src/git/staging.rs @@ -50,7 +50,7 @@ impl GitCommands { /// Stage a specific hunk by applying it as a patch. pub fn stage_hunk(&self, file_path: &str, hunk: &DiffHunk) -> Result<()> { - let patch = build_patch(file_path, hunk); + let patch = self.build_hunk_patch(file_path, hunk); self.git() .args(&["apply", "--cached", "--unidiff-zero", "-"]) .stdin(patch) @@ -60,7 +60,7 @@ impl GitCommands { /// Unstage a specific hunk by reverse-applying it as a patch. pub fn unstage_hunk(&self, file_path: &str, hunk: &DiffHunk) -> Result<()> { - let patch = build_patch(file_path, hunk); + let patch = self.build_hunk_patch(file_path, hunk); self.git() .args(&["apply", "--cached", "--reverse", "--unidiff-zero", "-"]) .stdin(patch) @@ -100,6 +100,33 @@ impl GitCommands { .with_context(|| format!("failed to revert hunk in {}", file_path))?; Ok(()) } + + pub fn build_visual_block_patch_text( + &self, + file_path: &str, + unified_diff: &str, + want_old: Option<(usize, usize)>, + want_new: Option<(usize, usize)>, + ) -> Result { + build_visual_block_patch(file_path, unified_diff, want_old, want_new) + } + + pub fn build_hunk_patch(&self, file_path: &str, hunk: &DiffHunk) -> String { + build_patch(file_path, hunk) + } + + pub fn apply_patch_text(&self, patch: String, cached: bool, reverse: bool) -> Result<()> { + let mut args = vec!["apply"]; + if cached { + args.push("--cached"); + } + if reverse { + args.push("--reverse"); + } + args.extend(["--unidiff-zero", "-"]); + self.git().args(&args).stdin(patch).run_expecting_success()?; + Ok(()) + } } fn build_visual_block_patch( diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index 4192894..d99b80a 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 1c750dd..31a9a31 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -26,7 +26,7 @@ use crate::config::keybindings::parse_key; use crate::git::{GitCommands, ModelPart, MODEL_PART_COUNT}; use crate::model::Model; use crate::model::file_tree::{build_file_tree, CommitFileTreeNode, FileTreeNode}; -use crate::pager::side_by_side::{DiffPanel, DiffPanelLayout, DiffViewState, TextSelection}; +use crate::pager::side_by_side::{DiffPanel, DiffPanelLayout, DiffViewState, HunkActionKind, TextSelection}; use self::context::{ContextId, ContextManager, SideWindow}; use self::layout::LayoutState; @@ -521,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,9 +709,13 @@ 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.reset_keep_prefs(); + self.diff_view = DiffViewState::new(); } } } @@ -1058,7 +1066,7 @@ impl Gui { // Clear stale diff when selection changes if selection_changed { - self.diff_view.reset_keep_prefs(); + self.diff_view = DiffViewState::new(); } self.diff_loading = true; @@ -1084,7 +1092,7 @@ impl Gui { // Clear stale diff when selection changes so user sees "Loading..." instead of old content if selection_changed { - self.diff_view.reset_keep_prefs(); + self.diff_view = DiffViewState::new(); } let model = self.model.lock().unwrap(); @@ -1211,15 +1219,15 @@ impl Gui { }); } else { drop(model); - self.diff_view.reset_keep_prefs(); + self.diff_view = DiffViewState::new(); } } else { drop(model); - self.diff_view.reset_keep_prefs(); + self.diff_view = DiffViewState::new(); } } else { drop(model); - self.diff_view.reset_keep_prefs(); + self.diff_view = DiffViewState::new(); } } ContextId::Commits => { @@ -1452,16 +1460,16 @@ impl Gui { }); } else { drop(model); - self.diff_view.reset_keep_prefs(); + self.diff_view = DiffViewState::new(); } } else { drop(model); - self.diff_view.reset_keep_prefs(); + self.diff_view = DiffViewState::new(); } } else { // No file selected — clear diff drop(model); - self.diff_view.reset_keep_prefs(); + self.diff_view = DiffViewState::new(); } } _ => { @@ -1637,6 +1645,20 @@ impl Gui { return Ok(()); } + // {/} jump between hunks from the sidebar (Files context, diff not focused) + if self.context_mgr.active() == ContextId::Files && !self.diff_view.is_empty() { + if key.code == KeyCode::Char('}') { + self.diff_view.select_next_hunk(); + self.center_selected_hunk(); + return Ok(()); + } + if key.code == KeyCode::Char('{') { + self.diff_view.select_prev_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 +1669,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; @@ -1707,7 +1719,7 @@ impl Gui { // Diff/Compare mode (W) if key.code == KeyCode::Char('W') { self.diff_mode.enter(); - self.diff_view.reset_keep_prefs(); + self.diff_view = DiffViewState::new(); return Ok(()); } @@ -1982,26 +1994,59 @@ impl Gui { } } + if matches_key(key, &keybindings.universal.next_revert_block) { + if self.context_mgr.active() == ContextId::Files { + 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.select_prev_hunk(); + self.center_selected_hunk(); + } + return Ok(()); + } + if matches_key(key, &keybindings.universal.stage_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::Stage) { + self.popup = PopupState::Message { + title: "Stage block failed".to_string(), + message: format!("{}", err), + kind: MessageKind::Error, + }; + } + } + } + return Ok(()); + } if matches_key(key, &keybindings.universal.revert_block) { if self.context_mgr.active() == ContextId::Files { - let hunk_idx = self - .diff_view - .selected_revert_hunk - .or(self.diff_view.hovered_revert_hunk); + let hunk_idx = self.diff_view.selected_hunk.or(self.diff_view.hovered_hunk); if let Some(hunk_idx) = hunk_idx { - self.diff_view.selected_revert_hunk = Some(hunk_idx); - self.show_hunk_context_menu(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.revert_undo_stack.is_empty() + && !self.diff_view.hunk_action_undo_stack.is_empty() { - if let Err(err) = self.undo_last_revert_block() { + if let Err(err) = self.undo_last_hunk_action() { self.popup = PopupState::Message { - title: "Undo revert failed".to_string(), + title: "Undo hunk action failed".to_string(), message: format!("{}", err), kind: MessageKind::Error, }; @@ -2048,8 +2093,8 @@ impl Gui { match key.code { // Escape: clear revert-hunk selection first, then search, then unfocus diff KeyCode::Esc => { - if self.diff_view.selected_revert_hunk.is_some() { - self.diff_view.selected_revert_hunk = None; + 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 { @@ -2074,23 +2119,14 @@ impl Gui { KeyCode::Char('l') | KeyCode::Right => { self.diff_view.scroll_right(4); } - // { 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). + // { and } navigate between hunks (always-on: select + center + wrap) KeyCode::Char('}') => { - if self.context_mgr.active() == ContextId::Files { - self.diff_view.cycle_next_revert_hunk(); - } else { - self.diff_view.next_hunk(); - } + self.diff_view.select_next_hunk(); + self.center_selected_hunk(); } KeyCode::Char('{') => { - if self.context_mgr.active() == ContextId::Files { - self.diff_view.cycle_prev_revert_hunk(); - } else { - self.diff_view.prev_hunk(); - } + self.diff_view.select_prev_hunk(); + self.center_selected_hunk(); } // [ and ] toggle old-only / new-only view KeyCode::Char(']') => { @@ -2575,14 +2611,10 @@ impl Gui { } } } - // Insert a newline in the body: - // - Enter while focused on Body (the natural keystroke for a multi-line field). - // - Shift+Enter from Summary jumps focus to Body and inserts a newline. - // - Ctrl+J (some terminals emit this for Shift+Enter) — without this branch it - // would hit tui_textarea's default `delete_line_by_head` binding. - else if (key.code == KeyCode::Enter - && (focus == popup::CommitInputFocus::Body - || key.modifiers.contains(KeyModifiers::SHIFT))) + // Shift+Enter (or Ctrl+J, which some terminals emit for Shift+Enter): insert a + // newline in the body. Without this branch, Ctrl+J hits tui_textarea's default + // binding for `delete_line_by_head`, wiping what the user just typed. + else if (key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::SHIFT)) || (key.code == KeyCode::Char('j') && key.modifiers.contains(KeyModifiers::CONTROL)) { let wrap_width = self.commit_body_wrap_width(); @@ -3277,7 +3309,6 @@ 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: ";".into(), description: "Toggle command log".into() }, HelpEntry { key: "W".into(), description: "Compare / Diff mode".into() }, HelpEntry { key: "I".into(), description: "Interactive rebase onto...".into() }, @@ -3308,9 +3339,11 @@ 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: "{/}".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() }, + HelpEntry { key: "{/}".into(), description: "Prev/next hunk (select + center)".into() }, + HelpEntry { key: "esc".into(), description: "Clear hunk selection".into() }, + HelpEntry { key: kb.universal.stage_block.clone(), 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 { @@ -3486,12 +3519,14 @@ impl Gui { } fn show_diff_help(&mut self) { + let kb = &self.config.user_config.keybinding; let diff_section = HelpSection { title: "Diff Viewer".into(), 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: "Cycle prev / next hunk (selects revert block in Files)".into() }, + HelpEntry { key: "{/}".into(), description: "Prev/next hunk (select + center)".into() }, + HelpEntry { key: "esc".into(), description: "Clear hunk selection".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() }, @@ -3499,17 +3534,18 @@ impl Gui { HelpEntry { key: "PgUp/PgDn".into(), description: "Page up / down".into() }, HelpEntry { key: "/".into(), description: "Search in diff".into() }, HelpEntry { key: "n/N".into(), description: "Next / previous search match".into() }, - HelpEntry { key: "".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: kb.universal.stage_block.clone(), description: "Stage selected block (Files)".into() }, + HelpEntry { key: kb.universal.revert_block.clone(), 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.revert_undo_stack.is_empty() { - "Undo last revert (nothing to undo)".into() + description: if self.diff_view.hunk_action_undo_stack.is_empty() { + "Undo last hunk action (nothing to undo)".into() } else { format!( - "Undo last revert ({}/{})", - self.diff_view.revert_undo_stack.len(), - self.diff_view.revert_undo_high_water, + "Undo last hunk action ({}/{})", + self.diff_view.hunk_action_undo_stack.len(), + self.diff_view.hunk_action_undo_high_water, ) }, }, @@ -3809,7 +3845,7 @@ impl Gui { gui.needs_refresh = false; gui.needs_diff_refresh = true; gui.context_mgr = context::ContextManager::new(); - gui.diff_view.reset_keep_prefs(); + gui.diff_view = DiffViewState::new(); if gui.show_file_tree { gui.update_file_tree_state(); } @@ -4398,12 +4434,11 @@ impl Gui { let main_panel = self.compute_main_panel_rect(); let pl = DiffPanelLayout::compute(main_panel, &self.diff_view); - // Track mouse hover over the revert-block marker (for tooltip). + // Track mouse hover over the hunk marker (for tooltip). if !self.diff_mode.active { - let new_hover = - self.revert_hunk_at_position(main_panel, &pl, mouse.column, mouse.row); - if self.diff_view.hovered_revert_hunk != new_hover { - self.diff_view.hovered_revert_hunk = new_hover; + let new_hover = self.hunk_at_position(main_panel, &pl, mouse.column, mouse.row); + if self.diff_view.hovered_hunk != new_hover { + self.diff_view.hovered_hunk = new_hover; } } @@ -4420,7 +4455,7 @@ impl Gui { let full_sidebar = self.screen_mode == ScreenMode::Full && !self.diff_focused; if in_main && !self.diff_view.is_empty() && !full_sidebar { - if self.try_handle_revert_block_click(main_panel, pl, mouse.column, mouse.row) { + if self.try_handle_hunk_click(main_panel, pl, mouse.column, mouse.row) { self.diff_focused = true; return; } @@ -4758,7 +4793,7 @@ impl Gui { // Check if click is in the diff panel — start text selection if rect_contains(diff_rect, col, row) && !self.diff_view.is_empty() { let pl = DiffPanelLayout::compute(diff_rect, &self.diff_view); - if self.try_handle_revert_block_click(diff_rect, pl, col, row) { + if self.try_handle_hunk_click(diff_rect, pl, col, row) { self.diff_mode.focus = DiffModeFocus::DiffExploration; return; } @@ -5059,7 +5094,7 @@ impl Gui { self.compute_current_frame_layout().main_panel } - fn revert_hunk_at_position( + fn hunk_at_position( &self, panel_rect: ratatui::layout::Rect, layout: &DiffPanelLayout, @@ -5069,24 +5104,22 @@ impl Gui { if self.context_mgr.active() != ContextId::Files { return None; } - if self.diff_view.is_empty() { + 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 { + if col < divider_x || col >= divider_x.saturating_add(2) { return None; } - 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) + 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_revert_block_click( + fn try_handle_hunk_click( &mut self, panel_rect: ratatui::layout::Rect, layout: DiffPanelLayout, @@ -5096,61 +5129,17 @@ impl Gui { if self.diff_mode.active { return false; } - let Some(hunk_idx) = self.revert_hunk_at_position(panel_rect, &layout, col, row) else { + let Some(hunk_idx) = self.hunk_at_position(panel_rect, &layout, col, row) else { return false; }; - self.diff_view.selected_revert_hunk = Some(hunk_idx); - if let Err(err) = self.revert_selected_file_hunk(hunk_idx) { - self.popup = PopupState::Message { - title: "Revert block failed".to_string(), - message: format!("{}", err), - kind: MessageKind::Error, - }; + if self.diff_view.selected_hunk == Some(hunk_idx) { + return true; } + self.diff_view.selected_hunk = Some(hunk_idx); true } - /// 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<()> { + 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(()); }; @@ -5160,75 +5149,131 @@ impl Gui { return Ok(()); }; - if !file.has_unstaged_changes { - self.popup = PopupState::Message { - title: "Revert block".to_string(), - message: "Block revert is available only for unstaged changes.".to_string(), - kind: MessageKind::Info, - }; - return Ok(()); - } - let file_name = file.name.clone(); + let has_staged = file.has_staged_changes; + let has_unstaged = file.has_unstaged_changes; drop(model); - let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) - else { - return Ok(()); - }; - if want_old.is_none() && want_new.is_none() { - return Ok(()); - } + let mut undo_entry: Option = None; - let diff = self.git.diff_file(&file_name)?; - if diff.is_empty() { - return Ok(()); + match action { + HunkActionKind::Revert => { + if !has_unstaged { + self.popup = PopupState::Message { + title: "Revert block".to_string(), + message: "Block revert is available only for unstaged changes.".to_string(), + kind: MessageKind::Info, + }; + return Ok(()); + } + let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) else { + return Ok(()); + }; + if want_old.is_none() && want_new.is_none() { + return Ok(()); + } + let diff = self.git.diff_file(&file_name)?; + if diff.is_empty() { + return Ok(()); + } + let patch = self + .git + .build_visual_block_patch_text(&file_name, &diff, want_old, want_new)?; + undo_entry = Some(crate::pager::side_by_side::HunkActionUndoEntry { + action, + patch, + apply_cached: false, + reverse: false, + }); + self.git + .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; + } + HunkActionKind::Stage => { + let staged_diff = !(has_unstaged || !has_staged); + let diff = if staged_diff { + self.git.diff_file_staged(&file_name)? + } else { + self.git.diff_file(&file_name)? + }; + if diff.is_empty() { + return Ok(()); + } + let Some((want_old, want_new)) = self.diff_view.visual_block_line_ranges(hunk_idx) else { + return Ok(()); + }; + if want_old.is_none() && want_new.is_none() { + return Ok(()); + } + let patch = self + .git + .build_visual_block_patch_text(&file_name, &diff, want_old, want_new)?; + undo_entry = Some(crate::pager::side_by_side::HunkActionUndoEntry { + action, + patch: patch.clone(), + apply_cached: true, + reverse: !staged_diff, + }); + self.git.apply_patch_text(patch, true, staged_diff)?; + } } - // Snapshot the working-tree file before reverting so the user can undo - // (`u`) within this session. Only keep the snapshot if the revert - // actually succeeds; otherwise we'd leak unrelated state into the stack. - let abs_path = self.git.repo_path().join(&file_name); - let pre_bytes = std::fs::read(&abs_path).ok(); - - self.git - .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; + let preferred_hunk_line = self.diff_view.hunk_starts.get(hunk_idx).copied(); - if let Some(bytes) = pre_bytes { - let stack = &mut self.diff_view.revert_undo_stack; + if let Some(entry) = undo_entry { + let stack = &mut self.diff_view.hunk_action_undo_stack; if stack.len() >= crate::pager::side_by_side::REVERT_UNDO_STACK_CAP { stack.remove(0); } - stack.push(crate::pager::side_by_side::RevertUndoEntry { - file_path: file_name.clone(), - pre_revert_bytes: bytes, - }); - self.diff_view.revert_undo_high_water = - self.diff_view.revert_undo_high_water.max(stack.len()); + stack.push(entry); + self.diff_view.hunk_action_undo_high_water = + self.diff_view.hunk_action_undo_high_water.max(stack.len()); } self.diff_view.selection = None; + self.diff_view.preferred_selected_hunk_line = preferred_hunk_line; + self.diff_view.center_selected_hunk_on_refresh = true; self.needs_files_refresh = true; self.needs_diff_refresh = true; Ok(()) } - fn undo_last_revert_block(&mut self) -> Result<()> { - let Some(entry) = self.diff_view.revert_undo_stack.pop() else { + fn undo_last_hunk_action(&mut self) -> Result<()> { + let Some(entry) = self.diff_view.hunk_action_undo_stack.pop() else { return Ok(()); }; - let abs_path = self.git.repo_path().join(&entry.file_path); - std::fs::write(&abs_path, &entry.pre_revert_bytes).with_context(|| { - format!("failed to restore {}", entry.file_path) - })?; - if self.diff_view.revert_undo_stack.is_empty() { - self.diff_view.revert_undo_high_water = 0; + self.git + .apply_patch_text(entry.patch, entry.apply_cached, entry.reverse) + .with_context(|| format!("failed to undo {} hunk action", entry.action.verb()))?; + if self.diff_view.hunk_action_undo_stack.is_empty() { + self.diff_view.hunk_action_undo_high_water = 0; } self.needs_files_refresh = true; self.needs_diff_refresh = true; Ok(()) } + /// Keep the selected 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(); @@ -5693,8 +5738,20 @@ fn read_clipboard() -> Option { fn matches_key(key: KeyEvent, binding: &str) -> bool { if let Some(expected) = parse_key(binding) { - // Compare code and modifiers, ignore kind/state - key.code == expected.code && key.modifiers == expected.modifiers + if key.code != expected.code { + return false; + } + // For non-alphabetic Char keys (e.g. `{`, `}`, `!`) the character itself + // already encodes whether Shift was involved, so terminals with keyboard + // enhancement (which emit SHIFT for these keys) and those without (which + // don't) must both match. Strip SHIFT from the comparison for those keys. + let strip_shift = matches!(key.code, KeyCode::Char(c) if !c.is_alphabetic()); + let mask = if strip_shift { + crossterm::event::KeyModifiers::all() & !crossterm::event::KeyModifiers::SHIFT + } else { + crossterm::event::KeyModifiers::all() + }; + (key.modifiers & mask) == (expected.modifiers & mask) } else { false } diff --git a/src/gui/presentation/diff_mode.rs b/src/gui/presentation/diff_mode.rs index 1c524fe..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, false); + 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 e05de86..8411fcf 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -1,8 +1,8 @@ -use ratatui::Frame; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; +use ratatui::Frame; use unicode_width::UnicodeWidthStr; use std::collections::HashSet; @@ -12,16 +12,16 @@ use std::sync::{Arc, Mutex}; use crate::config::{AppConfig, Theme}; use crate::git::GitCommands; -use crate::model::Model; use crate::model::commit::{Commit, CommitStat}; use crate::model::file_tree::{CommitFileTreeNode, FileTreeNode}; +use crate::model::Model; use crate::pager::side_by_side::{self, DiffPanel, DiffPanelLayout, DiffViewState}; -use super::ScreenMode; use super::context::{ContextId, ContextManager, SideWindow}; use super::layout::{self, LayoutState}; use super::popup::{CommitInputFocus, PopupState}; use super::presentation; +use super::ScreenMode; pub fn render( frame: &mut Frame, @@ -68,6 +68,8 @@ 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(); @@ -99,7 +101,16 @@ pub fn render( // Diff is focused: show diff fullscreen if !diff_view.is_empty() { let show_revert_markers = ctx_mgr.active() == ContextId::Files; - side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, true, diff_loading_show, show_revert_markers); + side_by_side::render_diff( + frame, + fl.main_panel, + diff_view, + theme, + &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 { @@ -161,15 +172,51 @@ pub fn render( file_tree_nodes, collapsed_dirs, ); - render_list_ctx(frame, fl.main_panel, block, items, selected, true, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, + fl.main_panel, + block, + items, + selected, + true, + theme, + ctx_mgr, + ctx_id, + ); } else { let items = presentation::files::render_file_list(model, theme); - render_list_ctx(frame, fl.main_panel, block, items, selected, true, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, + fl.main_panel, + block, + items, + selected, + true, + theme, + ctx_mgr, + ctx_id, + ); } } ContextId::Branches => { - let items = presentation::branches::render_branch_list(model, theme, remote_op_label, spinner_frame, remote_op_success); - render_list_ctx(frame, fl.main_panel, block, items, selected, true, theme, ctx_mgr, ctx_id); + let items = presentation::branches::render_branch_list( + model, + theme, + remote_op_label, + spinner_frame, + remote_op_success, + ); + render_list_ctx( + frame, + fl.main_panel, + block, + items, + selected, + true, + theme, + ctx_mgr, + ctx_id, + ); } ContextId::Remotes | ContextId::RemoteBranches => { if ctx_mgr.active() == ContextId::RemoteBranches { @@ -178,29 +225,98 @@ pub fn render( .title(format!(" Remote Branches ({}) ", remote_branches_name)) .borders(Borders::ALL) .border_style(theme.active_border); - let items = presentation::remote_branches::render_remote_branch_list(&model.sub_remote_branches, &model.head_branch_name, theme); - render_list_ctx(frame, fl.main_panel, rb_block, items, rb_selected, true, theme, ctx_mgr, ContextId::RemoteBranches); + let items = presentation::remote_branches::render_remote_branch_list( + &model.sub_remote_branches, + &model.head_branch_name, + theme, + ); + render_list_ctx( + frame, + fl.main_panel, + rb_block, + items, + rb_selected, + true, + theme, + ctx_mgr, + ContextId::RemoteBranches, + ); } else { let items = presentation::remotes::render_remote_list(model, theme); - render_list_ctx(frame, fl.main_panel, block, items, selected, true, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, + fl.main_panel, + block, + items, + selected, + true, + theme, + ctx_mgr, + ctx_id, + ); } } ContextId::Tags => { let items = presentation::tags::render_tag_list(model, theme); - render_list_ctx(frame, fl.main_panel, block, items, selected, true, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, + fl.main_panel, + block, + items, + selected, + true, + theme, + ctx_mgr, + ctx_id, + ); } ContextId::Commits => { - let items = presentation::commits::render_commit_list(model, theme, cherry_pick_clipboard); + let items = presentation::commits::render_commit_list( + model, + theme, + cherry_pick_clipboard, + ); let range = range_select_anchor.map(|a| (a.min(selected), a.max(selected))); - render_list_with_range_ctx(frame, fl.main_panel, block, items, selected, true, theme, range, ctx_mgr, ctx_id); + render_list_with_range_ctx( + frame, + fl.main_panel, + block, + items, + selected, + true, + theme, + range, + ctx_mgr, + ctx_id, + ); } ContextId::Stash => { let items = presentation::stash::render_stash_list(model, theme); - render_list_ctx(frame, fl.main_panel, block, items, selected, true, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, + fl.main_panel, + block, + items, + selected, + true, + theme, + ctx_mgr, + ctx_id, + ); } ContextId::BranchCommits => { let items = presentation::commits::render_sub_commit_list(model, theme); - render_list_ctx(frame, fl.main_panel, block, items, selected, true, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, + fl.main_panel, + block, + items, + selected, + true, + theme, + ctx_mgr, + ctx_id, + ); } ContextId::CommitFiles | ContextId::StashFiles | ContextId::BranchCommitFiles => { if show_commit_file_tree { @@ -210,11 +326,31 @@ pub fn render( commit_file_tree_nodes, commit_files_collapsed_dirs, ); - render_list_ctx(frame, fl.main_panel, block, items, selected, true, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, + fl.main_panel, + block, + items, + selected, + true, + theme, + ctx_mgr, + ctx_id, + ); } else { let items = presentation::commit_files::render_commit_file_list(model, theme); - render_list_ctx(frame, fl.main_panel, block, items, selected, true, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, + fl.main_panel, + block, + items, + selected, + true, + theme, + ctx_mgr, + ctx_id, + ); } } _ => { @@ -243,11 +379,27 @@ pub fn render( commit_details_scroll, ); } - render_status_bar(frame, fl.status_bar, ctx_mgr, diff_view, theme, model, diff_focused); + 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 { - render_popup(frame, popup, area, spinner_frame, theme, ai_button_hovered, ai_configured); + render_popup( + frame, + popup, + area, + spinner_frame, + theme, + ai_button_hovered, + ai_configured, + ); } render_command_log(frame, &fl, command_log, show_command_log, theme); return; @@ -306,35 +458,47 @@ pub fn render( file_tree_nodes, collapsed_dirs, ); - render_list_ctx(frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, + ); } else { let items = presentation::files::render_file_list(model, theme); - render_list_ctx(frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, + ); } } ContextId::Worktrees => { let items = render_worktree_list(model, theme); - render_list_ctx(frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, + ); } ContextId::Submodules => { if model.submodules.is_empty() { let widget = Paragraph::new(" (no submodules)").block(block); frame.render_widget(widget, rect); } else { - let items: Vec = model.submodules.iter().map(|sub| { - let line = Line::from(vec![ - Span::styled( - format!(" {} ", sub.name), - Style::default().fg(theme.accent), - ), - Span::styled( - sub.path.clone(), - Style::default().fg(theme.text_dimmed), - ), - ]); - ListItem::new(line) - }).collect(); - render_list_ctx(frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id); + let items: Vec = model + .submodules + .iter() + .map(|sub| { + let line = Line::from(vec![ + Span::styled( + format!(" {} ", sub.name), + Style::default().fg(theme.accent), + ), + Span::styled( + sub.path.clone(), + Style::default().fg(theme.text_dimmed), + ), + ]); + ListItem::new(line) + }) + .collect(); + render_list_ctx( + frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, + ); } } ContextId::Branches => { @@ -358,11 +522,31 @@ pub fn render( commit_file_tree_nodes, commit_files_collapsed_dirs, ); - render_list_ctx(frame, rect, cf_block, items, cf_selected, is_active, theme, ctx_mgr, ContextId::BranchCommitFiles); + render_list_ctx( + frame, + rect, + cf_block, + items, + cf_selected, + is_active, + theme, + ctx_mgr, + ContextId::BranchCommitFiles, + ); } else { let items = presentation::commit_files::render_commit_file_list(model, theme); - render_list_ctx(frame, rect, cf_block, items, cf_selected, is_active, theme, ctx_mgr, ContextId::BranchCommitFiles); + render_list_ctx( + frame, + rect, + cf_block, + items, + cf_selected, + is_active, + theme, + ctx_mgr, + ContextId::BranchCommitFiles, + ); } } else if ctx_mgr.active() == ContextId::BranchCommits { let bc_selected = ctx_mgr.selected(ContextId::BranchCommits); @@ -372,10 +556,28 @@ pub fn render( .borders(Borders::ALL) .border_style(border_style); let items = presentation::commits::render_sub_commit_list(model, theme); - render_list_ctx(frame, rect, bc_block, items, bc_selected, is_active, theme, ctx_mgr, ContextId::BranchCommits); + render_list_ctx( + frame, + rect, + bc_block, + items, + bc_selected, + is_active, + theme, + ctx_mgr, + ContextId::BranchCommits, + ); } else { - let items = presentation::branches::render_branch_list(model, theme, remote_op_label, spinner_frame, remote_op_success); - render_list_ctx(frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id); + let items = presentation::branches::render_branch_list( + model, + theme, + remote_op_label, + spinner_frame, + remote_op_success, + ); + render_list_ctx( + frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, + ); } } ContextId::Remotes => { @@ -400,11 +602,31 @@ pub fn render( commit_file_tree_nodes, commit_files_collapsed_dirs, ); - render_list_ctx(frame, rect, cf_block, items, cf_selected, is_active, theme, ctx_mgr, ContextId::BranchCommitFiles); + render_list_ctx( + frame, + rect, + cf_block, + items, + cf_selected, + is_active, + theme, + ctx_mgr, + ContextId::BranchCommitFiles, + ); } else { let items = presentation::commit_files::render_commit_file_list(model, theme); - render_list_ctx(frame, rect, cf_block, items, cf_selected, is_active, theme, ctx_mgr, ContextId::BranchCommitFiles); + render_list_ctx( + frame, + rect, + cf_block, + items, + cf_selected, + is_active, + theme, + ctx_mgr, + ContextId::BranchCommitFiles, + ); } } else if ctx_mgr.active() == ContextId::BranchCommits && sub_commits_parent_context == ContextId::RemoteBranches @@ -416,7 +638,17 @@ pub fn render( .borders(Borders::ALL) .border_style(border_style); let items = presentation::commits::render_sub_commit_list(model, theme); - render_list_ctx(frame, rect, bc_block, items, bc_selected, is_active, theme, ctx_mgr, ContextId::BranchCommits); + render_list_ctx( + frame, + rect, + bc_block, + items, + bc_selected, + is_active, + theme, + ctx_mgr, + ContextId::BranchCommits, + ); } else if ctx_mgr.active() == ContextId::RemoteBranches { let rb_selected = ctx_mgr.selected(ContextId::RemoteBranches); let rb_title = format!(" Remote Branches ({}) ", remote_branches_name); @@ -424,11 +656,27 @@ pub fn render( .title(rb_title) .borders(Borders::ALL) .border_style(border_style); - let items = presentation::remote_branches::render_remote_branch_list(&model.sub_remote_branches, &model.head_branch_name, theme); - render_list_ctx(frame, rect, rb_block, items, rb_selected, is_active, theme, ctx_mgr, ContextId::RemoteBranches); + let items = presentation::remote_branches::render_remote_branch_list( + &model.sub_remote_branches, + &model.head_branch_name, + theme, + ); + render_list_ctx( + frame, + rect, + rb_block, + items, + rb_selected, + is_active, + theme, + ctx_mgr, + ContextId::RemoteBranches, + ); } else { let items = presentation::remotes::render_remote_list(model, theme); - render_list_ctx(frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, + ); } } ContextId::Tags => { @@ -452,11 +700,31 @@ pub fn render( commit_file_tree_nodes, commit_files_collapsed_dirs, ); - render_list_ctx(frame, rect, cf_block, items, cf_selected, is_active, theme, ctx_mgr, ContextId::BranchCommitFiles); + render_list_ctx( + frame, + rect, + cf_block, + items, + cf_selected, + is_active, + theme, + ctx_mgr, + ContextId::BranchCommitFiles, + ); } else { let items = presentation::commit_files::render_commit_file_list(model, theme); - render_list_ctx(frame, rect, cf_block, items, cf_selected, is_active, theme, ctx_mgr, ContextId::BranchCommitFiles); + render_list_ctx( + frame, + rect, + cf_block, + items, + cf_selected, + is_active, + theme, + ctx_mgr, + ContextId::BranchCommitFiles, + ); } } else if ctx_mgr.active() == ContextId::BranchCommits { let bc_selected = ctx_mgr.selected(ContextId::BranchCommits); @@ -466,10 +734,22 @@ pub fn render( .borders(Borders::ALL) .border_style(border_style); let items = presentation::commits::render_sub_commit_list(model, theme); - render_list_ctx(frame, rect, bc_block, items, bc_selected, is_active, theme, ctx_mgr, ContextId::BranchCommits); + render_list_ctx( + frame, + rect, + bc_block, + items, + bc_selected, + is_active, + theme, + ctx_mgr, + ContextId::BranchCommits, + ); } else { let items = presentation::tags::render_tag_list(model, theme); - render_list_ctx(frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, + ); } } ContextId::Commits => { @@ -493,16 +773,47 @@ pub fn render( commit_file_tree_nodes, commit_files_collapsed_dirs, ); - render_list_ctx(frame, rect, cf_block, items, cf_selected, is_active, theme, ctx_mgr, ContextId::CommitFiles); + render_list_ctx( + frame, + rect, + cf_block, + items, + cf_selected, + is_active, + theme, + ctx_mgr, + ContextId::CommitFiles, + ); } else { let items = presentation::commit_files::render_commit_file_list(model, theme); - render_list_ctx(frame, rect, cf_block, items, cf_selected, is_active, theme, ctx_mgr, ContextId::CommitFiles); + render_list_ctx( + frame, + rect, + cf_block, + items, + cf_selected, + is_active, + theme, + ctx_mgr, + ContextId::CommitFiles, + ); } } else { - let items = presentation::commits::render_commit_list(model, theme, cherry_pick_clipboard); - let range = if is_active { range_select_anchor.map(|a| (a.min(selected), a.max(selected))) } else { None }; - render_list_with_range_ctx(frame, rect, block, items, selected, is_active, theme, range, ctx_mgr, ctx_id); + let items = presentation::commits::render_commit_list( + model, + theme, + cherry_pick_clipboard, + ); + let range = if is_active { + range_select_anchor.map(|a| (a.min(selected), a.max(selected))) + } else { + None + }; + render_list_with_range_ctx( + frame, rect, block, items, selected, is_active, theme, range, ctx_mgr, + ctx_id, + ); } } ContextId::Reflog => { @@ -526,15 +837,37 @@ pub fn render( commit_file_tree_nodes, commit_files_collapsed_dirs, ); - render_list_ctx(frame, rect, cf_block, items, cf_selected, is_active, theme, ctx_mgr, ContextId::CommitFiles); + render_list_ctx( + frame, + rect, + cf_block, + items, + cf_selected, + is_active, + theme, + ctx_mgr, + ContextId::CommitFiles, + ); } else { let items = presentation::commit_files::render_commit_file_list(model, theme); - render_list_ctx(frame, rect, cf_block, items, cf_selected, is_active, theme, ctx_mgr, ContextId::CommitFiles); + render_list_ctx( + frame, + rect, + cf_block, + items, + cf_selected, + is_active, + theme, + ctx_mgr, + ContextId::CommitFiles, + ); } } else { let items = presentation::reflog::render_reflog_list(model, theme); - render_list_ctx(frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, + ); } } ContextId::Stash => { @@ -558,15 +891,37 @@ pub fn render( commit_file_tree_nodes, commit_files_collapsed_dirs, ); - render_list_ctx(frame, rect, sf_block, items, sf_selected, is_active, theme, ctx_mgr, ContextId::StashFiles); + render_list_ctx( + frame, + rect, + sf_block, + items, + sf_selected, + is_active, + theme, + ctx_mgr, + ContextId::StashFiles, + ); } else { let items = presentation::commit_files::render_commit_file_list(model, theme); - render_list_ctx(frame, rect, sf_block, items, sf_selected, is_active, theme, ctx_mgr, ContextId::StashFiles); + render_list_ctx( + frame, + rect, + sf_block, + items, + sf_selected, + is_active, + theme, + ctx_mgr, + ContextId::StashFiles, + ); } } else { let items = presentation::stash::render_stash_list(model, theme); - render_list_ctx(frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id); + render_list_ctx( + frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, + ); } } _ => { @@ -586,7 +941,16 @@ pub fn render( render_status_main(frame, fl.main_panel, model, config, theme, status_block); } else if !diff_view.is_empty() { let show_revert_markers = ctx_mgr.active() == ContextId::Files; - side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, diff_focused, diff_loading_show, show_revert_markers); + side_by_side::render_diff( + frame, + fl.main_panel, + diff_view, + theme, + &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 { @@ -658,7 +1022,10 @@ pub fn render( // Prefix " /" let prefix_rect = Rect::new(fl.status_bar.x, fl.status_bar.y, prefix_width, 1); - let prefix = Paragraph::new(Span::styled(" /", Style::default().fg(theme.accent_secondary))); + let prefix = Paragraph::new(Span::styled( + " /", + Style::default().fg(theme.accent_secondary), + )); frame.render_widget(prefix, prefix_rect); // Textarea @@ -687,7 +1054,15 @@ pub fn render( frame.render_widget(bar, fl.status_bar); } } else { - render_status_bar(frame, fl.status_bar, ctx_mgr, diff_view, theme, model, diff_focused); + render_status_bar( + frame, + fl.status_bar, + ctx_mgr, + diff_view, + theme, + model, + diff_focused, + ); } // Render text selection highlight overlay and tooltip @@ -695,7 +1070,15 @@ pub fn render( // Render popup overlay if *popup != PopupState::None { - render_popup(frame, popup, area, spinner_frame, theme, ai_button_hovered, ai_configured); + render_popup( + frame, + popup, + area, + spinner_frame, + theme, + ai_button_hovered, + ai_configured, + ); } // Render command log last so it appears above everything @@ -806,7 +1189,10 @@ fn build_commit_files_title<'a>( }; let mut spans = vec![ Span::raw(prefix), - Span::styled(format!("({}", short), Style::default().fg(theme.accent_secondary)), + Span::styled( + format!("({}", short), + Style::default().fg(theme.accent_secondary), + ), ]; if !commit_message.is_empty() { spans.push(Span::raw(" ")); @@ -815,7 +1201,10 @@ fn build_commit_files_title<'a>( Style::default().fg(theme.text_dimmed), )); } - spans.push(Span::styled(") ", Style::default().fg(theme.accent_secondary))); + spans.push(Span::styled( + ") ", + Style::default().fg(theme.accent_secondary), + )); Line::from(spans) } @@ -858,7 +1247,12 @@ fn build_window_title<'a>( } /// Compact 1-line status for the sidebar: "reponame → branch +N -N" -fn render_status_sidebar<'a>(model: &Model, _config: &AppConfig, inner_width: usize, theme: &Theme) -> Line<'a> { +fn render_status_sidebar<'a>( + model: &Model, + _config: &AppConfig, + inner_width: usize, + theme: &Theme, +) -> Line<'a> { // Determine the working-tree state prefix (rebasing/merging/cherry-picking) let state_prefix = if model.is_rebasing { Some("rebasing") @@ -871,15 +1265,13 @@ fn render_status_sidebar<'a>(model: &Model, _config: &AppConfig, inner_width: us }; let head_branch = model.branches.iter().find(|b| b.head); - let branch_name = head_branch - .map(|b| b.name.clone()) - .unwrap_or_else(|| { - if model.head_branch_name.is_empty() { - "HEAD (no branch)".to_string() - } else { - model.head_branch_name.clone() - } - }); + let branch_name = head_branch.map(|b| b.name.clone()).unwrap_or_else(|| { + if model.head_branch_name.is_empty() { + "HEAD (no branch)".to_string() + } else { + model.head_branch_name.clone() + } + }); let ahead_behind = head_branch.and_then(|b| b.ahead_behind()); let repo_name = model.repo_name.clone(); @@ -925,8 +1317,13 @@ fn render_status_sidebar<'a>(model: &Model, _config: &AppConfig, inner_width: us }; let prefix = format!("({})", state); - let left_len = 1 + prefix.len() + 1 + repo_name.len() + 1 - + UnicodeWidthStr::width("→ ") + right_side.len(); + let left_len = 1 + + prefix.len() + + 1 + + repo_name.len() + + 1 + + UnicodeWidthStr::width("→ ") + + right_side.len(); let right_len = if has_changes { stats_text.len() + 1 } else { 0 }; let padding = if has_changes { inner_width.saturating_sub(left_len + right_len).max(1) @@ -1004,7 +1401,10 @@ fn render_status_sidebar<'a>(model: &Model, _config: &AppConfig, inner_width: us .add_modifier(Modifier::BOLD), )); spans.push(Span::styled("→ ", Style::default().fg(theme.text_dimmed))); - spans.push(Span::styled(branch_name, Style::default().fg(theme.branch_local.fg.unwrap_or(theme.accent)))); + spans.push(Span::styled( + branch_name, + Style::default().fg(theme.branch_local.fg.unwrap_or(theme.accent)), + )); if has_changes { spans.push(Span::raw(" ".repeat(padding))); @@ -1038,7 +1438,6 @@ fn render_status_main<'a>( theme: &crate::config::Theme, block: Block<'a>, ) { - let branch_name = model .branches .iter() @@ -1075,7 +1474,10 @@ fn render_status_main<'a>( } lines.push(Line::from(format!(" Commits: {}", model.commits.len()))); lines.push(Line::from(format!(" Files: {}", model.files.len()))); - lines.push(Line::from(format!(" Version: v{}", env!("CARGO_PKG_VERSION")))); + lines.push(Line::from(format!( + " Version: v{}", + env!("CARGO_PKG_VERSION") + ))); if !model.contributors.is_empty() { lines.push(Line::from("")); @@ -1149,7 +1551,9 @@ fn render_list_ctx( ctx_mgr: &mut ContextManager, ctx: ContextId, ) { - render_list_with_range_ctx(frame, rect, block, items, selected, is_active, theme, None, ctx_mgr, ctx); + render_list_with_range_ctx( + frame, rect, block, items, selected, is_active, theme, None, ctx_mgr, ctx, + ); } /// Render a list with range selection using persistent scroll offsets from ContextManager. @@ -1167,7 +1571,9 @@ fn render_list_with_range_ctx( ) { let mut so = ctx_mgr.scroll_offset(ctx); let follow = !ctx_mgr.viewport_manually_scrolled; - render_list_with_range_raw(frame, rect, block, items, selected, is_active, theme, range, &mut so, follow); + render_list_with_range_raw( + frame, rect, block, items, selected, is_active, theme, range, &mut so, follow, + ); ctx_mgr.set_scroll_offset(ctx, so); } @@ -1365,47 +1771,77 @@ fn render_status_bar( if diff_focused && !diff_view.is_empty() { // Diff-focused hint set: only the diff-relevant keys, kept tight. - // Revert-related keys are grouped together at the front so users see - // enter right next to its cycle keys. enter itself only appears when a - // hunk is actually selected (pressing it otherwise is a no-op). if ctx_mgr.active() == ContextId::Files { - let has_selection = diff_view.selected_revert_hunk.is_some(); - let has_undo = !diff_view.revert_undo_stack.is_empty(); + let has_selection = diff_view.selected_hunk.is_some(); + let has_undo = !diff_view.hunk_action_undo_stack.is_empty(); let mut idx = 0; + if !diff_view.hunk_starts.is_empty() { + hints.insert(idx, ("{/}", "prev/next hunk")); + idx += 1; + } if has_selection { - hints.insert(idx, ("enter", "hunk menu")); - emphasized.push("enter"); + hints.insert(idx, ("a", "stage hunk")); + emphasized.push("a"); + idx += 1; + hints.insert(idx, ("r", "revert hunk")); + emphasized.push("r"); idx += 1; } - hints.insert(idx, ("{/}", "cycle hunks")); - idx += 1; if has_undo { - hints.insert(idx, ("u", "undo revert")); + hints.insert(idx, ("u", "undo hunk action")); } - } else { - 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")]); + 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")]); + 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")]); + 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")]); + hints.extend([ + ("enter", "branches"), + ("f", "fetch"), + ("P", "push"), + ("p", "pull"), + ]); } ContextId::RemoteBranches => { - hints.extend([("enter", "commits"), ("space", "checkout"), ("M", "merge"), ("r", "rebase"), ("d", "delete")]); + hints.extend([ + ("enter", "commits"), + ("space", "checkout"), + ("M", "merge"), + ("r", "rebase"), + ("d", "delete"), + ]); } ContextId::Tags => { hints.extend([("n", "new"), ("d", "delete"), ("P", "push")]); @@ -1414,26 +1850,41 @@ fn render_status_bar( hints.extend([("space", "switch"), ("n", "new"), ("d", "remove")]); } ContextId::Submodules => { - hints.extend([("space", "update"), ("a", "add"), ("d", "remove"), ("e", "enter")]); + hints.extend([ + ("space", "update"), + ("a", "add"), + ("d", "remove"), + ("e", "enter"), + ]); } _ => {} } if !diff_view.is_empty() { hints.push(("J/K", "scroll diff")); - hints.push(("{/}", "hunks")); + if ctx_mgr.active() == ContextId::Files && !diff_view.hunk_starts.is_empty() { + hints.push(("{/}", "prev/next hunk")); + } } } // Global hints (always last) hints.extend([("q", "quit"), ("tab/1-5", "panels"), ("j/k", "nav")]); - 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 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 }; + let style = if emphasized.contains(key) { + key_emphasis_style + } else { + key_style + }; vec![ Span::styled(format!(" {} ", key), style), Span::styled(format!("{} ", desc), desc_style), @@ -1447,7 +1898,12 @@ fn render_status_bar( /// Render mouse text selection highlight overlay and copy tooltip on the diff view. /// `panel_rect` is the main diff panel Rect — selection is rendered only within the selected side. -pub fn render_selection_overlay(frame: &mut Frame, diff_view: &mut DiffViewState, panel_rect: Rect, theme: &Theme) { +pub fn render_selection_overlay( + frame: &mut Frame, + diff_view: &mut DiffViewState, + panel_rect: Rect, + theme: &Theme, +) { use crate::pager::ChangeType; let selection = match &diff_view.selection { @@ -1491,22 +1947,20 @@ pub fn render_selection_overlay(frame: &mut Frame, diff_view: &mut DiffViewState // --- Click state: highlight the clicked cell and show "e edit" tooltip --- if is_click { // Highlight the single clicked cell - if top_row >= pl.inner_y && top_row < pl.inner_end_y - && top_col >= content_start && top_col < content_end + if top_row >= pl.inner_y + && top_row < pl.inner_end_y + && top_col >= content_start + && top_col < content_end && top_row < buf_area.y + buf_area.height { - let highlight_style = Style::default() - .bg(theme.popup_border) - .fg(Color::Black); + let highlight_style = Style::default().bg(theme.popup_border).fg(Color::Black); if let Some(cell) = buf.cell_mut((top_col, top_row)) { cell.set_style(highlight_style); } } if diff_view.file_exists_on_disk { - let tooltip_style = Style::default() - .bg(theme.selected_bg) - .fg(theme.text_strong); + let tooltip_style = Style::default().bg(theme.selected_bg).fg(theme.text_strong); let key_style = Style::default() .bg(theme.selected_bg) .fg(theme.accent_secondary) @@ -1549,9 +2003,7 @@ pub fn render_selection_overlay(frame: &mut Frame, diff_view: &mut DiffViewState let row_start = top_row.max(pl.inner_y); let row_end = bot_row.min(pl.inner_end_y.saturating_sub(1)); - let highlight_style = Style::default() - .bg(theme.popup_border) - .fg(Color::Black); + let highlight_style = Style::default().bg(theme.popup_border).fg(Color::Black); for (i, row) in (row_start..=row_end).enumerate() { if row >= buf_area.y + buf_area.height { @@ -1615,9 +2067,7 @@ pub fn render_selection_overlay(frame: &mut Frame, diff_view: &mut DiffViewState // Tooltip below the selection (only after drag finishes). if !selection.dragging { - let tooltip_style = Style::default() - .bg(theme.selected_bg) - .fg(theme.text_strong); + let tooltip_style = Style::default().bg(theme.selected_bg).fg(theme.text_strong); let key_style = Style::default() .bg(theme.selected_bg) .fg(theme.accent_secondary) @@ -1720,7 +2170,15 @@ fn commit_ai_tooltip_rect(area: Rect, btn_rect: Rect, tip_w: u16) -> Rect { Rect::new(tip_x, tip_y, tip_w, 1) } -pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_frame: usize, theme: &Theme, ai_button_hovered: bool, ai_configured: bool) { +pub fn render_popup( + frame: &mut Frame, + popup: &PopupState, + area: Rect, + spinner_frame: usize, + theme: &Theme, + ai_button_hovered: bool, + ai_configured: bool, +) { // Bail out early on terminals too small to host any popup — better than // panicking inside a render with an out-of-bounds rect. if area.width < 4 || area.height < 4 { @@ -1769,7 +2227,11 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f let widget = Paragraph::new(text).block(block); frame.render_widget(widget, popup_rect); } - PopupState::Message { title, message, kind } => { + PopupState::Message { + title, + message, + kind, + } => { let is_error = *kind == crate::gui::popup::MessageKind::Error; let icon = if is_error { "⚠ " } else { "" }; let inner_width = popup_width.saturating_sub(4) as usize; // borders + padding @@ -1787,7 +2249,11 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f let cy = (area.height.saturating_sub(msg_height)) / 2; let popup_rect = Rect::new(x, cy, popup_width, msg_height); frame.render_widget(Clear, popup_rect); - let border_color = if is_error { Color::Red } else { theme.accent_secondary }; + let border_color = if is_error { + Color::Red + } else { + theme.accent_secondary + }; let block = Block::default() .title(format!(" {}{} ", icon, title)) .borders(Borders::ALL) @@ -1845,7 +2311,9 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f // Hint line (opencode-style: bold key, dim description) let hint_area = Rect::new(inner.x, inner.y + inner.height - 2, inner.width, 1); - let key_style = Style::default().fg(theme.text).add_modifier(ratatui::style::Modifier::BOLD); + let key_style = Style::default() + .fg(theme.text) + .add_modifier(ratatui::style::Modifier::BOLD); let desc_style = Style::default().fg(theme.text_dimmed); let hint_line = Line::from(vec![ Span::styled(" ctrl+s ", key_style), @@ -1861,14 +2329,14 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f let btn_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); let (btn_style, btn_text) = if *confirm_focused { ( - Style::default().fg(Color::Black).bg(theme.accent).add_modifier(ratatui::style::Modifier::BOLD), + Style::default() + .fg(Color::Black) + .bg(theme.accent) + .add_modifier(ratatui::style::Modifier::BOLD), " Confirm ", ) } else { - ( - Style::default().fg(theme.accent), - " Confirm ", - ) + (Style::default().fg(theme.accent), " Confirm ") }; let btn_width = (btn_text.len() as u16).min(btn_area.width); if btn_width > 0 { @@ -1889,7 +2357,9 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f frame.render_widget(textarea, ta_area); let hint_area = Rect::new(inner.x, inner.y + inner.height - 1, inner.width, 1); - let key_style = Style::default().fg(theme.text).add_modifier(ratatui::style::Modifier::BOLD); + let key_style = Style::default() + .fg(theme.text) + .add_modifier(ratatui::style::Modifier::BOLD); let desc_style = Style::default().fg(theme.text_dimmed); let hint_line = Line::from(vec![ Span::styled(" enter ", key_style), @@ -1932,12 +2402,18 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f }); if inner.height > 6 { - let focused_style = Style::default().fg(theme.accent_secondary).add_modifier(Modifier::BOLD); + let focused_style = Style::default() + .fg(theme.accent_secondary) + .add_modifier(Modifier::BOLD); let unfocused_style = Style::default().fg(theme.text_dimmed); // Summary label let summary_label_area = Rect::new(inner.x, inner.y, inner.width, 1); - let summary_label_style = if *focus == CommitInputFocus::Summary { focused_style } else { unfocused_style }; + let summary_label_style = if *focus == CommitInputFocus::Summary { + focused_style + } else { + unfocused_style + }; frame.render_widget( Paragraph::new(Line::from(Span::styled("Summary", summary_label_style))), summary_label_area, @@ -1949,7 +2425,11 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f // Body label let body_label_area = Rect::new(inner.x, inner.y + 3, inner.width, 1); - let body_label_style = if *focus == CommitInputFocus::Body { focused_style } else { unfocused_style }; + let body_label_style = if *focus == CommitInputFocus::Body { + focused_style + } else { + unfocused_style + }; frame.render_widget( Paragraph::new(Line::from(Span::styled("Description", body_label_style))), body_label_area, @@ -2036,7 +2516,10 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f Line::from(vec![ Span::styled(label, Style::default()), Span::raw(" "), - Span::styled(&item.description, Style::default().fg(theme.accent_secondary)), + Span::styled( + &item.description, + Style::default().fg(theme.accent_secondary), + ), ]) } else { Line::from(label) @@ -2212,18 +2695,27 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f Span::styled(": toggle ", Style::default().fg(theme.text_dimmed)), ]; if any_checked { - hint_spans.push(Span::styled("ctrl-a", Style::default().fg(theme.accent_secondary))); + hint_spans.push(Span::styled( + "ctrl-a", + Style::default().fg(theme.accent_secondary), + )); hint_spans.push(Span::styled( ": clear ", Style::default().fg(theme.text_dimmed), )); } - hint_spans.push(Span::styled("enter", Style::default().fg(theme.accent_secondary))); + hint_spans.push(Span::styled( + "enter", + Style::default().fg(theme.accent_secondary), + )); hint_spans.push(Span::styled( ": apply ", Style::default().fg(theme.text_dimmed), )); - hint_spans.push(Span::styled("esc", Style::default().fg(theme.accent_secondary))); + hint_spans.push(Span::styled( + "esc", + Style::default().fg(theme.accent_secondary), + )); hint_spans.push(Span::styled( ": cancel", Style::default().fg(theme.text_dimmed), @@ -2408,8 +2900,9 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f let line = Line::from(spans); if is_selected { - list_items - .push(ListItem::new(line).style(Style::default().bg(theme.selected_bg))); + list_items.push( + ListItem::new(line).style(Style::default().bg(theme.selected_bg)), + ); } else { list_items.push(ListItem::new(line)); } @@ -2433,18 +2926,38 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f } PopupState::RefPicker { title, core, .. } => { render_list_picker( - frame, area, theme, core, + frame, + area, + theme, + core, title, - 70, 72, 36, - &[("↑↓", "navigate"), ("type", "jump to"), ("enter", "select"), ("esc", "cancel")], + 70, + 72, + 36, + &[ + ("↑↓", "navigate"), + ("type", "jump to"), + ("enter", "select"), + ("esc", "cancel"), + ], ); } PopupState::ThemePicker { core, .. } => { render_list_picker( - frame, area, theme, core, + frame, + area, + theme, + core, "Color Theme", - 65, 70, 36, - &[("↑↓", "preview"), ("type", "filter"), ("enter", "apply"), ("esc", "cancel")], + 65, + 70, + 36, + &[ + ("↑↓", "preview"), + ("type", "filter"), + ("enter", "apply"), + ("esc", "cancel"), + ], ); } PopupState::None => {} @@ -2467,9 +2980,7 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f .add_modifier(Modifier::BOLD) } } else if ai_button_hovered { - Style::default() - .fg(theme.text) - .add_modifier(Modifier::BOLD) + Style::default().fg(theme.text).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.text_dimmed) }; @@ -2480,16 +2991,12 @@ pub fn render_popup(frame: &mut Frame, popup: &PopupState, area: Rect, spinner_f } if ai_button_hovered { - let tip_style = Style::default() - .bg(theme.selected_bg) - .fg(theme.text_strong); + 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 dim_style = Style::default() - .bg(theme.selected_bg) - .fg(theme.text_dimmed); + let dim_style = Style::default().bg(theme.selected_bg).fg(theme.text_dimmed); let parts: Vec<(&str, Style)> = if ai_configured { vec![ @@ -2648,12 +3155,19 @@ fn render_list_picker( let is_selected = entry_idx == core.selected; entry_idx += 1; - let base_fg = if is_selected { theme.text_strong } else { theme.text }; + let base_fg = if is_selected { + theme.text_strong + } else { + theme.text + }; let highlight_fg = theme.accent_secondary; // ▸ marker for selected item let marker = if is_selected { "▸ " } else { " " }; - let mut spans = vec![Span::styled(marker, Style::default().fg(theme.accent_secondary))]; + let mut spans = vec![Span::styled( + marker, + Style::default().fg(theme.accent_secondary), + )]; // Build label spans with search match highlighting if !search_lower.is_empty() { @@ -2663,12 +3177,20 @@ fn render_list_picker( let matched = &label[pos..pos + search_lower.len()]; let after = &label[pos + search_lower.len()..]; if !before.is_empty() { - spans.push(Span::styled(before.to_string(), Style::default().fg(base_fg))); + spans.push(Span::styled( + before.to_string(), + Style::default().fg(base_fg), + )); } - let match_style = Style::default().fg(highlight_fg).add_modifier(Modifier::BOLD); + let match_style = Style::default() + .fg(highlight_fg) + .add_modifier(Modifier::BOLD); spans.push(Span::styled(matched.to_string(), match_style)); if !after.is_empty() { - spans.push(Span::styled(after.to_string(), Style::default().fg(base_fg))); + spans.push(Span::styled( + after.to_string(), + Style::default().fg(base_fg), + )); } } else { spans.push(Span::styled(label.clone(), Style::default().fg(base_fg))); @@ -2700,11 +3222,20 @@ fn render_list_picker( let mut hint_spans = Vec::new(); for (i, (key, desc)) in hints.iter().enumerate() { if i == 0 { - hint_spans.push(Span::styled(format!(" {}", key), Style::default().fg(theme.accent_secondary))); + hint_spans.push(Span::styled( + format!(" {}", key), + Style::default().fg(theme.accent_secondary), + )); } else { - hint_spans.push(Span::styled(key.to_string(), Style::default().fg(theme.accent_secondary))); + hint_spans.push(Span::styled( + key.to_string(), + Style::default().fg(theme.accent_secondary), + )); } - hint_spans.push(Span::styled(format!(": {} ", desc), Style::default().fg(theme.text_dimmed))); + hint_spans.push(Span::styled( + format!(": {} ", desc), + Style::default().fg(theme.text_dimmed), + )); } frame.render_widget(Paragraph::new(Line::from(hint_spans)), hint_area); } diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index d940d19..86f4147 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::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; -use crate::config::Theme; +use crate::config::{parse_optional_color, HunkMarkerConfig, Theme}; use super::highlight::FileHighlighter; use super::{ChangeType, DiffLine, InlineSegment}; @@ -218,10 +218,53 @@ pub struct DiffSearchMatch { pub col: usize, } -/// One entry in the diff view's revert-hunk undo stack. -pub struct RevertUndoEntry { - pub file_path: String, - pub pre_revert_bytes: Vec, +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HunkActionKind { + Stage, + Revert, +} + +impl HunkActionKind { + pub fn verb(self) -> &'static str { + match self { + Self::Stage => "stage", + Self::Revert => "revert", + } + } +} + +/// One entry in the diff view's hunk-action undo stack. +pub struct HunkActionUndoEntry { + pub action: HunkActionKind, + pub patch: String, + pub apply_cached: bool, + pub reverse: bool, +} + +#[derive(Clone, Debug)] +pub struct 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. @@ -262,18 +305,19 @@ pub struct DiffViewState { pub search_match_idx: usize, /// Textarea widget for search input. pub search_textarea: Option>, - /// Currently selected revert-button hunk index (for keyboard cycling). - pub selected_revert_hunk: Option, + /// Currently selected hunk-action marker index (for keyboard cycling). + pub selected_hunk: Option, /// Hunk index currently under the mouse cursor (for tooltip rendering). - pub hovered_revert_hunk: Option, - /// Pre-revert file snapshots, most-recent last. Bounded by - /// `REVERT_UNDO_STACK_CAP`. Used to undo revert-hunk actions within a - /// session. - pub revert_undo_stack: Vec, - /// Peak `revert_undo_stack.len()` since it was last empty. Drives the + pub hovered_hunk: Option, + /// Pre-action file snapshots, most-recent last. Bounded by + /// `REVERT_UNDO_STACK_CAP`. Used to undo hunk actions within a session. + pub hunk_action_undo_stack: Vec, + /// Peak `hunk_action_undo_stack.len()` since it was last empty. Drives the /// `n/m` denominator in the bottom-right footnote; resets to 0 once the /// stack drains so a fresh streak starts at `1/1`. - pub revert_undo_high_water: usize, + pub hunk_action_undo_high_water: usize, + pub preferred_selected_hunk_line: Option, + pub center_selected_hunk_on_refresh: bool, } impl Default for DiffViewState { @@ -298,10 +342,12 @@ impl Default for DiffViewState { search_matches: Vec::new(), search_match_idx: 0, search_textarea: None, - selected_revert_hunk: None, - hovered_revert_hunk: None, - revert_undo_stack: Vec::new(), - revert_undo_high_water: 0, + 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, } } } @@ -314,14 +360,15 @@ impl DiffViewState { } } - /// Reset to a fresh state while keeping user preferences (`wrap`) that - /// should survive file/commit navigation. Without this, every reassignment - /// of `diff_view = DiffViewState::new()` would clobber the wrap setting - /// loaded from `state.yml`. + /// Reset diff content but preserve user-controlled preferences (wrap mode, + /// side-view selection). Used when navigating to a new file so that the + /// user's layout choices survive across selections. pub fn reset_keep_prefs(&mut self) { let wrap = self.wrap; + let side_view = self.side_view.clone(); *self = Self::new(); self.wrap = wrap; + self.side_view = side_view; } /// Get the actual file line number for a DiffLine, applying hunk offsets. @@ -581,8 +628,8 @@ impl DiffViewState { /// Apply a pre-parsed diff result, preserving scroll position for same-file reloads. pub fn apply_parsed(&mut self, parsed: ParsedDiff) { let same_file = self.filename == parsed.filename; - let prev_selected_revert_hunk = self.selected_revert_hunk; - let prev_hovered_revert_hunk = self.hovered_revert_hunk; + let prev_selected_hunk = self.selected_hunk; + let prev_hovered_hunk = self.hovered_hunk; self.filename = parsed.filename; self.old_content = parsed.old_content; self.new_content = parsed.new_content; @@ -591,13 +638,22 @@ impl DiffViewState { self.hunk_line_offsets = parsed.hunk_line_offsets; self.sections = parsed.sections; self.file_exists_on_disk = parsed.file_exists_on_disk; - self.selected_revert_hunk = if same_file { - prev_selected_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + 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.hovered_revert_hunk = if same_file { - prev_hovered_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + self.preferred_selected_hunk_line = None; + self.hovered_hunk = if same_file { + prev_hovered_hunk.filter(|&i| i < self.hunk_starts.len()) } else { None }; @@ -616,8 +672,8 @@ impl DiffViewState { pub fn load(&mut self, filename: &str, old: &str, new: &str) { // Preserve scroll position when reloading the same file (e.g. periodic refresh) let same_file = self.filename == filename; - let prev_selected_revert_hunk = self.selected_revert_hunk; - let prev_hovered_revert_hunk = self.hovered_revert_hunk; + let prev_selected_hunk = self.selected_hunk; + let prev_hovered_hunk = self.hovered_hunk; self.filename = filename.to_string(); self.old_content = old.to_string(); self.new_content = new.to_string(); @@ -634,13 +690,13 @@ impl DiffViewState { self.selection = None; self.clear_search(); } - self.selected_revert_hunk = if same_file { - prev_selected_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + self.selected_hunk = if same_file { + prev_selected_hunk.filter(|&i| i < self.hunk_starts.len()) } else { None }; - self.hovered_revert_hunk = if same_file { - prev_hovered_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + self.hovered_hunk = if same_file { + prev_hovered_hunk.filter(|&i| i < self.hunk_starts.len()) } else { None }; @@ -696,8 +752,8 @@ impl DiffViewState { let file_count = file_diffs.len(); let new_filename = format!("{} ({} files)", filename, file_count); let same_file = self.filename == new_filename; - let prev_selected_revert_hunk = self.selected_revert_hunk; - let prev_hovered_revert_hunk = self.hovered_revert_hunk; + let prev_selected_hunk = self.selected_hunk; + let prev_hovered_hunk = self.hovered_hunk; self.filename = new_filename; self.old_content = String::new(); self.new_content = String::new(); @@ -709,16 +765,8 @@ impl DiffViewState { self.selection = None; self.clear_search(); } - self.selected_revert_hunk = if same_file { - prev_selected_revert_hunk - } else { - None - }; - self.hovered_revert_hunk = if same_file { - prev_hovered_revert_hunk - } else { - None - }; + 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(); @@ -766,15 +814,13 @@ 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()) + self.selected_hunk = if same_file { + self.selected_hunk.filter(|&i| i < self.hunk_starts.len()) } else { None }; - self.hovered_revert_hunk = if same_file { - self.hovered_revert_hunk - .filter(|&i| i < self.hunk_starts.len()) + self.hovered_hunk = if same_file { + self.hovered_hunk.filter(|&i| i < self.hunk_starts.len()) } else { None }; @@ -807,6 +853,7 @@ impl DiffViewState { pub fn next_hunk(&mut self) { if let Some(next) = self.hunk_starts.iter().find(|&&h| h > self.scroll_offset) { self.scroll_offset = *next; + self.selected_hunk = self.hunk_index_for_start_line(*next); } } @@ -818,6 +865,7 @@ impl DiffViewState { .find(|&&h| h < self.scroll_offset) { self.scroll_offset = *prev; + self.selected_hunk = self.hunk_index_for_start_line(*prev); } } @@ -825,45 +873,18 @@ impl DiffViewState { self.lines.is_empty() } - /// 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)> { + /// 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 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; + let idx = self.scroll_offset + (row - layout.inner_y) as usize; + if idx < self.lines.len() { + Some(idx) + } else { + None } - None } /// Return true when the given line index is the first line of a diff hunk. @@ -922,6 +943,43 @@ impl DiffViewState { Some((old_range, new_range)) } + /// Return the inclusive visual line range for a revertable hunk in the diff buffer. + pub fn visual_block_line_span(&self, block_idx: usize) -> Option<(usize, usize)> { + let start = *self.hunk_starts.get(block_idx)?; + let mut end = start; + while end < self.lines.len() && !matches!(self.lines[end].change_type, ChangeType::Equal) { + end += 1; + } + Some((start, end.saturating_sub(1))) + } + + /// Return the line index where the sticky 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, @@ -938,45 +996,40 @@ impl DiffViewState { offsets } - /// 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) { + /// Cycle to the next hunk marker. + pub fn select_next_hunk(&mut self) { if self.hunk_starts.is_empty() { - self.selected_revert_hunk = None; + self.selected_hunk = None; return; } - let next = match self.selected_revert_hunk { + let next = match self.selected_hunk { Some(i) => (i + 1) % self.hunk_starts.len(), - None => self - .hunk_starts - .iter() - .position(|&h| h > self.scroll_offset) - .unwrap_or(0), + None => 0, }; - self.selected_revert_hunk = Some(next); - self.scroll_offset = self.hunk_starts[next]; + self.selected_hunk = Some(next); } - /// 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) { + /// Cycle to the previous hunk marker. + pub fn select_prev_hunk(&mut self) { if self.hunk_starts.is_empty() { - self.selected_revert_hunk = None; + self.selected_hunk = None; return; } - let prev = match self.selected_revert_hunk { - 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), + let prev = match self.selected_hunk { + Some(0) | None => self.hunk_starts.len() - 1, + Some(i) => i.saturating_sub(1), }; - self.selected_revert_hunk = Some(prev); - self.scroll_offset = self.hunk_starts[prev]; + 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. @@ -997,6 +1050,7 @@ pub fn render_diff( area: Rect, state: &DiffViewState, theme: &Theme, + marker_cfg: &HunkMarkerStyle, focused: bool, diff_loading: bool, show_revert_markers: bool, @@ -1006,6 +1060,7 @@ pub fn render_diff( } else { theme.inactive_border }; + let default_marker_color = border_style.fg.unwrap_or(theme.separator); if state.is_empty() { let msg = if diff_loading { @@ -1038,13 +1093,13 @@ pub fn render_diff( .borders(Borders::ALL) .border_style(border_style); - // Bottom-right footnote: revert-hunk undo indicator. Only shown when + // Bottom-right footnote: hunk-action undo indicator. Only shown when // there's something to undo; the denominator is the peak stack depth // since it last drained, so a streak reads `1/1`, `2/2`, ... and undos // walk it back down to `1/3` etc. - let undo_n = state.revert_undo_stack.len(); + let undo_n = state.hunk_action_undo_stack.len(); if undo_n > 0 { - let undo_m = state.revert_undo_high_water.max(undo_n); + let undo_m = state.hunk_action_undo_high_water.max(undo_n); block = block.title_bottom( Line::from(vec![ Span::styled(" ", Style::default().fg(theme.text_dimmed)), @@ -1055,7 +1110,7 @@ pub fn render_diff( .add_modifier(Modifier::BOLD), ), Span::styled( - format!(" undo revert ({}/{}) ", undo_n, undo_m), + format!(" undo hunk action ({}/{}) ", undo_n, undo_m), Style::default().fg(theme.text_dimmed), ), ]) @@ -1381,38 +1436,45 @@ pub fn render_diff( ); } - // Divider or revert marker (first visual row of a hunk only). - let show_marker = show_revert_markers - && 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) + // 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_revert_hunk; - let (divider_char, marker_style) = if show_marker { - let is_selected = marker_hunk_idx == state.selected_revert_hunk; + && marker_hunk_idx == state.hovered_hunk; + let (divider_chars, marker_style) = if show_marker { + let is_selected = marker_hunk_idx == state.selected_hunk; // Hover wins over selection so the hover state is always // visible — even on a hunk that's currently selected. let fg = if marker_is_hovered { - theme.accent_secondary + marker_cfg.hover_color.unwrap_or(theme.accent_secondary) } else if is_selected { - theme.accent + marker_cfg.selected_color.unwrap_or(default_marker_color) + } else { + marker_cfg.color.unwrap_or(default_marker_color) + }; + let mut style = Style::default().fg(fg); + if marker_cfg.bold { + style = style.add_modifier(Modifier::BOLD); + } + let chars = if is_selected { + "" } else { - theme.separator + marker_cfg.icon.as_str() }; - ("󰧛", Style::default().fg(fg).add_modifier(Modifier::BOLD)) + (chars, style) } else { ("│", divider_style) }; buf_write_str(buf, div_x, y, " ", divider_style, divider_width); - buf_write_str(buf, div_x, y, divider_char, marker_style, divider_width); + buf_write_str(buf, div_x, y, divider_chars, marker_style, divider_width); if show_marker && marker_hunk_idx.is_some() - && marker_hunk_idx == state.hovered_revert_hunk + && marker_hunk_idx == state.hovered_hunk { hover_tooltip_y = Some(y); } @@ -1491,35 +1553,41 @@ pub fn render_diff( state.horizontal_scroll, ); - // 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) + // 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_revert_hunk; - let (divider_char, marker_style) = if show_marker { - let is_selected = marker_hunk_idx == state.selected_revert_hunk; + && marker_hunk_idx == state.hovered_hunk; + let (divider_chars, marker_style) = if show_marker { + let is_selected = marker_hunk_idx == state.selected_hunk; let fg = if marker_is_hovered { - theme.accent_secondary + marker_cfg.hover_color.unwrap_or(theme.accent_secondary) } else if is_selected { - theme.accent + marker_cfg.selected_color.unwrap_or(default_marker_color) } else { - theme.separator + marker_cfg.color.unwrap_or(default_marker_color) }; - ("󰧛", Style::default().fg(fg).add_modifier(Modifier::BOLD)) + let mut style = Style::default().fg(fg); + if marker_cfg.bold { + style = style.add_modifier(Modifier::BOLD); + } + let chars = if is_selected { + "" + } else { + marker_cfg.icon.as_str() + }; + (chars, style) } else { ("│", divider_style) }; buf_write_str(buf, div_x, y, " ", divider_style, divider_width); - buf_write_str(buf, div_x, y, divider_char, marker_style, divider_width); - if show_marker - && marker_hunk_idx.is_some() - && marker_hunk_idx == state.hovered_revert_hunk + 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); } @@ -1568,9 +1636,9 @@ pub fn render_diff( } if let Some(y) = hover_tooltip_y { - let show_key = state.hovered_revert_hunk.is_some() - && state.hovered_revert_hunk == state.selected_revert_hunk; - render_revert_tooltip( + let show_key = + state.hovered_hunk.is_some() && state.hovered_hunk == state.selected_hunk; + render_hunk_action_tooltip( buf, div_x + divider_width, y, @@ -1582,7 +1650,7 @@ pub fn render_diff( } } -fn render_revert_tooltip( +fn render_hunk_action_tooltip( buf: &mut Buffer, x: u16, y: u16, @@ -1599,11 +1667,13 @@ fn render_revert_tooltip( let parts: Vec<(&str, Style)> = if show_key { vec![ (" ", tip_style), - ("enter", key_style), - (" Revert hunk ", tip_style), + ("a", key_style), + (" stage ", tip_style), + ("r", key_style), + (" revert ", tip_style), ] } else { - vec![(" Revert hunk ", tip_style)] + vec![(" Stage/revert hunk ", tip_style)] }; let buf_area = buf.area(); @@ -1725,74 +1795,6 @@ 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![]]; 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