diff --git a/README.md b/README.md index 21bb39d..a26d64c 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,21 @@ A faster, memory-safe, more ergonomic slopfork of lazygit (🦀 rust btw). -1 - This is mostly a "for me" tool — built for my own workflow. Not saying you shouldn't use it, but don't expect it to be a community project. But hey, it works for me! **Why fork?** PRs were sitting too long, or the upstream direction didn't match how I wanted to work. -2 -2.1 - The goal: everything lazygit does, but faster and with opinions I actually agree with. (I can't promise backwards-compat w/ lazygit's config since it'll eventually drift w/ my own opinions, but I made sure to do that) ![demo1](https://raw.githubusercontent.com/Blankeos/lazygitrs/main/_docs/demo1.webp) ![demo2](https://raw.githubusercontent.com/Blankeos/lazygitrs/main/_docs/demo2.webp) -3 - ### Install > Make sure you have: -> gh +> +> - [git](https://git-scm.com) +> - [gh](https://cli.github.com) ```sh npm install -g lazygitrs # npm @@ -127,5 +122,3 @@ Summary MIT Feel free to fork and give it your own spin. -last -1 diff --git a/npm/README.md b/npm/README.md index ae0d2c2..a26d64c 100644 --- a/npm/README.md +++ b/npm/README.md @@ -13,6 +13,11 @@ The goal: everything lazygit does, but faster and with opinions I actually agree ### Install +> Make sure you have: +> +> - [git](https://git-scm.com) +> - [gh](https://cli.github.com) + ```sh npm install -g lazygitrs # npm bun install -g lazygitrs # or bun diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index de24421..9d47ce2 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -110,6 +110,10 @@ pub struct UniversalKeybinding { pub prev_screen_mode: String, #[serde(rename = "createPatchOptionsMenu")] pub create_patch_options_menu: String, + #[serde(rename = "revertBlock")] + pub revert_block: String, + #[serde(rename = "undoRevertBlock")] + pub undo_revert_block: String, } impl Default for UniversalKeybinding { @@ -157,6 +161,8 @@ impl Default for UniversalKeybinding { next_screen_mode: "+".into(), prev_screen_mode: "_".into(), create_patch_options_menu: "".into(), + revert_block: "".into(), + undo_revert_block: "u".into(), } } } diff --git a/src/git/staging.rs b/src/git/staging.rs index c2141bb..326153f 100644 --- a/src/git/staging.rs +++ b/src/git/staging.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result, bail}; use super::GitCommands; @@ -77,6 +77,148 @@ impl GitCommands { }; Ok(self.parse_diff_hunks(&diff)) } + + /// Reverse-apply only the lines of a single visual change block to the + /// working tree copy of `file_path`. `want_old` and `want_new` are the + /// inclusive old-file and new-file line-number ranges the visual block + /// covers; either may be `None` for pure insertion or pure deletion. + /// The block typically lives inside one of the `@@` hunks of + /// `unified_diff`, but may be narrower than that `@@` — visual blocks + /// can be split by 1–3 lines of context within a single `@@`. + pub fn revert_visual_block_in_worktree( + &self, + file_path: &str, + unified_diff: &str, + want_old: Option<(usize, usize)>, + want_new: Option<(usize, usize)>, + ) -> Result<()> { + let patch = build_visual_block_patch(file_path, unified_diff, want_old, want_new)?; + self.git() + .args(&["apply", "--reverse", "--unidiff-zero", "-"]) + .stdin(patch) + .run_expecting_success() + .with_context(|| format!("failed to revert hunk in {}", file_path))?; + Ok(()) + } +} + +fn build_visual_block_patch( + file_path: &str, + unified_diff: &str, + want_old: Option<(usize, usize)>, + want_new: Option<(usize, usize)>, +) -> Result { + if want_old.is_none() && want_new.is_none() { + bail!("empty visual block"); + } + + let mut emitted: Vec = Vec::new(); + let mut anchor_old: Option = None; + let mut anchor_new: Option = None; + let mut old_count = 0usize; + let mut new_count = 0usize; + + let mut in_hunk = false; + let mut old_counter = 0usize; + let mut new_counter = 0usize; + let mut last_emitted = false; + + for line in unified_diff.lines() { + if line.starts_with("@@") { + let (os, _, ns, _) = parse_hunk_header(line); + old_counter = os; + new_counter = ns; + in_hunk = true; + last_emitted = false; + continue; + } + if !in_hunk { + continue; + } + // A new file's preamble can interleave between hunks of multi-file + // diffs; abandon the current hunk until we see a fresh `@@`. + if line.starts_with("diff ") + || line.starts_with("--- ") + || line.starts_with("+++ ") + || line.starts_with("index ") + || line.starts_with("similarity ") + || line.starts_with("rename ") + || line.starts_with("new file ") + || line.starts_with("deleted file ") + || line.starts_with("Binary ") + { + in_hunk = false; + last_emitted = false; + continue; + } + // A "\ No newline at end of file" marker refers to the immediately + // preceding diff line. Propagate it only when that line was emitted. + if line.starts_with('\\') { + if last_emitted { + emitted.push(line.to_string()); + } + continue; + } + if line.starts_with('-') { + let in_range = + want_old.is_some_and(|(lo, hi)| old_counter >= lo && old_counter <= hi); + if in_range { + if anchor_old.is_none() { + anchor_old = Some(old_counter); + } + if anchor_new.is_none() { + anchor_new = Some(new_counter); + } + emitted.push(line.to_string()); + old_count += 1; + last_emitted = true; + } else { + last_emitted = false; + } + old_counter += 1; + } else if line.starts_with('+') { + let in_range = + want_new.is_some_and(|(lo, hi)| new_counter >= lo && new_counter <= hi); + if in_range { + if anchor_old.is_none() { + anchor_old = Some(old_counter); + } + if anchor_new.is_none() { + anchor_new = Some(new_counter); + } + emitted.push(line.to_string()); + new_count += 1; + last_emitted = true; + } else { + last_emitted = false; + } + new_counter += 1; + } else if line.starts_with(' ') || line.is_empty() { + old_counter += 1; + new_counter += 1; + last_emitted = false; + } + } + + if emitted.is_empty() { + bail!("visual block matched no diff lines"); + } + + let old_start = anchor_old.unwrap_or(0); + let new_start = anchor_new.unwrap_or(0); + + let mut patch = String::new(); + patch.push_str(&format!("--- a/{}\n", file_path)); + patch.push_str(&format!("+++ b/{}\n", file_path)); + patch.push_str(&format!( + "@@ -{},{} +{},{} @@\n", + old_start, old_count, new_start, new_count + )); + for line in &emitted { + patch.push_str(line); + patch.push('\n'); + } + Ok(patch) } fn parse_hunk_header(header: &str) -> (usize, usize, usize, usize) { diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 8226688..68970fa 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -13,7 +13,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::time::Instant; -use anyhow::Result; +use anyhow::{Context, Result}; use crossterm::event::{self, Event, KeyCode, KeyEvent, MouseEvent}; use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::{execute, cursor}; @@ -1982,6 +1982,34 @@ impl Gui { } } + if matches_key(key, &keybindings.universal.revert_block) { + if self.context_mgr.active() == ContextId::Files { + let hunk_idx = self + .diff_view + .selected_revert_hunk + .or(self.diff_view.hovered_revert_hunk); + if let Some(hunk_idx) = hunk_idx { + self.diff_view.selected_revert_hunk = Some(hunk_idx); + self.show_hunk_context_menu(hunk_idx); + } + } + return Ok(()); + } + if matches_key(key, &keybindings.universal.undo_revert_block) { + if self.context_mgr.active() == ContextId::Files + && !self.diff_view.revert_undo_stack.is_empty() + { + if let Err(err) = self.undo_last_revert_block() { + self.popup = PopupState::Message { + title: "Undo revert failed".to_string(), + message: format!("{}", err), + kind: MessageKind::Error, + }; + } + } + return Ok(()); + } + // Toggle command log (;) if key.code == KeyCode::Char(';') { self.show_command_log = !self.show_command_log; @@ -2018,9 +2046,11 @@ impl Gui { } match key.code { - // Escape: clear search first, then unfocus diff + // Escape: clear revert-hunk selection first, then search, then unfocus diff KeyCode::Esc => { - if !self.diff_view.search_query.is_empty() { + if self.diff_view.selected_revert_hunk.is_some() { + self.diff_view.selected_revert_hunk = None; + } else if !self.diff_view.search_query.is_empty() { self.diff_view.clear_search(); } else { self.diff_focused = false; @@ -2044,12 +2074,23 @@ impl Gui { KeyCode::Char('l') | KeyCode::Right => { self.diff_view.scroll_right(4); } - // { and } jump between hunks + // { and } jump between hunks. In Files context they also select + // the hunk as the revert target so the marker glyph turns + // accent-coloured; the scroll motion stays the same as plain + // hunk navigation (always jumps, even if already in viewport). KeyCode::Char('}') => { - self.diff_view.next_hunk(); + if self.context_mgr.active() == ContextId::Files { + self.diff_view.cycle_next_revert_hunk(); + } else { + self.diff_view.next_hunk(); + } } KeyCode::Char('{') => { - self.diff_view.prev_hunk(); + if self.context_mgr.active() == ContextId::Files { + self.diff_view.cycle_prev_revert_hunk(); + } else { + self.diff_view.prev_hunk(); + } } // [ and ] toggle old-only / new-only view KeyCode::Char(']') => { @@ -3267,6 +3308,9 @@ impl Gui { HelpEntry { key: kb.universal.edit.clone(), description: "Open in editor".into() }, HelpEntry { key: kb.universal.open_file.clone(), description: "Open in default program".into() }, HelpEntry { key: "y".into(), description: "Copy to clipboard menu".into() }, + HelpEntry { key: "{/}".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() }, ], }, ContextId::Worktrees => HelpSection { @@ -3447,7 +3491,7 @@ impl Gui { entries: vec![ HelpEntry { key: "j/k".into(), description: "Scroll down / up".into() }, HelpEntry { key: "h/l".into(), description: "Scroll left / right".into() }, - HelpEntry { key: "{/}".into(), description: "Previous / next hunk".into() }, + HelpEntry { key: "{/}".into(), description: "Cycle prev / next hunk (selects revert block in Files)".into() }, HelpEntry { key: "[".into(), description: "Toggle old-only view".into() }, HelpEntry { key: "]".into(), description: "Toggle new-only view".into() }, HelpEntry { key: "z".into(), description: "Toggle line wrap".into() }, @@ -3455,6 +3499,20 @@ 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: "u".into(), + description: if self.diff_view.revert_undo_stack.is_empty() { + "Undo last revert (nothing to undo)".into() + } else { + format!( + "Undo last revert ({}/{})", + self.diff_view.revert_undo_stack.len(), + self.diff_view.revert_undo_high_water, + ) + }, + }, HelpEntry { key: "e".into(), description: "Edit file at line".into() }, HelpEntry { key: "o".into(), description: "Open file in default program".into() }, HelpEntry { key: "y".into(), description: "Copy selected text".into() }, @@ -4306,6 +4364,15 @@ impl Gui { let main_panel = self.compute_main_panel_rect(); let pl = DiffPanelLayout::compute(main_panel, &self.diff_view); + // Track mouse hover over the revert-block marker (for tooltip). + if !self.diff_mode.active { + let new_hover = + self.revert_hunk_at_position(main_panel, &pl, mouse.column, mouse.row); + if self.diff_view.hovered_revert_hunk != new_hover { + self.diff_view.hovered_revert_hunk = new_hover; + } + } + match mouse.kind { MouseEventKind::Down(MouseButton::Left) => { let in_main = main_panel.x <= mouse.column @@ -4319,6 +4386,10 @@ impl Gui { let full_sidebar = self.screen_mode == ScreenMode::Full && !self.diff_focused; if in_main && !self.diff_view.is_empty() && !full_sidebar { + if self.try_handle_revert_block_click(main_panel, pl, mouse.column, mouse.row) { + self.diff_focused = true; + return; + } if let Some(panel) = pl.panel_at_x(mouse.column) { self.diff_view.selection = Some(TextSelection { panel, @@ -4653,6 +4724,10 @@ impl Gui { // Check if click is in the diff panel — start text selection if rect_contains(diff_rect, col, row) && !self.diff_view.is_empty() { let pl = DiffPanelLayout::compute(diff_rect, &self.diff_view); + if self.try_handle_revert_block_click(diff_rect, pl, col, row) { + self.diff_mode.focus = DiffModeFocus::DiffExploration; + return; + } if let Some(panel) = pl.panel_at_x(col) { self.diff_view.selection = Some(TextSelection { panel, @@ -4950,6 +5025,176 @@ impl Gui { self.compute_current_frame_layout().main_panel } + fn revert_hunk_at_position( + &self, + panel_rect: ratatui::layout::Rect, + layout: &DiffPanelLayout, + col: u16, + row: u16, + ) -> Option { + if self.context_mgr.active() != ContextId::Files { + return None; + } + if self.diff_view.is_empty() { + return None; + } + if !rect_contains(panel_rect, col, row) { + return None; + } + let divider_x = layout.divider_x()?; + if col != divider_x { + 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) + } + + fn try_handle_revert_block_click( + &mut self, + panel_rect: ratatui::layout::Rect, + layout: DiffPanelLayout, + col: u16, + row: u16, + ) -> bool { + if self.diff_mode.active { + return false; + } + let Some(hunk_idx) = self.revert_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, + }; + } + true + } + + /// Open the hunk action menu (shown when Enter is pressed on a selected + /// or hovered revert hunk). Cancel is focused first so an accidental + /// Enter doesn't revert anything. + fn show_hunk_context_menu(&mut self, hunk_idx: usize) { + let items = vec![ + popup::MenuItem { + label: "Cancel".to_string(), + description: String::new(), + key: None, + // No-op: execute_menu_action already drops the menu popup + // before invoking the action, so returning Ok leaves the + // menu closed. Esc also closes the menu via the universal + // menu Esc handler. + action: Some(Box::new(|_gui| Ok(()))), + }, + popup::MenuItem { + label: "Revert hunk".to_string(), + description: String::new(), + key: None, + action: Some(Box::new(move |gui| { + if let Err(err) = gui.revert_selected_file_hunk(hunk_idx) { + gui.popup = PopupState::Message { + title: "Revert block failed".to_string(), + message: format!("{}", err), + kind: MessageKind::Error, + }; + } + Ok(()) + })), + }, + ]; + + self.popup = PopupState::Menu { + title: "Hunk".to_string(), + items, + selected: 0, + loading_index: None, + }; + } + + fn revert_selected_file_hunk(&mut self, hunk_idx: usize) -> Result<()> { + let Some(file_idx) = self.selected_file_index() else { + return Ok(()); + }; + + let model = self.model.lock().unwrap(); + let Some(file) = model.files.get(file_idx) else { + return Ok(()); + }; + + if !file.has_unstaged_changes { + self.popup = PopupState::Message { + title: "Revert block".to_string(), + message: "Block revert is available only for unstaged changes.".to_string(), + kind: MessageKind::Info, + }; + return Ok(()); + } + + let file_name = file.name.clone(); + drop(model); + + let 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(()); + } + + // Snapshot the working-tree file before reverting so the user can undo + // (`u`) within this session. Only keep the snapshot if the revert + // actually succeeds; otherwise we'd leak unrelated state into the stack. + let abs_path = self.git.repo_path().join(&file_name); + let pre_bytes = std::fs::read(&abs_path).ok(); + + self.git + .revert_visual_block_in_worktree(&file_name, &diff, want_old, want_new)?; + + if let Some(bytes) = pre_bytes { + let stack = &mut self.diff_view.revert_undo_stack; + if stack.len() >= crate::pager::side_by_side::REVERT_UNDO_STACK_CAP { + stack.remove(0); + } + stack.push(crate::pager::side_by_side::RevertUndoEntry { + file_path: file_name.clone(), + pre_revert_bytes: bytes, + }); + self.diff_view.revert_undo_high_water = + self.diff_view.revert_undo_high_water.max(stack.len()); + } + + self.diff_view.selection = None; + self.needs_files_refresh = true; + self.needs_diff_refresh = true; + Ok(()) + } + + fn undo_last_revert_block(&mut self) -> Result<()> { + let Some(entry) = self.diff_view.revert_undo_stack.pop() else { + return Ok(()); + }; + let abs_path = self.git.repo_path().join(&entry.file_path); + std::fs::write(&abs_path, &entry.pre_revert_bytes).with_context(|| { + format!("failed to restore {}", entry.file_path) + })?; + if self.diff_view.revert_undo_stack.is_empty() { + self.diff_view.revert_undo_high_water = 0; + } + self.needs_files_refresh = true; + self.needs_diff_refresh = true; + Ok(()) + } + /// Approximate visible height of the active sidebar panel (inner area minus borders). fn sidebar_visible_height(&self) -> usize { let fl = self.compute_current_frame_layout(); diff --git a/src/gui/presentation/diff_mode.rs b/src/gui/presentation/diff_mode.rs index 71e843a..1c524fe 100644 --- a/src/gui/presentation/diff_mode.rs +++ b/src/gui/presentation/diff_mode.rs @@ -283,7 +283,7 @@ fn render_diff_panel( let focused = state.focus == DiffModeFocus::DiffExploration; if !diff_view.is_empty() { - side_by_side::render_diff(frame, area, diff_view, theme, focused, diff_loading); + side_by_side::render_diff(frame, area, diff_view, theme, focused, diff_loading, false); side_by_side::render_diff_search_highlights(frame, area, diff_view, theme); side_by_side::render_diff_search_bar(frame, area, diff_view, theme); } else { diff --git a/src/gui/views.rs b/src/gui/views.rs index c51cadd..e05de86 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -98,7 +98,8 @@ pub fn render( if diff_focused { // Diff is focused: show diff fullscreen if !diff_view.is_empty() { - side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, true, diff_loading_show); + let show_revert_markers = ctx_mgr.active() == ContextId::Files; + side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, true, diff_loading_show, show_revert_markers); side_by_side::render_diff_search_highlights(frame, fl.main_panel, diff_view, theme); side_by_side::render_diff_search_bar(frame, fl.main_panel, diff_view, theme); } else if diff_loading { @@ -242,7 +243,7 @@ pub fn render( commit_details_scroll, ); } - render_status_bar(frame, fl.status_bar, ctx_mgr, diff_view, theme, model); + render_status_bar(frame, fl.status_bar, ctx_mgr, diff_view, theme, model, diff_focused); // Render text selection highlight overlay and tooltip (must be before popup) render_selection_overlay(frame, diff_view, fl.main_panel, theme); if *popup != PopupState::None { @@ -584,7 +585,8 @@ pub fn render( .border_style(theme.active_border); render_status_main(frame, fl.main_panel, model, config, theme, status_block); } else if !diff_view.is_empty() { - side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, diff_focused, diff_loading_show); + let show_revert_markers = ctx_mgr.active() == ContextId::Files; + side_by_side::render_diff(frame, fl.main_panel, diff_view, theme, diff_focused, diff_loading_show, show_revert_markers); side_by_side::render_diff_search_highlights(frame, fl.main_panel, diff_view, theme); side_by_side::render_diff_search_bar(frame, fl.main_panel, diff_view, theme); } else if diff_loading { @@ -685,7 +687,7 @@ pub fn render( frame.render_widget(bar, fl.status_bar); } } else { - render_status_bar(frame, fl.status_bar, ctx_mgr, diff_view, theme, model); + render_status_bar(frame, fl.status_bar, ctx_mgr, diff_view, theme, model, diff_focused); } // Render text selection highlight overlay and tooltip @@ -1347,8 +1349,10 @@ fn render_status_bar( diff_view: &DiffViewState, _theme: &crate::config::Theme, model: &Model, + diff_focused: bool, ) { let mut hints: Vec<(&str, &str)> = Vec::new(); + let mut emphasized: Vec<&str> = Vec::new(); // When in a special state (rebasing/merging/cherry-picking), show those options prominently if model.is_rebasing { @@ -1359,53 +1363,79 @@ fn render_status_bar( hints.push(("m", "continue/abort cherry-pick")); } - // Context-specific hints - match ctx_mgr.active() { - ContextId::Files => { - hints.extend([("c", "commit"), ("a", "stage all"), ("space", "toggle"), ("d", "discard"), ("e", "edit"), ("o", "open")]); - } - ContextId::Branches => { - hints.extend([("space", "checkout"), ("n", "new"), ("d", "delete"), ("M", "merge"), ("r", "rebase")]); - } - ContextId::Commits => { - hints.extend([("r", "reword"), ("g", "reset"), ("t", "revert"), ("C", "cherry-pick"), ("ctrl+l", "filter branch")]); - } - ContextId::Stash => { - hints.extend([("g", "pop"), ("space", "apply"), ("d", "drop")]); - } - ContextId::Remotes => { - hints.extend([("enter", "branches"), ("f", "fetch"), ("P", "push"), ("p", "pull")]); - } - ContextId::RemoteBranches => { - hints.extend([("enter", "commits"), ("space", "checkout"), ("M", "merge"), ("r", "rebase"), ("d", "delete")]); - } - ContextId::Tags => { - hints.extend([("n", "new"), ("d", "delete"), ("P", "push")]); + if diff_focused && !diff_view.is_empty() { + // Diff-focused hint set: only the diff-relevant keys, kept tight. + // Revert-related keys are grouped together at the front so users see + // 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 mut idx = 0; + if has_selection { + hints.insert(idx, ("enter", "hunk menu")); + emphasized.push("enter"); + idx += 1; + } + hints.insert(idx, ("{/}", "cycle hunks")); + idx += 1; + if has_undo { + hints.insert(idx, ("u", "undo revert")); + } + } else { + hints.push(("{/}", "prev/next hunk")); } - ContextId::Worktrees => { - hints.extend([("space", "switch"), ("n", "new"), ("d", "remove")]); + hints.push(("[/]", "side view")); + } else { + // Sidebar-focused: context-specific hints. + match ctx_mgr.active() { + ContextId::Files => { + hints.extend([("c", "commit"), ("a", "stage all"), ("space", "toggle"), ("d", "discard"), ("e", "edit"), ("o", "open")]); + } + ContextId::Branches => { + hints.extend([("space", "checkout"), ("n", "new"), ("d", "delete"), ("M", "merge"), ("r", "rebase")]); + } + ContextId::Commits => { + hints.extend([("r", "reword"), ("g", "reset"), ("t", "revert"), ("C", "cherry-pick"), ("ctrl+l", "filter branch")]); + } + ContextId::Stash => { + hints.extend([("g", "pop"), ("space", "apply"), ("d", "drop")]); + } + ContextId::Remotes => { + hints.extend([("enter", "branches"), ("f", "fetch"), ("P", "push"), ("p", "pull")]); + } + ContextId::RemoteBranches => { + hints.extend([("enter", "commits"), ("space", "checkout"), ("M", "merge"), ("r", "rebase"), ("d", "delete")]); + } + ContextId::Tags => { + hints.extend([("n", "new"), ("d", "delete"), ("P", "push")]); + } + ContextId::Worktrees => { + hints.extend([("space", "switch"), ("n", "new"), ("d", "remove")]); + } + ContextId::Submodules => { + hints.extend([("space", "update"), ("a", "add"), ("d", "remove"), ("e", "enter")]); + } + _ => {} } - ContextId::Submodules => { - hints.extend([("space", "update"), ("a", "add"), ("d", "remove"), ("e", "enter")]); + if !diff_view.is_empty() { + hints.push(("J/K", "scroll diff")); + hints.push(("{/}", "hunks")); } - _ => {} } - // Global hints + // Global hints (always last) hints.extend([("q", "quit"), ("tab/1-5", "panels"), ("j/k", "nav")]); - // Diff scroll info - if !diff_view.is_empty() { - hints.extend([("J/K", "scroll diff"), ("{/}", "hunks")]); - } - let key_style = Style::default().fg(_theme.text).add_modifier(ratatui::style::Modifier::BOLD); + let key_emphasis_style = Style::default().fg(_theme.accent).add_modifier(ratatui::style::Modifier::BOLD); let desc_style = Style::default().fg(_theme.text_dimmed); let spans: Vec = hints .iter() .flat_map(|(key, desc)| { + let style = if emphasized.contains(key) { key_emphasis_style } else { key_style }; vec![ - Span::styled(format!(" {} ", key), key_style), + Span::styled(format!(" {} ", key), style), Span::styled(format!("{} ", desc), desc_style), ] }) diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index 955b1b6..d940d19 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -1,9 +1,9 @@ +use ratatui::Frame; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::Span; +use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; -use ratatui::Frame; use crate::config::Theme; @@ -103,7 +103,7 @@ impl DiffPanelLayout { let inner_end_y = inner_y + panel_rect.height.saturating_sub(2); let gutter: u16 = 5; - let divider: u16 = 1; + let divider: u16 = 2; let is_new_file = state.old_content.is_empty() && state.sections.len() <= 1; @@ -134,7 +134,11 @@ impl DiffPanelLayout { } } else { let total_chrome = gutter * 2 + divider; - let content_w = if inner_w > total_chrome { inner_w - total_chrome } else { 0 }; + let content_w = if inner_w > total_chrome { + inner_w - total_chrome + } else { + 0 + }; let panel_w = content_w / 2; let old_content_x = inner_x + gutter; @@ -184,6 +188,15 @@ impl DiffPanelLayout { DiffPanel::New => (self.new_content_x, self.new_content_end_x), } } + + /// Get the divider X column between old and new panels (both-side view only). + pub fn divider_x(&self) -> Option { + if self.is_new_file || self.old_content_end_x == 0 || self.new_content_x == 0 { + None + } else { + Some(self.old_content_end_x) + } + } } /// Which side(s) of the diff to display. @@ -205,6 +218,15 @@ pub struct DiffSearchMatch { pub col: usize, } +/// One entry in the diff view's revert-hunk undo stack. +pub struct RevertUndoEntry { + pub file_path: String, + pub pre_revert_bytes: Vec, +} + +/// Maximum entries kept in the revert-hunk undo stack. +pub const REVERT_UNDO_STACK_CAP: usize = 20; + /// State for the diff view panel. pub struct DiffViewState { pub scroll_offset: usize, @@ -240,6 +262,18 @@ 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, + /// Hunk index currently under the mouse cursor (for tooltip rendering). + pub hovered_revert_hunk: Option, + /// Pre-revert file snapshots, most-recent last. Bounded by + /// `REVERT_UNDO_STACK_CAP`. Used to undo revert-hunk actions within a + /// session. + pub revert_undo_stack: Vec, + /// Peak `revert_undo_stack.len()` since it was last empty. Drives the + /// `n/m` denominator in the bottom-right footnote; resets to 0 once the + /// stack drains so a fresh streak starts at `1/1`. + pub revert_undo_high_water: usize, } impl Default for DiffViewState { @@ -264,6 +298,10 @@ 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, } } } @@ -426,7 +464,13 @@ impl DiffViewState { } /// Parse old/new content into a ParsedDiff on any thread (no &self needed). - pub fn parse_content(filename: &str, old: &str, new: &str, tab_width: usize, file_exists_on_disk: bool) -> ParsedDiff { + pub fn parse_content( + filename: &str, + old: &str, + new: &str, + tab_width: usize, + file_exists_on_disk: bool, + ) -> ParsedDiff { let lines = super::diff_algo::compute_side_by_side(old, new, tab_width); let hunk_starts = super::diff_algo::find_hunk_starts(&lines); let sections = vec![FileSection { @@ -446,7 +490,12 @@ impl DiffViewState { } /// Parse raw diff output into a ParsedDiff on any thread (no &self needed). - pub fn parse_diff_output(filename: &str, diff_output: &str, tab_width: usize, file_exists_on_disk: bool) -> ParsedDiff { + pub fn parse_diff_output( + filename: &str, + diff_output: &str, + tab_width: usize, + file_exists_on_disk: bool, + ) -> ParsedDiff { let file_diffs = parse_multi_file_diff(diff_output); if file_diffs.len() <= 1 { @@ -532,6 +581,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; self.filename = parsed.filename; self.old_content = parsed.old_content; self.new_content = parsed.new_content; @@ -540,6 +591,16 @@ impl DiffViewState { self.hunk_line_offsets = parsed.hunk_line_offsets; self.sections = parsed.sections; self.file_exists_on_disk = parsed.file_exists_on_disk; + self.selected_revert_hunk = if same_file { + prev_selected_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; + self.hovered_revert_hunk = if same_file { + prev_hovered_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; if same_file { let max = self.lines.len().saturating_sub(1); self.scroll_offset = self.scroll_offset.min(max); @@ -555,6 +616,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; self.filename = filename.to_string(); self.old_content = old.to_string(); self.new_content = new.to_string(); @@ -571,6 +634,16 @@ impl DiffViewState { self.selection = None; self.clear_search(); } + self.selected_revert_hunk = if same_file { + prev_selected_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; + self.hovered_revert_hunk = if same_file { + prev_hovered_revert_hunk.filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; // Preserve side_view across reloads so periodic refresh doesn't reset it // Single section with index 0 self.sections = vec![FileSection { @@ -623,6 +696,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; self.filename = new_filename; self.old_content = String::new(); self.new_content = String::new(); @@ -634,6 +709,16 @@ 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.hunk_line_offsets = Vec::new(); @@ -665,14 +750,12 @@ impl DiffViewState { // Compute hunk line offsets for this section let hunks = parse_hunk_headers(file_diff); - let section_offsets = build_hunk_line_offsets( - &hunks, - &self.lines[section_start..], - 0, - ); + let section_offsets = + build_hunk_line_offsets(&hunks, &self.lines[section_start..], 0); // Adjust indices to be global (relative to self.lines) for (idx, old_off, new_off) in section_offsets { - self.hunk_line_offsets.push((section_start + idx, old_off, new_off)); + self.hunk_line_offsets + .push((section_start + idx, old_off, new_off)); } // Create highlighters for this section @@ -683,6 +766,18 @@ impl DiffViewState { } self.hunk_starts = super::diff_algo::find_hunk_starts(&self.lines); + self.selected_revert_hunk = if same_file { + self.selected_revert_hunk + .filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; + self.hovered_revert_hunk = if same_file { + self.hovered_revert_hunk + .filter(|&i| i < self.hunk_starts.len()) + } else { + None + }; if same_file { // Clamp scroll in case the diff got shorter @@ -710,11 +805,7 @@ impl DiffViewState { } pub fn next_hunk(&mut self) { - if let Some(next) = self - .hunk_starts - .iter() - .find(|&&h| h > self.scroll_offset) - { + if let Some(next) = self.hunk_starts.iter().find(|&&h| h > self.scroll_offset) { self.scroll_offset = *next; } } @@ -734,8 +825,165 @@ 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)> { + 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; + } + None + } + + /// Return true when the given line index is the first line of a diff hunk. + pub fn is_hunk_start_line(&self, line_idx: usize) -> bool { + self.hunk_starts.binary_search(&line_idx).is_ok() + } + + /// Get the zero-based hunk index for a hunk-start line. + pub fn hunk_index_for_start_line(&self, line_idx: usize) -> Option { + self.hunk_starts.binary_search(&line_idx).ok() + } + + /// For the visual hunk at `block_idx`, return the (old, new) line-number + /// ranges (inclusive) the block covers in the underlying file. Either + /// side may be `None` when the block is a pure insertion (no `-` lines) + /// or pure deletion (no `+` lines). Used to slice a sub-patch out of the + /// raw unified diff so revert affects only this visual block, not the + /// surrounding `@@` hunk that may contain other change blocks. + pub fn visual_block_line_ranges( + &self, + block_idx: usize, + ) -> Option<(Option<(usize, usize)>, Option<(usize, usize)>)> { + let start = *self.hunk_starts.get(block_idx)?; + let mut end = start; + while end < self.lines.len() && !matches!(self.lines[end].change_type, ChangeType::Equal) { + end += 1; + } + // DiffLine line numbers are content-relative (positions inside the + // concatenated old/new strings produced by parse_unified_diff), but + // the unified-diff walker we feed these ranges to tracks file-relative + // line numbers. `hunk_line_offsets` provides the per-`@@` deltas we + // need to bridge the two. + let (old_offset, new_offset) = self.line_number_offsets_at(start); + let mut old_range: Option<(usize, usize)> = None; + let mut new_range: Option<(usize, usize)> = None; + for line in &self.lines[start..end] { + if matches!(line.change_type, ChangeType::Delete | ChangeType::Modified) { + if let Some((n, _)) = &line.old_line { + let n = *n + old_offset; + old_range = Some(match old_range { + None => (n, n), + Some((lo, hi)) => (lo.min(n), hi.max(n)), + }); + } + } + if matches!(line.change_type, ChangeType::Insert | ChangeType::Modified) { + if let Some((n, _)) = &line.new_line { + let n = *n + new_offset; + new_range = Some(match new_range { + None => (n, n), + Some((lo, hi)) => (lo.min(n), hi.max(n)), + }); + } + } + } + Some((old_range, new_range)) + } + + /// Find the `@@` hunk that owns the DiffLine at `line_idx` and return + /// its (old, new) content→file line-number offsets. Returns (0, 0) when + /// no hunk metadata is available (e.g. the `load(...)` raw-content path, + /// where content and file numbering already coincide). + fn line_number_offsets_at(&self, line_idx: usize) -> (usize, usize) { + let mut offsets = (0usize, 0usize); + for &(start_idx, old_off, new_off) in &self.hunk_line_offsets { + if start_idx <= line_idx { + offsets = (old_off, new_off); + } else { + break; + } + } + offsets + } + + /// Jump to the next hunk and select it as the revert target. Always + /// scrolls to the hunk's start line — same motion as `next_hunk` — + /// even if it's already in the viewport. Wraps to the first hunk + /// after the last. + pub fn cycle_next_revert_hunk(&mut self) { + if self.hunk_starts.is_empty() { + self.selected_revert_hunk = None; + return; + } + let next = match self.selected_revert_hunk { + Some(i) => (i + 1) % self.hunk_starts.len(), + None => self + .hunk_starts + .iter() + .position(|&h| h > self.scroll_offset) + .unwrap_or(0), + }; + self.selected_revert_hunk = Some(next); + self.scroll_offset = self.hunk_starts[next]; + } + + /// Jump to the previous hunk and select it as the revert target. + /// Wraps to the last hunk before the first. + pub fn cycle_prev_revert_hunk(&mut self) { + if self.hunk_starts.is_empty() { + self.selected_revert_hunk = None; + return; + } + let prev = match self.selected_revert_hunk { + Some(0) => self.hunk_starts.len() - 1, + Some(i) => i - 1, + None => self + .hunk_starts + .iter() + .rposition(|&h| h < self.scroll_offset) + .unwrap_or(self.hunk_starts.len() - 1), + }; + self.selected_revert_hunk = Some(prev); + self.scroll_offset = self.hunk_starts[prev]; + } + /// Get the highlighters for a given section index. - fn highlighters_for_section(&self, section_index: usize) -> Option<(&FileHighlighter, &FileHighlighter)> { + fn highlighters_for_section( + &self, + section_index: usize, + ) -> Option<(&FileHighlighter, &FileHighlighter)> { self.sections .get(section_index) .map(|s| (&s.old_highlighter, &s.new_highlighter)) @@ -751,6 +999,7 @@ pub fn render_diff( theme: &Theme, focused: bool, diff_loading: bool, + show_revert_markers: bool, ) { let border_style = if focused { theme.active_border @@ -784,11 +1033,36 @@ pub fn render_diff( format!(" {}{}", state.filename, side_label) }; - let block = Block::default() + let mut block = Block::default() .title(title) .borders(Borders::ALL) .border_style(border_style); + // Bottom-right footnote: revert-hunk undo indicator. Only shown when + // there's something to undo; the denominator is the peak stack depth + // since it last drained, so a streak reads `1/1`, `2/2`, ... and undos + // walk it back down to `1/3` etc. + let undo_n = state.revert_undo_stack.len(); + if undo_n > 0 { + let undo_m = state.revert_undo_high_water.max(undo_n); + block = block.title_bottom( + Line::from(vec![ + Span::styled(" ", Style::default().fg(theme.text_dimmed)), + Span::styled( + "u", + Style::default() + .fg(theme.accent_secondary) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" undo revert ({}/{}) ", undo_n, undo_m), + Style::default().fg(theme.text_dimmed), + ), + ]) + .alignment(ratatui::layout::Alignment::Right), + ); + } + let inner = block.inner(area); frame.render_widget(block, area); @@ -797,7 +1071,7 @@ pub fn render_diff( } let gutter_width = 5u16; - let divider_width = 1u16; + let divider_width = 2u16; // Detect new file: old content is empty, so no left panel needed let is_new_file = state.old_content.is_empty() && state.sections.len() <= 1; @@ -933,10 +1207,26 @@ pub fn render_diff( theme, content_width as usize, ); - buf_write_spans(buf, inner.x + gutter_width, y, &spans, content_width, state.horizontal_scroll); + buf_write_spans( + buf, + inner.x + gutter_width, + y, + &spans, + content_width, + state.horizontal_scroll, + ); } else { - let fill: String = std::iter::repeat(' ').take(content_width as usize).collect(); - buf_write_str(buf, inner.x + gutter_width, y, &fill, Style::default().bg(bg), content_width); + let fill: String = std::iter::repeat(' ') + .take(content_width as usize) + .collect(); + buf_write_str( + buf, + inner.x + gutter_width, + y, + &fill, + Style::default().bg(bg), + content_width, + ); } row += 1; } @@ -957,6 +1247,8 @@ pub fn render_diff( .width .saturating_sub(gutter_width * 2 + panel_width + divider_width); + let mut hover_tooltip_y: Option = None; + let mut row = 0usize; for (idx_offset, diff_line) in state.lines[state.scroll_offset..].iter().enumerate() { if row >= visible_height { @@ -1043,38 +1335,123 @@ pub fn render_diff( } let y = inner.y + row as u16; - let left_gutter_text = if chunk_idx == 0 { left_num.clone() } else { " · ".to_string() }; - let right_gutter_text = if chunk_idx == 0 { right_num.clone() } else { " · ".to_string() }; + let left_gutter_text = if chunk_idx == 0 { + left_num.clone() + } else { + " · ".to_string() + }; + let right_gutter_text = if chunk_idx == 0 { + right_num.clone() + } else { + " · ".to_string() + }; // Left gutter + content - buf_write_str(buf, inner.x, y, &left_gutter_text, gutter_style, gutter_width); + buf_write_str( + buf, + inner.x, + y, + &left_gutter_text, + gutter_style, + gutter_width, + ); if is_insert { - let slash: String = std::iter::repeat('/').take(panel_width as usize).collect(); - buf_write_str(buf, inner.x + gutter_width, y, &slash, - Style::default().fg(theme.diff_line_number).bg(left_bg), panel_width); + let slash: String = + std::iter::repeat('/').take(panel_width as usize).collect(); + buf_write_str( + buf, + inner.x + gutter_width, + y, + &slash, + Style::default().fg(theme.diff_line_number).bg(left_bg), + panel_width, + ); } else if let Some(chunk) = left_wrapped.get(chunk_idx) { buf_write_spans(buf, inner.x + gutter_width, y, chunk, panel_width, 0); } else { - let fill: String = std::iter::repeat(' ').take(panel_width as usize).collect(); - buf_write_str(buf, inner.x + gutter_width, y, &fill, - Style::default().bg(left_bg), panel_width); + let fill: String = + std::iter::repeat(' ').take(panel_width as usize).collect(); + buf_write_str( + buf, + inner.x + gutter_width, + y, + &fill, + Style::default().bg(left_bg), + panel_width, + ); } - // Divider - buf_write_str(buf, div_x, y, "│", divider_style, divider_width); + // Divider or 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) + } else { + None + }; + let marker_is_hovered = show_marker + && marker_hunk_idx.is_some() + && marker_hunk_idx == state.hovered_revert_hunk; + let (divider_char, marker_style) = if show_marker { + let is_selected = marker_hunk_idx == state.selected_revert_hunk; + // Hover wins over selection so the hover state is always + // visible — even on a hunk that's currently selected. + let fg = if marker_is_hovered { + theme.accent_secondary + } else if is_selected { + theme.accent + } else { + theme.separator + }; + ("󰧛", Style::default().fg(fg).add_modifier(Modifier::BOLD)) + } 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 + { + hover_tooltip_y = Some(y); + } // Right gutter + content - buf_write_str(buf, right_gutter_x, y, &right_gutter_text, right_gutter_style, gutter_width); + buf_write_str( + buf, + right_gutter_x, + y, + &right_gutter_text, + right_gutter_style, + gutter_width, + ); if is_delete { - let slash: String = std::iter::repeat('/').take(right_content_width as usize).collect(); - buf_write_str(buf, right_content_x, y, &slash, - Style::default().fg(theme.diff_line_number).bg(right_bg), right_content_width); + let slash: String = std::iter::repeat('/') + .take(right_content_width as usize) + .collect(); + buf_write_str( + buf, + right_content_x, + y, + &slash, + Style::default().fg(theme.diff_line_number).bg(right_bg), + right_content_width, + ); } else if let Some(chunk) = right_wrapped.get(chunk_idx) { buf_write_spans(buf, right_content_x, y, chunk, right_content_width, 0); } else { - let fill: String = std::iter::repeat(' ').take(right_content_width as usize).collect(); - buf_write_str(buf, right_content_x, y, &fill, - Style::default().bg(right_bg), right_content_width); + let fill: String = std::iter::repeat(' ') + .take(right_content_width as usize) + .collect(); + buf_write_str( + buf, + right_content_x, + y, + &fill, + Style::default().bg(right_bg), + right_content_width, + ); } row += 1; @@ -1087,7 +1464,8 @@ pub fn render_diff( // Left content let left_spans = if is_insert { - let slash_fill: String = std::iter::repeat('/').take(panel_width as usize).collect(); + let slash_fill: String = + std::iter::repeat('/').take(panel_width as usize).collect(); vec![Span::styled( slash_fill, Style::default().fg(theme.diff_line_number).bg(left_bg), @@ -1104,17 +1482,62 @@ pub fn render_diff( panel_width as usize, ) }; - buf_write_spans(buf, inner.x + gutter_width, y, &left_spans, panel_width, state.horizontal_scroll); + buf_write_spans( + buf, + inner.x + gutter_width, + y, + &left_spans, + panel_width, + state.horizontal_scroll, + ); - // Divider - buf_write_str(buf, div_x, y, "│", divider_style, divider_width); + // Divider or revert marker. + let show_marker = + show_revert_markers && !state.wrap && state.is_hunk_start_line(line_idx); + let marker_hunk_idx = if show_marker { + state.hunk_index_for_start_line(line_idx) + } else { + None + }; + let marker_is_hovered = show_marker + && marker_hunk_idx.is_some() + && marker_hunk_idx == state.hovered_revert_hunk; + let (divider_char, marker_style) = if show_marker { + let is_selected = marker_hunk_idx == state.selected_revert_hunk; + let fg = if marker_is_hovered { + theme.accent_secondary + } else if is_selected { + theme.accent + } else { + theme.separator + }; + ("󰧛", Style::default().fg(fg).add_modifier(Modifier::BOLD)) + } 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 + { + hover_tooltip_y = Some(y); + } // Right gutter - buf_write_str(buf, right_gutter_x, y, &right_num, right_gutter_style, gutter_width); + buf_write_str( + buf, + right_gutter_x, + y, + &right_num, + right_gutter_style, + gutter_width, + ); // Right content let right_spans = if is_delete { - let slash_fill: String = std::iter::repeat('/').take(panel_width as usize).collect(); + let slash_fill: String = + std::iter::repeat('/').take(panel_width as usize).collect(); vec![Span::styled( slash_fill, Style::default().fg(theme.diff_line_number).bg(right_bg), @@ -1131,11 +1554,76 @@ pub fn render_diff( panel_width as usize, ) }; - buf_write_spans(buf, right_content_x, y, &right_spans, right_content_width, state.horizontal_scroll); + buf_write_spans( + buf, + right_content_x, + y, + &right_spans, + right_content_width, + state.horizontal_scroll, + ); row += 1; } } + + if let Some(y) = hover_tooltip_y { + let show_key = state.hovered_revert_hunk.is_some() + && state.hovered_revert_hunk == state.selected_revert_hunk; + render_revert_tooltip( + buf, + div_x + divider_width, + y, + right_content_width + gutter_width, + theme, + show_key, + ); + } + } +} + +fn render_revert_tooltip( + buf: &mut Buffer, + x: u16, + y: u16, + max_width: u16, + theme: &Theme, + show_key: bool, +) { + let tip_style = Style::default().bg(theme.selected_bg).fg(theme.text_strong); + let key_style = Style::default() + .bg(theme.selected_bg) + .fg(theme.accent_secondary) + .add_modifier(Modifier::BOLD); + + let parts: Vec<(&str, Style)> = if show_key { + vec![ + (" ", tip_style), + ("enter", key_style), + (" Revert hunk ", tip_style), + ] + } else { + vec![(" Revert hunk ", tip_style)] + }; + + let buf_area = buf.area(); + if y < buf_area.y || y >= buf_area.y + buf_area.height { + return; + } + let max_x = (x + max_width).min(buf_area.x + buf_area.width); + + let mut col = x; + for (text, style) in &parts { + for ch in text.chars() { + if col >= max_x { + return; + } + if let Some(cell) = buf.cell_mut((col, y)) { + cell.set_char(ch); + cell.set_style(*style); + } + col += 1; + } } } @@ -1189,7 +1677,14 @@ fn buf_write_str(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style, max /// Write styled spans directly to the buffer at (x, y), clamped to max_width. /// `h_scroll` skips the first N display columns of content. #[inline] -fn buf_write_spans(buf: &mut Buffer, x: u16, y: u16, spans: &[Span<'_>], max_width: u16, h_scroll: usize) { +fn buf_write_spans( + buf: &mut Buffer, + x: u16, + y: u16, + spans: &[Span<'_>], + max_width: u16, + h_scroll: usize, +) { let buf_area = buf.area(); if y < buf_area.y || y >= buf_area.y + buf_area.height { return; @@ -1230,6 +1725,74 @@ fn unicode_display_width(ch: char) -> usize { /// Split a list of styled spans into visual rows of at most `width` display columns each. /// Used by wrap mode to soft-wrap long diff lines. +/// Count how many visual rows a single line of `text` would occupy when wrapped +/// at `width` display columns. Mirrors the row count produced by `wrap_spans`, +/// without building styled spans. +fn wrap_row_count(text: &str, width: usize) -> usize { + if width == 0 { + return 1; + } + let widths: Vec = text + .chars() + .filter_map(|ch| { + let w = unicode_display_width(ch); + if w > 0 { Some(w) } else { None } + }) + .collect(); + if widths.is_empty() { + return 1; + } + let mut rows = 0usize; + let mut i = 0usize; + while i < widths.len() { + let mut col_w = 0usize; + let mut end = i; + while end < widths.len() { + let w = widths[end]; + if col_w + w > width { + break; + } + col_w += w; + end += 1; + } + if end == i { + end = i + 1; + } + i = end; + rows += 1; + } + rows +} + +/// Visual height (in panel rows) of a diff line, matching the renderer's +/// `num_rows` calculation in side-by-side wrap mode. +fn line_visual_height(diff_line: &DiffLine, panel_width: usize, right_content_width: usize) -> usize { + if diff_line.file_header.is_some() { + return 1; + } + let is_insert = diff_line.change_type == ChangeType::Insert; + let is_delete = diff_line.change_type == ChangeType::Delete; + let left_rows = if is_insert { + 0 + } else { + diff_line + .old_line + .as_ref() + .map(|(_, t)| wrap_row_count(t, panel_width)) + .unwrap_or(1) + }; + let right_rows = if is_delete { + 0 + } else { + diff_line + .new_line + .as_ref() + .map(|(_, t)| wrap_row_count(t, right_content_width)) + .unwrap_or(1) + }; + left_rows.max(right_rows).max(1) +} + fn wrap_spans<'a>(spans: &[Span<'a>], width: usize) -> Vec>> { if width == 0 { return vec![vec![]]; @@ -1241,7 +1804,11 @@ fn wrap_spans<'a>(spans: &[Span<'a>], width: usize) -> Vec>> { .flat_map(|sp| { let style = sp.style; sp.content.chars().filter_map(move |ch| { - if unicode_display_width(ch) > 0 { Some((ch, style)) } else { None } + if unicode_display_width(ch) > 0 { + Some((ch, style)) + } else { + None + } }) }) .collect(); @@ -1400,7 +1967,10 @@ fn build_word_diff_spans<'a>( .add_modifier(Modifier::BOLD), ) } else { - Span::styled(seg.text.clone(), Style::default().bg(bg).fg(theme.syntax_default)) + Span::styled( + seg.text.clone(), + Style::default().bg(bg).fg(theme.syntax_default), + ) } }) .collect() @@ -1443,7 +2013,9 @@ pub fn render_diff_search_highlights( }; let visible_height = area.height.saturating_sub(2) as usize; // -2 for borders - let highlight_style = Style::default().bg(theme.diff_search_highlight_bg).fg(theme.diff_search_highlight_fg); + let highlight_style = Style::default() + .bg(theme.diff_search_highlight_bg) + .fg(theme.diff_search_highlight_fg); let current_highlight_style = Style::default() .bg(theme.diff_search_cursor_bg) .fg(theme.diff_search_cursor_fg) @@ -1496,12 +2068,7 @@ pub fn render_diff_search_highlights( } /// Render a search bar at the bottom of the diff panel area. -pub fn render_diff_search_bar( - frame: &mut Frame, - area: Rect, - state: &DiffViewState, - theme: &Theme, -) { +pub fn render_diff_search_bar(frame: &mut Frame, area: Rect, state: &DiffViewState, theme: &Theme) { // Only render if search is active (typing) or has a query (dismissed but results shown) if !state.search_active && state.search_query.is_empty() { return; @@ -1548,7 +2115,9 @@ pub fn render_diff_search_bar( let prefix_rect = Rect::new(bar_rect.x, bar_y, prefix_width, 1); let prefix = Paragraph::new(Span::styled( " /", - Style::default().fg(theme.diff_grid_fg).bg(theme.diff_grid_bg), + Style::default() + .fg(theme.diff_grid_fg) + .bg(theme.diff_grid_bg), )); frame.render_widget(prefix, prefix_rect); @@ -1558,17 +2127,22 @@ pub fn render_diff_search_bar( } if !match_info.is_empty() { - let suffix_rect = Rect::new(bar_rect.x + prefix_width + ta_width, bar_y, suffix_width, 1); + let suffix_rect = + Rect::new(bar_rect.x + prefix_width + ta_width, bar_y, suffix_width, 1); let suffix = Paragraph::new(Span::styled( match_info, - Style::default().fg(theme.diff_grid_fg).bg(theme.diff_grid_bg), + Style::default() + .fg(theme.diff_grid_fg) + .bg(theme.diff_grid_bg), )); frame.render_widget(suffix, suffix_rect); } } else { // Dismissed search — show query + match info let text = format!(" /{}{}", state.search_query, match_info); - let style = Style::default().fg(theme.diff_grid_fg).bg(theme.diff_grid_bg); + let style = Style::default() + .fg(theme.diff_grid_fg) + .bg(theme.diff_grid_bg); buf_write_str( frame.buffer_mut(), bar_rect.x, @@ -1730,7 +2304,8 @@ fn build_hunk_line_offsets( .as_ref() .map(|(n, _)| *n >= content_old_start) .unwrap_or(false) - || dl.new_line + || dl + .new_line .as_ref() .map(|(n, _)| *n >= content_new_start) .unwrap_or(false)