From 93151810bc7bd5ee21368b061e690d0b3c45dab0 Mon Sep 17 00:00:00 2001 From: felipe Date: Thu, 17 Sep 2026 11:04:11 -0300 Subject: [PATCH 01/11] feat(filetree): directory folding with '-', hierarchy navigation, and combined folder diffs --- src/config/keybindings.rs | 45 ++++++++ src/gui/context/mod.rs | 4 + src/gui/controller/commit_files.rs | 38 ++++++- src/gui/controller/diff_mode.rs | 110 +++++++++++++++--- src/gui/controller/files.rs | 45 +++++++- src/gui/mod.rs | 173 ++++++++++++++++++++++++++++- src/gui/presentation/diff_mode.rs | 13 ++- src/gui/views.rs | 31 +++++- src/model/file_tree.rs | 132 ++++++++++++++++++++++ 9 files changed, 561 insertions(+), 30 deletions(-) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 0b992f6..83774bb 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -126,6 +126,16 @@ pub struct UniversalKeybinding { pub reset_side_panel: String, #[serde(rename = "toggleDiffViewLayout")] pub toggle_diff_view_layout: String, + #[serde(rename = "foldDirectory")] + pub fold_directory: String, + #[serde(rename = "treeParent")] + pub tree_parent: String, + #[serde(rename = "treeChild")] + pub tree_child: String, + #[serde(rename = "treePrevSibling")] + pub tree_prev_sibling: String, + #[serde(rename = "treeNextSibling")] + pub tree_next_sibling: String, } impl Default for UniversalKeybinding { @@ -181,6 +191,11 @@ impl Default for UniversalKeybinding { main_panel_full: "".into(), reset_side_panel: "".into(), toggle_diff_view_layout: "\\".into(), + fold_directory: "-".into(), + tree_parent: ",".into(), + tree_child: ".".into(), + tree_prev_sibling: "<".into(), + tree_next_sibling: ">".into(), } } } @@ -448,3 +463,33 @@ pub fn parse_key(s: &str) -> Option { None } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tree_keybindings_defaults_and_deserialization() { + let default_config = KeybindingConfig::default(); + assert_eq!(default_config.universal.fold_directory, "-"); + assert_eq!(default_config.universal.tree_parent, ","); + assert_eq!(default_config.universal.tree_child, "."); + assert_eq!(default_config.universal.tree_prev_sibling, "<"); + assert_eq!(default_config.universal.tree_next_sibling, ">"); + + let yaml = r#" +universal: + foldDirectory: "_" + treeParent: "p" + treeChild: "c" + treePrevSibling: "h" + treeNextSibling: "l" +"#; + let parsed: KeybindingConfig = serde_yaml::from_str(yaml).expect("valid yaml"); + assert_eq!(parsed.universal.fold_directory, "_"); + assert_eq!(parsed.universal.tree_parent, "p"); + assert_eq!(parsed.universal.tree_child, "c"); + assert_eq!(parsed.universal.tree_prev_sibling, "h"); + assert_eq!(parsed.universal.tree_next_sibling, "l"); + } +} diff --git a/src/gui/context/mod.rs b/src/gui/context/mod.rs index 6f7ac34..07ab248 100644 --- a/src/gui/context/mod.rs +++ b/src/gui/context/mod.rs @@ -299,6 +299,10 @@ impl ContextManager { self.selections.get(&ctx).copied().unwrap_or(0) } + pub fn set_selected(&mut self, ctx: ContextId, idx: usize) { + self.selections.insert(ctx, idx); + } + pub fn selected_active(&self) -> usize { self.selected(self.active) } diff --git a/src/gui/controller/commit_files.rs b/src/gui/controller/commit_files.rs index 6a7bda7..f30cfb9 100644 --- a/src/gui/controller/commit_files.rs +++ b/src/gui/controller/commit_files.rs @@ -47,12 +47,20 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) return Ok(()); } - // Enter: toggle directory collapse in tree view, or focus diff for files - if key.code == KeyCode::Enter { + // Enter: focus diff for files/directories, fold_directory explicitly toggles directory collapse + let is_fold = matches_key(key, &keybindings.universal.fold_directory); + if key.code == KeyCode::Enter || is_fold { if gui.show_commit_file_tree { let selected = gui.context_mgr.selected_active(); if let Some(node) = gui.commit_file_tree_nodes.get(selected) { if node.is_dir { + if !is_fold { + if !gui.diff_view.is_empty() { + gui.diff_focused = true; + gui.screen_mode = crate::gui::ScreenMode::Full; + } + return Ok(()); + } let path = node.path.clone(); if gui.commit_files_collapsed_dirs.contains(&path) { gui.commit_files_collapsed_dirs.remove(&path); @@ -64,13 +72,39 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) } } } + if is_fold { + return Ok(()); + } // Focus the diff panel for the selected file if !gui.diff_view.is_empty() { gui.diff_focused = true; + gui.screen_mode = crate::gui::ScreenMode::Full; } return Ok(()); } + // Tree navigation (parent, child, next sibling, prev sibling) + if gui.show_commit_file_tree { + let active = gui.context_mgr.active(); + let selected = gui.context_mgr.selected(active); + let new_idx = if matches_key(key, &keybindings.universal.tree_parent) { + crate::model::file_tree::find_parent_idx(&gui.commit_file_tree_nodes, selected) + } else if matches_key(key, &keybindings.universal.tree_child) { + crate::model::file_tree::find_first_child_idx(&gui.commit_file_tree_nodes, selected) + } else if matches_key(key, &keybindings.universal.tree_next_sibling) { + crate::model::file_tree::find_next_sibling_idx(&gui.commit_file_tree_nodes, selected) + } else if matches_key(key, &keybindings.universal.tree_prev_sibling) { + crate::model::file_tree::find_prev_sibling_idx(&gui.commit_file_tree_nodes, selected) + } else { + None + }; + if let Some(idx) = new_idx { + gui.context_mgr.set_selected(active, idx); + gui.needs_diff_refresh = true; + return Ok(()); + } + } + // Toggle file tree view if matches_key(key, &keybindings.files.toggle_tree_view) { gui.show_commit_file_tree = !gui.show_commit_file_tree; diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index 73d4511..5d163ca 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -293,28 +293,74 @@ fn handle_commit_files_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { } } KeyCode::Enter => { - if gui.diff_mode.show_tree { - // Toggle dir collapse or focus diff - if let Some(node) = gui - .diff_mode - .tree_nodes - .get(gui.diff_mode.diff_files_selected) - { - if node.is_dir { - let path = node.path.clone(); - if gui.diff_mode.collapsed_dirs.contains(&path) { - gui.diff_mode.collapsed_dirs.remove(&path); - } else { - gui.diff_mode.collapsed_dirs.insert(path); - } - update_diff_mode_tree(gui); + gui.diff_mode.focus = DiffModeFocus::DiffExploration; + gui.needs_diff_refresh = true; + } + _ => {} + } + + let is_fold = matches_key(key, &keybindings.universal.fold_directory); + if key.code == KeyCode::Enter || is_fold { + if gui.diff_mode.show_tree { + if let Some(node) = gui + .diff_mode + .tree_nodes + .get(gui.diff_mode.diff_files_selected) + { + if node.is_dir { + if !is_fold { + gui.diff_mode.focus = DiffModeFocus::DiffExploration; + gui.needs_diff_refresh = true; return Ok(()); } + let path = node.path.clone(); + if gui.diff_mode.collapsed_dirs.contains(&path) { + gui.diff_mode.collapsed_dirs.remove(&path); + } else { + gui.diff_mode.collapsed_dirs.insert(path); + } + update_diff_mode_tree(gui); + return Ok(()); } } - gui.diff_mode.focus = DiffModeFocus::DiffExploration; + } + if is_fold { + return Ok(()); + } + } + + if gui.diff_mode.show_tree { + let new_idx = if matches_key(key, &keybindings.universal.tree_parent) { + crate::model::file_tree::find_parent_idx( + &gui.diff_mode.tree_nodes, + gui.diff_mode.diff_files_selected, + ) + } else if matches_key(key, &keybindings.universal.tree_child) { + crate::model::file_tree::find_first_child_idx( + &gui.diff_mode.tree_nodes, + gui.diff_mode.diff_files_selected, + ) + } else if matches_key(key, &keybindings.universal.tree_next_sibling) { + crate::model::file_tree::find_next_sibling_idx( + &gui.diff_mode.tree_nodes, + gui.diff_mode.diff_files_selected, + ) + } else if matches_key(key, &keybindings.universal.tree_prev_sibling) { + crate::model::file_tree::find_prev_sibling_idx( + &gui.diff_mode.tree_nodes, + gui.diff_mode.diff_files_selected, + ) + } else { + None + }; + if let Some(idx) = new_idx { + gui.diff_mode.diff_files_selected = idx; gui.needs_diff_refresh = true; + return Ok(()); } + } + + match key.code { KeyCode::Char('g') => { gui.diff_mode.diff_files_selected = 0; gui.diff_mode.viewport_manually_scrolled = false; @@ -583,6 +629,38 @@ fn handle_diff_exploration_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { } } + let keybindings = &gui.config.user_config.keybinding; + if gui.diff_mode.show_tree { + let new_idx = if matches_key(key, &keybindings.universal.tree_parent) { + crate::model::file_tree::find_parent_idx( + &gui.diff_mode.tree_nodes, + gui.diff_mode.diff_files_selected, + ) + } else if matches_key(key, &keybindings.universal.tree_child) { + crate::model::file_tree::find_first_child_idx( + &gui.diff_mode.tree_nodes, + gui.diff_mode.diff_files_selected, + ) + } else if matches_key(key, &keybindings.universal.tree_next_sibling) { + crate::model::file_tree::find_next_sibling_idx( + &gui.diff_mode.tree_nodes, + gui.diff_mode.diff_files_selected, + ) + } else if matches_key(key, &keybindings.universal.tree_prev_sibling) { + crate::model::file_tree::find_prev_sibling_idx( + &gui.diff_mode.tree_nodes, + gui.diff_mode.diff_files_selected, + ) + } else { + None + }; + if let Some(idx) = new_idx { + gui.diff_mode.diff_files_selected = idx; + gui.needs_diff_refresh = true; + return Ok(()); + } + } + match key.code { KeyCode::Esc => { if !gui.diff_view.search_query.is_empty() { diff --git a/src/gui/controller/files.rs b/src/gui/controller/files.rs index 23755a0..2666263 100644 --- a/src/gui/controller/files.rs +++ b/src/gui/controller/files.rs @@ -19,12 +19,23 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) return super::commits::show_files_filtering_menu(gui); } - // Enter: toggle directory collapse in tree view, or focus diff for files - if key.code == KeyCode::Enter { + // Enter: focus diff for files/directories, fold_directory explicitly toggles directory collapse + let is_fold = matches_key(key, &keybindings.universal.fold_directory); + if key.code == KeyCode::Enter || is_fold { if gui.show_file_tree { let selected = gui.context_mgr.selected_active(); if let Some(node) = gui.file_tree_nodes.get(selected) { if node.is_dir { + // For any directory, Enter focuses the combined diff view. + // fold_directory explicitly toggles collapse for any directory (including root). + if !is_fold { + if !gui.diff_view.is_empty() { + gui.diff_focused = true; + gui.screen_mode = crate::gui::ScreenMode::Full; + } + return Ok(()); + } + let path = node.path.clone(); if gui.collapsed_dirs.contains(&path) { gui.collapsed_dirs.remove(&path); @@ -36,13 +47,43 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) } } } + + if is_fold { + return Ok(()); + } + // Focus the diff panel for the selected file if !gui.diff_view.is_empty() { gui.diff_focused = true; + gui.screen_mode = crate::gui::ScreenMode::Full; } return Ok(()); } + // Tree navigation (parent, child, next sibling, prev sibling) + if gui.show_file_tree { + let selected = gui + .context_mgr + .selected(crate::gui::context::ContextId::Files); + let new_idx = if matches_key(key, &keybindings.universal.tree_parent) { + crate::model::file_tree::find_parent_idx(&gui.file_tree_nodes, selected) + } else if matches_key(key, &keybindings.universal.tree_child) { + crate::model::file_tree::find_first_child_idx(&gui.file_tree_nodes, selected) + } else if matches_key(key, &keybindings.universal.tree_next_sibling) { + crate::model::file_tree::find_next_sibling_idx(&gui.file_tree_nodes, selected) + } else if matches_key(key, &keybindings.universal.tree_prev_sibling) { + crate::model::file_tree::find_prev_sibling_idx(&gui.file_tree_nodes, selected) + } else { + None + }; + if let Some(idx) = new_idx { + gui.context_mgr + .set_selected(crate::gui::context::ContextId::Files, idx); + gui.needs_diff_refresh = true; + return Ok(()); + } + } + // Stage/unstage toggle with space if key.code == KeyCode::Char(' ') { return toggle_stage(gui); diff --git a/src/gui/mod.rs b/src/gui/mod.rs index f642801..68e8462 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -3708,7 +3708,18 @@ impl Gui { // `.` toggles the commit-details box when in any commit-related // context. Kept outside per-context controllers so the binding is // consistent across Commits / BranchCommits / Reflog / CommitFiles. - if key.code == KeyCode::Char('.') && self.context_has_commit_details() { + if key.code == KeyCode::Char('.') + && self.context_has_commit_details() + && !(self.show_commit_file_tree + && matches_key( + key, + &self.config.user_config.keybinding.universal.tree_child, + ) + && matches!( + self.context_mgr.active(), + ContextId::CommitFiles | ContextId::BranchCommitFiles | ContextId::StashFiles + )) + { self.show_commit_details = !self.show_commit_details; self.persist_commit_details_visibility(); return Ok(()); @@ -3999,6 +4010,112 @@ impl Gui { KeyCode::Char('q') => { self.should_quit = true; } + _ if matches_key( + key, + &self.config.user_config.keybinding.universal.tree_parent, + ) || matches_key( + key, + &self.config.user_config.keybinding.universal.tree_child, + ) || matches_key( + key, + &self + .config + .user_config + .keybinding + .universal + .tree_next_sibling, + ) || matches_key( + key, + &self + .config + .user_config + .keybinding + .universal + .tree_prev_sibling, + ) => + { + let active = self.context_mgr.active(); + let is_parent = matches_key( + key, + &self.config.user_config.keybinding.universal.tree_parent, + ); + let is_child = matches_key( + key, + &self.config.user_config.keybinding.universal.tree_child, + ); + let is_next = matches_key( + key, + &self + .config + .user_config + .keybinding + .universal + .tree_next_sibling, + ); + + let new_idx = match active { + ContextId::Files if self.show_file_tree => { + let selected = self.context_mgr.selected(ContextId::Files); + if is_parent { + crate::model::file_tree::find_parent_idx( + &self.file_tree_nodes, + selected, + ) + } else if is_child { + crate::model::file_tree::find_first_child_idx( + &self.file_tree_nodes, + selected, + ) + } else if is_next { + crate::model::file_tree::find_next_sibling_idx( + &self.file_tree_nodes, + selected, + ) + } else { + crate::model::file_tree::find_prev_sibling_idx( + &self.file_tree_nodes, + selected, + ) + } + .map(|idx| (ContextId::Files, idx)) + } + ContextId::CommitFiles + | ContextId::StashFiles + | ContextId::BranchCommitFiles + if self.show_commit_file_tree => + { + let selected = self.context_mgr.selected(active); + if is_parent { + crate::model::file_tree::find_parent_idx( + &self.commit_file_tree_nodes, + selected, + ) + } else if is_child { + crate::model::file_tree::find_first_child_idx( + &self.commit_file_tree_nodes, + selected, + ) + } else if is_next { + crate::model::file_tree::find_next_sibling_idx( + &self.commit_file_tree_nodes, + selected, + ) + } else { + crate::model::file_tree::find_prev_sibling_idx( + &self.commit_file_tree_nodes, + selected, + ) + } + .map(|idx| (active, idx)) + } + _ => None, + }; + if let Some((ctx, idx)) = new_idx { + self.context_mgr.set_selected(ctx, idx); + self.needs_diff_refresh = true; + } + return Ok(()); + } // j/k/up/down scroll line by line KeyCode::Char('j') | KeyCode::Down => { self.diff_view.scroll_down(1); @@ -5666,6 +5783,21 @@ impl Gui { kb.files.toggle_tree_view.clone(), "Toggle tree view".into(), ), + CommandEntry::keybinding( + kb.universal.fold_directory.clone(), + "Fold / unfold directory node".into(), + ), + CommandEntry::keybinding( + format!("{} / {}", kb.universal.tree_parent, kb.universal.tree_child), + "Navigate to parent / child node in tree".into(), + ), + CommandEntry::keybinding( + format!( + "{} / {}", + kb.universal.tree_prev_sibling, kb.universal.tree_next_sibling + ), + "Navigate to prev / next sibling in tree".into(), + ), CommandEntry::keybinding(kb.files.fetch.clone(), "Fetch".into()), CommandEntry::keybinding(kb.files.ignore_file.clone(), "Ignore file".into()), CommandEntry::keybinding("d".into(), "Discard changes".into()), @@ -5868,6 +6000,21 @@ impl Gui { kb.files.toggle_tree_view.clone(), "Toggle tree view".into(), ), + CommandEntry::keybinding( + kb.universal.fold_directory.clone(), + "Fold / unfold directory node".into(), + ), + CommandEntry::keybinding( + format!("{} / {}", kb.universal.tree_parent, kb.universal.tree_child), + "Navigate to parent / child node in tree".into(), + ), + CommandEntry::keybinding( + format!( + "{} / {}", + kb.universal.tree_prev_sibling, kb.universal.tree_next_sibling + ), + "Navigate to prev / next sibling in tree".into(), + ), CommandEntry::keybinding("y".into(), "Copy to clipboard menu".into()), CommandEntry::keybinding(".".into(), "Toggle commit details panel".into()), ], @@ -6055,6 +6202,30 @@ impl Gui { CommandEntry::keybinding("e".into(), "Edit file at line".into()), CommandEntry::keybinding("o".into(), "Open file in default program".into()), CommandEntry::keybinding("y".into(), "Copy selected text".into()), + CommandEntry::keybinding( + format!( + "{} / {}", + self.config.user_config.keybinding.universal.tree_parent, + self.config.user_config.keybinding.universal.tree_child + ), + "Navigate to parent / child node in tree".into(), + ), + CommandEntry::keybinding( + format!( + "{} / {}", + self.config + .user_config + .keybinding + .universal + .tree_prev_sibling, + self.config + .user_config + .keybinding + .universal + .tree_next_sibling + ), + "Navigate to prev / next sibling in tree".into(), + ), CommandEntry::keybinding("q".into(), "Quit".into()), CommandEntry::keybinding("+/_".into(), "Enlarge / shrink panel".into()), CommandEntry::keybinding(";".into(), "Toggle command log".into()), diff --git a/src/gui/presentation/diff_mode.rs b/src/gui/presentation/diff_mode.rs index ab61c81..1949785 100644 --- a/src/gui/presentation/diff_mode.rs +++ b/src/gui/presentation/diff_mode.rs @@ -431,15 +431,20 @@ fn render_status_bar( side_by_side::DiffViewLayout::SideBySide => "unified view", side_by_side::DiffViewLayout::Unified => "split view", }; - vec![ + let mut h = vec![ ("q", "exit"), ("Tab", "cycle"), ("1-4", "panel"), ("", "swap"), ("`", "tree"), - ("\\", view_layout_hint), - ("?", "help"), - ] + ]; + if state.show_tree { + h.push(("-", "fold")); + h.push((",/.", "nav")); + } + h.push(("\\", view_layout_hint)); + h.push(("?", "help")); + h }; let key_style = Style::default().fg(theme.text).add_modifier(Modifier::BOLD); diff --git a/src/gui/views.rs b/src/gui/views.rs index eef8068..3f6150f 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -399,6 +399,8 @@ pub fn render( model, diff_focused, !cherry_pick_clipboard.is_empty(), + show_file_tree, + show_commit_file_tree, ); // Render text selection highlight overlay and tooltip (must be before popup) render_selection_overlay(frame, diff_view, fl.main_panel, theme); @@ -1079,6 +1081,8 @@ pub fn render( model, diff_focused, !cherry_pick_clipboard.is_empty(), + show_file_tree, + show_commit_file_tree, ); // Render text selection highlight overlay and tooltip @@ -2205,6 +2209,8 @@ fn render_search_bar_or_status_bar( model: &Model, diff_focused: bool, has_copied_commits: bool, + show_file_tree: bool, + show_commit_file_tree: bool, ) { if let Some((query, match_count, current_match)) = search_state { let match_info = if match_count > 0 { @@ -2262,6 +2268,8 @@ fn render_search_bar_or_status_bar( model, diff_focused, has_copied_commits, + show_file_tree, + show_commit_file_tree, ); } } @@ -2275,6 +2283,8 @@ fn render_status_bar( model: &Model, diff_focused: bool, has_copied_commits: bool, + show_file_tree: bool, + show_commit_file_tree: bool, ) { let mut hints: Vec<(&str, &str)> = Vec::new(); let mut emphasized: Vec<&str> = Vec::new(); @@ -2310,6 +2320,11 @@ fn render_status_bar( } else { hints.push(("{/}", "prev/next hunk")); } + if (ctx_mgr.active() == ContextId::Files && show_file_tree) + || (ctx_mgr.active() == ContextId::CommitFiles && show_commit_file_tree) + { + hints.push((",/.", "nav")); + } hints.push(("[/]", "side view")); let view_layout_hint = match diff_view.view_layout { DiffViewLayout::SideBySide => "unified view", @@ -2328,6 +2343,12 @@ fn render_status_bar( ("c", "commit"), ("a", "stage all"), ("space", "toggle"), + ("`", "tree"), + ]); + if show_file_tree { + hints.extend([("-", "fold"), (",/.", "nav")]); + } + hints.extend([ ("\\", view_layout_hint), ("d", "discard"), ("e", "edit"), @@ -2335,11 +2356,11 @@ fn render_status_bar( ]); } ContextId::CommitFiles | ContextId::StashFiles | ContextId::BranchCommitFiles => { - hints.extend([ - ("enter", "focus diff"), - ("\\", view_layout_hint), - ("y", "copy"), - ]); + hints.extend([("enter", "focus diff"), ("`", "tree")]); + if ctx_mgr.active() == ContextId::CommitFiles && show_commit_file_tree { + hints.extend([("-", "fold"), (",/.", "nav")]); + } + hints.extend([("\\", view_layout_hint), ("y", "copy")]); } ContextId::BranchCommits => { hints.extend([ diff --git a/src/model/file_tree.rs b/src/model/file_tree.rs index fa921cd..e6a78d8 100644 --- a/src/model/file_tree.rs +++ b/src/model/file_tree.rs @@ -68,6 +68,51 @@ mod tests { assert_eq!(nodes[1].path, "src/views/provider_oauth_flow.rs"); assert_eq!(nodes[1].file_index, Some(0)); } + + #[test] + fn test_tree_navigation() { + struct TestNode { + depth: usize, + } + + impl NavigableTreeNode for TestNode { + fn depth(&self) -> usize { + self.depth + } + } + + // Tree structure: + // 0: root (depth 0, dir) + // 1: child1 (depth 1, dir) + // 2: grandchild1 (depth 2, file) + // 3: grandchild2 (depth 2, file) + // 4: child2 (depth 1, file) + let nodes = vec![ + TestNode { depth: 0 }, + TestNode { depth: 1 }, + TestNode { depth: 2 }, + TestNode { depth: 2 }, + TestNode { depth: 1 }, + ]; + + // Parent navigation + assert_eq!(find_parent_idx(&nodes, 2), Some(1)); + assert_eq!(find_parent_idx(&nodes, 1), Some(0)); + assert_eq!(find_parent_idx(&nodes, 0), None); + assert_eq!(find_parent_idx(&nodes, 4), Some(0)); + + // First child navigation + assert_eq!(find_first_child_idx(&nodes, 0), Some(1)); + assert_eq!(find_first_child_idx(&nodes, 1), Some(2)); + assert_eq!(find_first_child_idx(&nodes, 2), None); // Not a directory / no child + + // Sibling navigation + assert_eq!(find_next_sibling_idx(&nodes, 2), Some(3)); // grandchild1 -> grandchild2 + assert_eq!(find_next_sibling_idx(&nodes, 3), None); // grandchild2 is last child + assert_eq!(find_next_sibling_idx(&nodes, 1), Some(4)); // child1 -> child2 (skipping grandchildren!) + assert_eq!(find_prev_sibling_idx(&nodes, 3), Some(2)); // grandchild2 -> grandchild1 + assert_eq!(find_prev_sibling_idx(&nodes, 4), Some(1)); // child2 -> child1 (skipping grandchildren!) + } } fn rename_leaf_name(name: &str) -> Option { @@ -461,3 +506,90 @@ fn compress_single_child_commit_dirs(nodes: &mut Vec) { i += 1; } } + +pub trait NavigableTreeNode { + fn depth(&self) -> usize; +} + +impl NavigableTreeNode for FileTreeNode { + fn depth(&self) -> usize { + self.depth + } +} + +impl NavigableTreeNode for CommitFileTreeNode { + fn depth(&self) -> usize { + self.depth + } +} + +pub fn find_parent_idx(nodes: &[T], current_idx: usize) -> Option { + if current_idx == 0 || nodes.is_empty() || current_idx >= nodes.len() { + return None; + } + let current_depth = nodes[current_idx].depth(); + if current_depth == 0 { + return None; + } + let target_depth = current_depth - 1; + for idx in (0..current_idx).rev() { + if nodes[idx].depth() == target_depth { + return Some(idx); + } + } + None +} + +pub fn find_first_child_idx( + nodes: &[T], + current_idx: usize, +) -> Option { + if current_idx + 1 >= nodes.len() || current_idx >= nodes.len() { + return None; + } + let current_depth = nodes[current_idx].depth(); + if nodes[current_idx + 1].depth() == current_depth + 1 { + return Some(current_idx + 1); + } + None +} + +pub fn find_prev_sibling_idx( + nodes: &[T], + current_idx: usize, +) -> Option { + if current_idx == 0 || nodes.is_empty() || current_idx >= nodes.len() { + return None; + } + let current_depth = nodes[current_idx].depth(); + for idx in (0..current_idx).rev() { + let depth = nodes[idx].depth(); + if depth < current_depth { + break; + } + if depth == current_depth { + return Some(idx); + } + } + None +} + +pub fn find_next_sibling_idx( + nodes: &[T], + current_idx: usize, +) -> Option { + if current_idx + 1 >= nodes.len() || current_idx >= nodes.len() { + return None; + } + let current_depth = nodes[current_idx].depth(); + for idx in (current_idx + 1)..nodes.len() { + let depth = nodes[idx].depth(); + if depth < current_depth { + break; + } + if depth == current_depth { + return Some(idx); + } + } + None +} From 2ccbe8656bc83fd1e27afeca275127ddd6110a22 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 29 Sep 2026 22:31:43 +0800 Subject: [PATCH 02/11] feat(pager): add sticky file headers and preview placeholders to diff view - pin current file separator to viewport top in multi-file unified and split diffs with sticky header helpers - add preview_placeholder rows for unavailable previews including centered binary placeholder - fix row-to-line mapping, fallback index, and selection/overlay hit-testing for reserved sticky row - ignore mouse selection on sticky header rowsfeat(pager): add sticky file headers and preview placeholders to diff view - pin current file separator to viewport top in multi-file unified and split diffs with sticky header helpers - add preview_placeholder rows for unavailable previews including centered binary placeholder - fix row-to-line mapping, fallback index, and selection/overlay hit-testing for reserved sticky row - ignore mouse selection on sticky header rows --- src/gui/controller/diff_mode.rs | 2 +- src/gui/mod.rs | 18 +- src/gui/views.rs | 4 +- src/pager/diff_algo.rs | 5 + src/pager/mod.rs | 2 + src/pager/side_by_side.rs | 590 ++++++++++++++++++++++++++++++-- 6 files changed, 583 insertions(+), 38 deletions(-) diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index 73d4511..04ec95b 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -518,7 +518,7 @@ fn handle_diff_exploration_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { .map(|(line_idx, _, panel)| (line_idx, panel)) .unwrap_or_else(|| { ( - gui.diff_view.scroll_offset + (top_row - pl.inner_y) as usize, + gui.diff_view.fallback_line_idx_for_row(top_row, &pl), sel_ref.panel, ) }) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index f642801..11997c3 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -816,7 +816,7 @@ fn synthesize_new_file_diff(filename: &str, content: &str) -> String { diff } -/// Placeholder so the pager shows "Binary file (not viewable)". +/// Synthetic binary diff so the pager renders its unavailable-preview placeholder. fn synthesize_binary_file_diff(filename: &str) -> String { format!( "diff --git a/{f} b/{f}\n\ @@ -3827,7 +3827,7 @@ impl Gui { .map(|(line_idx, _, panel)| (line_idx, panel)) .unwrap_or_else(|| { ( - self.diff_view.scroll_offset + (top_row - pl.inner_y) as usize, + self.diff_view.fallback_line_idx_for_row(top_row, &pl), sel_ref.panel, ) }) @@ -7034,6 +7034,13 @@ impl Gui { self.diff_focused = true; return; } + // Clicks on the pinned sticky file header don't start a + // text selection — there is no content to select there. + if self.diff_view.is_sticky_row(mouse.row, &pl) { + self.diff_view.selection = None; + self.diff_focused = true; + return; + } if let Some(panel) = pl.panel_at_x(mouse.column) { self.diff_view.selection = Some(TextSelection { panel, @@ -7345,6 +7352,11 @@ impl Gui { self.diff_mode.focus = DiffModeFocus::DiffExploration; return; } + if self.diff_view.is_sticky_row(row, &pl) { + self.diff_view.selection = None; + self.diff_mode.focus = DiffModeFocus::DiffExploration; + return; + } if let Some(panel) = pl.panel_at_x(col) { self.diff_view.selection = Some(TextSelection { panel, @@ -8905,6 +8917,7 @@ mod terminal_mouse_tests { old_segments: None, new_segments: None, file_header: None, + preview_placeholder: None, section_index: 0, }); cache.insert(format!("key-{index}"), view); @@ -8951,6 +8964,7 @@ mod terminal_mouse_tests { old_segments: None, new_segments: None, file_header: None, + preview_placeholder: None, section_index: 0, }); cache.insert(key.to_string(), view); diff --git a/src/gui/views.rs b/src/gui/views.rs index eef8068..7772094 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -2488,7 +2488,7 @@ pub fn render_selection_overlay( .map(|(line_idx, _, panel)| (line_idx, panel)) .unwrap_or_else(|| { ( - diff_view.scroll_offset + (top_row - pl.inner_y) as usize, + diff_view.fallback_line_idx_for_row(top_row, &pl), selection.panel, ) }); @@ -2580,7 +2580,7 @@ pub fn render_selection_overlay( let line_idx = diff_view .line_chunk_at_row(row, &pl) .map(|(line_idx, _)| line_idx) - .unwrap_or_else(|| diff_view.scroll_offset + (row - pl.inner_y) as usize); + .unwrap_or_else(|| diff_view.fallback_line_idx_for_row(row, &pl)); if let Some(diff_line) = diff_view.lines.get(line_idx) { // Skip file header separator lines. if diff_line.file_header.is_some() { diff --git a/src/pager/diff_algo.rs b/src/pager/diff_algo.rs index 6b6e862..bf61eca 100644 --- a/src/pager/diff_algo.rs +++ b/src/pager/diff_algo.rs @@ -28,6 +28,7 @@ pub fn compute_side_by_side(old: &str, new: &str, tab_width: usize) -> Vec Vec Vec V old_segments, new_segments, file_header: None, + preview_placeholder: None, section_index: 0, }); } @@ -204,6 +208,7 @@ pub fn compute_side_by_side_from_unified_diff(diff: &str, tab_width: usize) -> V old_segments: None, new_segments: None, file_header: None, + preview_placeholder: None, section_index: 0, }); old_num += 1; diff --git a/src/pager/mod.rs b/src/pager/mod.rs index a3b5330..b7fb6be 100644 --- a/src/pager/mod.rs +++ b/src/pager/mod.rs @@ -20,6 +20,8 @@ pub struct DiffLine { pub new_segments: Option>, /// If set, this line is a file header separator (multi-file diffs). pub file_header: Option, + /// Non-source placeholder row. Space-only labels provide vertical padding without source content. + pub preview_placeholder: Option<&'static str>, /// Index of the file section this line belongs to (for multi-file highlighting). pub section_index: usize, } diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index d9952a6..f285724 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -456,6 +456,127 @@ impl DiffViewState { &self.filename } + /// True when this diff contains per-file separator lines (multi-file diff). + pub fn has_file_headers(&self) -> bool { + self.lines.iter().any(|l| l.file_header.is_some()) + } + + fn header_idx_at_or_before(&self, line_idx: usize) -> Option { + if self.lines.is_empty() { + return None; + } + let clamped = line_idx.min(self.lines.len().saturating_sub(1)); + for i in (0..=clamped).rev() { + if self + .lines + .get(i) + .and_then(|l| l.file_header.as_ref()) + .is_some() + { + return Some(i); + } + } + None + } + + /// Sticky header for a given DiffLine index. + /// Returns `(header_line_idx, header_text)` when `line_idx` is content + /// scrolled past its file header (so the header is no longer visible). + /// Returns `None` when the top row already is a header or when this + /// is a single-file diff with no separators. + pub fn sticky_header_for_line(&self, line_idx: usize) -> Option<(usize, &str)> { + if !self.has_file_headers() { + return None; + } + let line = self.lines.get(line_idx)?; + if line.file_header.is_some() { + return None; + } + let h_idx = self.header_idx_at_or_before(line_idx)?; + let header = self.lines.get(h_idx)?.file_header.as_ref()?; + Some((h_idx, header.as_str())) + } + + fn sticky_for_unified_with_width(&self, content_width: usize) -> Option<(usize, &str)> { + let w = content_width.max(1); + let (line_idx, _, _) = self.unified_line_at_visual_row(self.scroll_offset, w)?; + self.sticky_header_for_line(line_idx) + } + + /// Header text to pin to the first row of the viewport, if any. + /// Split layouts use `scroll_offset` directly; unified maps the visual + /// scroll row back to a DiffLine first. + pub fn sticky_file_header(&self) -> Option<&str> { + if self.view_layout == DiffViewLayout::Unified { + let w = self.last_content_width.max(1); + self.sticky_for_unified_with_width(w).map(|(_, h)| h) + } else { + self.sticky_header_for_line(self.scroll_offset) + .map(|(_, h)| h) + } + } + + /// DiffLine index of the header currently pinned by [`Self::sticky_file_header`]. + pub fn sticky_header_line_idx(&self) -> Option { + if self.view_layout == DiffViewLayout::Unified { + let w = self.last_content_width.max(1); + self.sticky_for_unified_with_width(w).map(|(idx, _)| idx) + } else { + self.sticky_header_for_line(self.scroll_offset) + .map(|(idx, _)| idx) + } + } + + /// True when `row` is the pinned sticky header row for this layout. + /// Used to skip highlight/selection work on the pinned row and to + /// adjust row -> line mapping for the reserved top row. + pub fn is_sticky_row(&self, row: u16, layout: &DiffPanelLayout) -> bool { + if row != layout.inner_y { + return false; + } + if self.view_layout == DiffViewLayout::Unified { + let content_width = layout + .new_content_end_x + .saturating_sub(layout.new_content_x) as usize; + self.sticky_for_unified_with_width(content_width.max(1)) + .is_some() + } else { + self.sticky_header_for_line(self.scroll_offset).is_some() + } + } + + /// Best-effort DiffLine index for a terminal row when hit-testing fails + /// (past end of diff). Mirrors [`Self::line_chunk_at_row`] including the + /// reserved sticky row so callers don't drift by one. + pub fn fallback_line_idx_for_row(&self, row: u16, layout: &DiffPanelLayout) -> usize { + if row < layout.inner_y { + return 0; + } + let raw_off = (row - layout.inner_y) as usize; + if self.view_layout == DiffViewLayout::Unified { + let content_width = layout + .new_content_end_x + .saturating_sub(layout.new_content_x) as usize; + let sticky = self + .sticky_for_unified_with_width(content_width.max(1)) + .is_some(); + let adj = raw_off.saturating_sub(if sticky { 1 } else { 0 }); + // Map the adjusted visual offset precisely instead of guessing. + if let Some((line_idx, _, _)) = + self.unified_line_chunk_panel_from(self.scroll_offset, adj, content_width.max(1)) + { + return line_idx; + } + return self.lines.len(); + } + let sticky = self.sticky_header_for_line(self.scroll_offset).is_some(); + let adj = raw_off.saturating_sub(if sticky { 1 } else { 0 }); + if sticky && raw_off == 0 { + return self.sticky_header_line_idx().unwrap_or(self.scroll_offset); + } + self.scroll_offset.saturating_add(adj) + } + /// Activate search mode with an empty query. pub fn start_search(&mut self) { self.search_active = true; @@ -765,6 +886,7 @@ impl DiffViewState { old_segments: None, new_segments: None, file_header: Some((*file_name).clone()), + preview_placeholder: None, section_index: section_idx, }); @@ -953,6 +1075,7 @@ impl DiffViewState { old_segments: None, new_segments: None, file_header: Some((*file_name).clone()), + preview_placeholder: None, section_index: section_idx, }); @@ -1278,17 +1401,64 @@ impl DiffViewState { if row < layout.inner_y || row >= layout.inner_end_y { return None; } - let target_off = (row - layout.inner_y) as usize; if self.view_layout == DiffViewLayout::Unified { let content_width = layout .new_content_end_x .saturating_sub(layout.new_content_x) as usize; + if let Some((h_idx, _)) = self.sticky_for_unified_with_width(content_width.max(1)) { + if row == layout.inner_y { + return Some((h_idx, 0)); + } + let target_off = (row - layout.inner_y - 1) as usize; + return self + .unified_line_chunk_panel_at_offset(target_off, content_width) + .map(|(line_idx, chunk_idx, _)| (line_idx, chunk_idx)); + } + let target_off = (row - layout.inner_y) as usize; return self .unified_line_chunk_panel_at_offset(target_off, content_width) .map(|(line_idx, chunk_idx, _)| (line_idx, chunk_idx)); } + // Split layouts: the first row may be a pinned sticky header. + if let Some((h_idx, _)) = self.sticky_header_for_line(self.scroll_offset) { + if row == layout.inner_y { + return Some((h_idx, 0)); + } + let target_off = (row - layout.inner_y - 1) 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; + // Guard when scroll_offset is past the end (e.g. transient reload). + if self.scroll_offset >= self.lines.len() { + return None; + } + 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; + } + 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() { @@ -1306,6 +1476,9 @@ impl DiffViewState { .saturating_sub(layout.new_content_x) as usize; let mut acc = 0usize; + if self.scroll_offset >= self.lines.len() { + return None; + } 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); @@ -1326,15 +1499,29 @@ impl DiffViewState { layout: &DiffPanelLayout, fallback_panel: DiffPanel, ) -> Option<(usize, usize, DiffPanel)> { - let (line_idx, chunk_idx) = self.line_chunk_at_row(row, layout)?; - if self.view_layout != DiffViewLayout::Unified { - return Some((line_idx, chunk_idx, fallback_panel)); + if row < layout.inner_y || row >= layout.inner_end_y { + return None; } - - let content_width = layout - .new_content_end_x - .saturating_sub(layout.new_content_x) as usize; - self.unified_line_chunk_panel_at_offset((row - layout.inner_y) as usize, content_width) + if self.view_layout == DiffViewLayout::Unified { + let content_width = layout + .new_content_end_x + .saturating_sub(layout.new_content_x) as usize; + if self + .sticky_for_unified_with_width(content_width.max(1)) + .is_some() + { + if row == layout.inner_y { + let (h_idx, _) = self.sticky_for_unified_with_width(content_width.max(1))?; + return Some((h_idx, 0, fallback_panel)); + } + let adj = (row - layout.inner_y - 1) as usize; + return self.unified_line_chunk_panel_at_offset(adj, content_width); + } + let off = (row - layout.inner_y) as usize; + return self.unified_line_chunk_panel_at_offset(off, content_width); + } + let (line_idx, chunk_idx) = self.line_chunk_at_row(row, layout)?; + Some((line_idx, chunk_idx, fallback_panel)) } fn unified_line_chunk_panel_at_offset( @@ -1622,6 +1809,25 @@ pub fn render_diff( let inner = block.inner(area); frame.render_widget(block, area); + // Only a lone unavailable file fills the viewport. In multi-file buffers, + // the five placeholder rows below remain ordinary scrollable rows. + if state + .lines + .iter() + .all(|line| line.preview_placeholder.is_some()) + { + let message = state + .lines + .iter() + .find_map(|line| { + line.preview_placeholder + .filter(|message| !message.trim().is_empty()) + }) + .unwrap_or(""); + render_preview_placeholder(frame.buffer_mut(), inner, message, theme); + return; + } + if inner.width < 10 || inner.height < 2 { return; } @@ -1659,12 +1865,20 @@ pub fn render_diff( let show_panel = single_side.unwrap_or(DiffPanel::New); // new-file defaults to New let content_width = inner.width.saturating_sub(gutter_width); + // Sticky file header: pin the current file's separator to the first + // row when scrolled past it in a multi-file diff. + let sticky = state.sticky_file_header().map(|s| s.to_string()); let mut row = 0usize; - for (idx_offset, diff_line) in state.lines[state.scroll_offset..].iter().enumerate() { + if let Some(ref header) = sticky { + render_file_header(buf, inner.x, inner.y, inner.width, header, theme); + row = 1; + } + let start = state.scroll_offset.min(state.lines.len()); + for (idx_offset, diff_line) in state.lines[start..].iter().enumerate() { if row >= visible_height { break; } - let line_idx = state.scroll_offset + idx_offset; + let line_idx = start + idx_offset; // Handle file header separator lines if let Some(ref header) = diff_line.file_header { @@ -1674,6 +1888,17 @@ pub fn render_diff( continue; } + if let Some(message) = diff_line.preview_placeholder { + render_preview_placeholder( + buf, + Rect::new(inner.x, inner.y + row as u16, inner.width, 1), + message, + theme, + ); + row += 1; + continue; + } + let default_hl = FileHighlighter::default(); let (old_highlighter, new_highlighter) = state .highlighters_for_section(diff_line.section_index) @@ -1823,12 +2048,20 @@ pub fn render_diff( let mut hover_tooltip_y: Option = None; + // Sticky file header: pin the current file's separator to the first + // row when scrolled past it in a multi-file diff. + let sticky = state.sticky_file_header().map(|s| s.to_string()); let mut row = 0usize; - for (idx_offset, diff_line) in state.lines[state.scroll_offset..].iter().enumerate() { + if let Some(ref header) = sticky { + render_file_header(buf, inner.x, inner.y, inner.width, header, theme); + row = 1; + } + let start = state.scroll_offset.min(state.lines.len()); + for (idx_offset, diff_line) in state.lines[start..].iter().enumerate() { if row >= visible_height { break; } - let line_idx = state.scroll_offset + idx_offset; + let line_idx = start + idx_offset; // Handle file header separator lines if let Some(ref header) = diff_line.file_header { @@ -1843,6 +2076,17 @@ pub fn render_diff( .highlighters_for_section(diff_line.section_index) .unwrap_or((&default_hl, &default_hl)); + if let Some(message) = diff_line.preview_placeholder { + render_preview_placeholder( + buf, + Rect::new(inner.x, inner.y + row as u16, inner.width, 1), + message, + theme, + ); + row += 1; + continue; + } + let staged = state.is_staged_line(line_idx); let (left_bg, right_bg) = line_bg_colors(diff_line.change_type, theme, staged); let (left_gutter_bg, right_gutter_bg) = @@ -2184,7 +2428,16 @@ fn render_unified_diff_body( // (all deletes in a change block, then all inserts) so mid-block scrolling // does not drop earlier paired lines. let mut skip = state.scroll_offset; + // Sticky file header: pin the current file's separator to the first row + // when scrolled past it in a multi-file diff. + let sticky = state + .sticky_for_unified_with_width(content_width as usize) + .map(|(_, h)| h.to_string()); let mut row = 0usize; + if let Some(ref header) = sticky { + render_file_header(buf, inner.x, inner.y, inner.width, header, theme); + row = 1; + } let mut line_idx = 0usize; while line_idx < state.lines.len() { if row >= visible_height { @@ -2205,6 +2458,22 @@ fn render_unified_diff_body( continue; } + if let Some(message) = diff_line.preview_placeholder { + if skip > 0 { + skip -= 1; + } else { + render_preview_placeholder( + buf, + Rect::new(inner.x, inner.y + row as u16, inner.width, 1), + message, + theme, + ); + row += 1; + } + line_idx += 1; + continue; + } + if diff_line.change_type == ChangeType::Equal { let height = unified_line_visual_height(diff_line, content_width as usize, state); if skip >= height { @@ -2564,6 +2833,48 @@ fn render_revert_tooltip( } /// Render a file header separator line spanning the full width. +/// The same diagonal glyph and muted foreground used for absent diff sides. +fn render_preview_placeholder(buf: &mut Buffer, area: Rect, message: &str, theme: &Theme) { + let area = area.intersection(buf.area); + if area.is_empty() { + return; + } + let hatch = "╱".repeat(area.width as usize); + let hatch_style = Style::reset().fg(theme.diff_line_number); + for y in area.y..area.bottom() { + buf.set_stringn(area.x, y, &hatch, area.width as usize, hatch_style); + } + if message.is_empty() { + return; + } + // Keep the reason on one row even in narrow terminals; never wrap it into + // the next file's header. ASCII labels make clipping cell-width safe. + let label = if area.width as usize >= message.len() + 4 { + format!(" {message} ") + } else { + message.chars().take(area.width as usize).collect() + }; + let x = area.x + (area.width - label.len() as u16) / 2; + let y = area.y + area.height / 2; + let padding = " ".repeat(label.len()); + for padding_y in y.saturating_sub(1).max(area.y)..=(y + 1).min(area.bottom() - 1) { + buf.set_stringn( + x, + padding_y, + &padding, + label.len(), + Style::reset().fg(theme.text_dimmed), + ); + } + buf.set_stringn( + x, + y, + label, + area.width as usize, + Style::reset().fg(theme.text_dimmed), + ); +} + fn render_file_header(buf: &mut Buffer, x: u16, y: u16, width: u16, filename: &str, theme: &Theme) { let buf_area = buf.area(); if y < buf_area.y || y >= buf_area.y + buf_area.height { @@ -3066,10 +3377,14 @@ pub fn render_diff_search_highlights( if y >= pl.inner_end_y || y >= buf_area.y + buf_area.height { break; } + // Never highlight the pinned sticky header row. + if state.is_sticky_row(y, &pl) { + continue; + } let line_idx = state .line_chunk_at_row(y, &pl) .map(|(line_idx, _)| line_idx) - .unwrap_or(state.scroll_offset + row_offset); + .unwrap_or_else(|| state.fallback_line_idx_for_row(y, &pl)); let is_current_line = current_match_line == Some(line_idx); @@ -3365,7 +3680,7 @@ fn parse_unified_diff(diff: &str) -> (String, String) { fn diff_lines_from_unified_or_rename_only(diff: &str, tab_width: usize) -> Vec { if is_binary_diff(diff) { - return binary_file_placeholder_lines(tab_width); + return binary_file_placeholder_lines(); } if let Some((old_path, new_path)) = rename_only_paths(diff) { return rename_only_lines(&old_path, &new_path, tab_width); @@ -3377,24 +3692,33 @@ fn diff_lines_from_unified_or_rename_only(diff: &str, tab_width: usize) -> Vec bool { diff.lines().any(|line| { - let t = line.trim_start(); - t.starts_with("Binary files ") - || t.starts_with("Binary file ") - || t.starts_with("GIT binary patch") + // Do not trim: a leading space marks ordinary hunk context, not metadata. + line.starts_with("Binary files ") + || line.starts_with("Binary file ") + || line.starts_with("GIT binary patch") }) } -fn binary_file_placeholder_lines(tab_width: usize) -> Vec { - let msg = super::expand_tabs("Binary file (not viewable)", tab_width); - vec![DiffLine { - old_line: Some((1, msg.clone())), - new_line: Some((1, msg)), +fn binary_file_placeholder_lines() -> Vec { + [ + "", + " ", + "Binary cannot be previewed", + " ", + "", + ] + .into_iter() + .map(|message| DiffLine { + old_line: None, + new_line: None, change_type: ChangeType::Equal, old_segments: None, new_segments: None, file_header: None, + preview_placeholder: Some(message), section_index: 0, - }] + }) + .collect() } /// True when git emitted a rename/copy with no content hunks (pure move). @@ -3432,6 +3756,7 @@ fn rename_only_lines(old_path: &str, new_path: &str, tab_width: usize) -> Vec Buffer { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + render_diff( + frame, + Rect::new(0, 0, width, height), + state, + &Theme::dark(), + true, + false, + true, + ); + }) + .unwrap(); + terminal.backend().buffer().clone() + } + + fn buffer_row(buf: &Buffer, y: u16) -> String { + (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect() + } + + #[test] + fn lone_binary_fills_pane_in_every_layout() { + for layout in [DiffViewLayout::SideBySide, DiffViewLayout::Unified] { + for side in [ + DiffSideView::Both, + DiffSideView::OldOnly, + DiffSideView::NewOnly, + ] { + let mut state = DiffViewState::new(); + state.load_from_diff_output("foo.png", BINARY_DIFF); + state.view_layout = layout; + state.side_view = side; + let buf = render_preview(&mut state, 60, 12); + assert!(buffer_row(&buf, 6).contains(" Binary cannot be previewed ")); + for y in [1, 4, 8, 10] { + assert_eq!( + (1..59).map(|x| buf[(x, y)].symbol()).collect::(), + "╱".repeat(58) + ); + } + for y in [5, 7] { + assert_eq!( + buffer_row(&buf, y), + format!("│{}{}{}│", "╱".repeat(14), " ".repeat(30), "╱".repeat(14)) + ); + } + assert_eq!(buf[(1, 1)].fg, Theme::dark().diff_line_number); + assert!(state.file_line_number(1, DiffPanel::New).is_none()); + } + } + } + + #[test] + fn mixed_binary_placeholder_is_compact_in_every_layout() { + let diff = format!("{BINARY_DIFF}{TEXT_DIFF}{BINARY_DIFF}"); + for layout in [DiffViewLayout::SideBySide, DiffViewLayout::Unified] { + for side in [ + DiffSideView::Both, + DiffSideView::OldOnly, + DiffSideView::NewOnly, + ] { + for wrap in [false, true] { + for width in [24, 80] { + let mut state = DiffViewState::new(); + state.apply_parsed(DiffViewState::parse_diff_output( + "mixed", &diff, 4, true, + )); + state.view_layout = layout; + state.side_view = side; + state.wrap = wrap; + let buf = render_preview(&mut state, width, 18); + assert!(buffer_row(&buf, 1).contains("foo.png")); + assert!(buffer_row(&buf, 4).contains("Binary")); + for y in [2, 6] { + assert_eq!( + (1..width - 1) + .map(|x| buf[(x, y)].symbol()) + .collect::(), + "╱".repeat(width as usize - 2) + ); + } + for y in [3, 5] { + let row = buffer_row(&buf, y); + assert!(row.contains(&" ".repeat(30.min(width as usize - 2)))); + if width == 80 { + assert!(row.contains('╱')); + } + } + assert!(buffer_row(&buf, 7).contains("text.txt")); + assert!( + buffer_row(&buf, 8).contains(if side == DiffSideView::NewOnly { + "new" + } else { + "old" + }) + ); + // Exactly five rows per binary, not viewport-sized blocks. + assert_eq!( + state + .lines + .iter() + .filter(|line| line.preview_placeholder.is_some()) + .count(), + 10 + ); + assert_eq!(state.hunk_starts, vec![7]); + assert_eq!(state.file_line_number(7, DiffPanel::New), Some(1)); + let panel = DiffPanelLayout::compute(buf.area, &state); + assert_eq!(state.line_chunk_at_row(7, &panel), Some((6, 0))); + state.start_search(); + state.search_query = "Binary".to_string(); + state.update_search(); + assert!(state.search_matches.is_empty()); + } + } + } + } + } + + #[test] + fn scrolling_into_binary_keeps_sticky_header_and_next_file_aligned() { + for layout in [DiffViewLayout::SideBySide, DiffViewLayout::Unified] { + for wrap in [false, true] { + let mut state = DiffViewState::new(); + state.load_from_diff_output("mixed", &format!("{BINARY_DIFF}{TEXT_DIFF}")); + state.view_layout = layout; + state.wrap = wrap; + state.scroll_offset = 3; // Middle row of the binary placeholder. + let buf = render_preview(&mut state, 80, 10); + assert!(buffer_row(&buf, 1).contains("foo.png")); + assert!(buffer_row(&buf, 2).contains("Binary cannot be previewed")); + assert!(buffer_row(&buf, 3).contains('╱')); + assert_eq!( + (1..79).map(|x| buf[(x, 4)].symbol()).collect::(), + "╱".repeat(78) + ); + assert!(buffer_row(&buf, 5).contains("text.txt")); + let panel = DiffPanelLayout::compute(buf.area, &state); + assert_eq!(state.line_chunk_at_row(5, &panel), Some((6, 0))); + } + } + } + + #[test] + fn placeholder_clips_to_tiny_offset_areas() { + for width in 0..32 { + for height in 0..4 { + let mut buf = Buffer::empty(Rect::new(0, 0, 40, 8)); + let area = Rect::new(3, 2, width, height); + render_preview_placeholder( + &mut buf, + area, + "Binary cannot be previewed", + &Theme::dark(), + ); + for y in 0..8 { + for x in 0..40 { + if x < area.x || x >= area.right() || y < area.y || y >= area.bottom() { + assert_eq!(buf[(x, y)].symbol(), " "); + } + } + } + } + } + } + + #[test] + fn binary_patch_is_placeholder_but_binary_words_in_source_are_not() { + let parsed = DiffViewState::parse_diff_output( + "foo.png", + "diff --git a/foo.png b/foo.png\nGIT binary patch\nliteral 3\nabc\n", + 4, + true, + ); + assert_eq!( + parsed.lines[2].preview_placeholder, + Some("Binary cannot be previewed") + ); + let text = "diff --git a/text.txt b/text.txt\n--- a/text.txt\n+++ b/text.txt\n@@ -1,2 +1,2 @@\n Binary files are normal text here\n-old\n+new\n"; + let parsed = DiffViewState::parse_diff_output("text.txt", text, 4, true); + assert!( + parsed + .lines + .iter() + .all(|line| line.preview_placeholder.is_none()) ); + assert!(!parsed.hunk_starts.is_empty()); } } From 5b2197f3d22cd3506d03065817c7a28742d0f637 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 30 Sep 2026 01:25:47 +0800 Subject: [PATCH 03/11] fix(ui): prevent ANSI styling leaks from commit text --- Cargo.lock | 19 ++++ Cargo.toml | 1 + src/gui/mod.rs | 6 +- src/gui/presentation/commit_details.rs | 20 +++-- src/gui/presentation/commits.rs | 115 ++++++++++++++++++++----- src/gui/presentation/mod.rs | 1 + src/gui/presentation/reflog.rs | 5 +- src/gui/presentation/stash.rs | 8 +- src/gui/presentation/text.rs | 56 ++++++++++++ src/gui/views.rs | 8 +- 10 files changed, 204 insertions(+), 35 deletions(-) create mode 100644 src/gui/presentation/text.rs diff --git a/Cargo.lock b/Cargo.lock index 3f4c2ed..bfc8b4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,6 +553,7 @@ dependencies = [ "serde_json", "serde_yaml", "similar", + "strip-ansi-escapes", "textwrap", "thiserror 2.0.20", "tokio", @@ -997,6 +998,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1430,6 +1440,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index f4d0645..8118aea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ unicode-width = "0.2" tui-textarea = { version = "0.7", features = ["crossterm"] } textwrap = "0.16.2" libc = "0.2" +strip-ansi-escapes = "0.2" [profile.release] lto = true diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 11997c3..ff0674f 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -5538,7 +5538,11 @@ impl Gui { for commit in model.commits.iter().skip(1) { items.push(ListPickerItem { value: commit.hash.clone(), - label: format!("{} {}", commit.short_hash(), commit.name), + label: format!( + "{} {}", + commit.short_hash(), + presentation::text::plain_text(&commit.name) + ), category: "Commits".to_string(), description: None, }); diff --git a/src/gui/presentation/commit_details.rs b/src/gui/presentation/commit_details.rs index 3c08e9c..8bc2cc2 100644 --- a/src/gui/presentation/commit_details.rs +++ b/src/gui/presentation/commit_details.rs @@ -4,6 +4,7 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use super::text::plain_text; use crate::config::Theme; use crate::model::commit::{Commit, CommitStat, CommitStatus}; @@ -61,9 +62,9 @@ pub fn render_commit_details( return; } - let message = full_message.unwrap_or(&commit.name); - let co_authors = parse_co_authors(message); - let display_message = strip_co_author_trailers(message); + let message = plain_text(full_message.unwrap_or(&commit.name)); + let co_authors = parse_co_authors(&message); + let display_message = strip_co_author_trailers(&message); let mut lines: Vec = Vec::new(); @@ -72,7 +73,10 @@ pub fn render_commit_details( if !compact && !commit.author_email.is_empty() { lines.push(Line::from(vec![ Span::styled(" ✉ ", Style::default().fg(theme.text_dimmed)), - Span::styled(commit.author_email.clone(), Style::default().fg(theme.text)), + Span::styled( + plain_text(&commit.author_email), + Style::default().fg(theme.text), + ), ])); } @@ -253,13 +257,13 @@ fn strip_co_author_trailers(message: &str) -> String { } fn header_line<'a>(commit: &'a Commit, theme: &Theme) -> Line<'a> { - let initial = commit - .author_name + let author_name = plain_text(&commit.author_name); + let initial = author_name .chars() .next() .map(|c| c.to_ascii_uppercase()) .unwrap_or('?'); - let avatar_color = avatar_color_for(&commit.author_email, theme); + let avatar_color = avatar_color_for(&plain_text(&commit.author_email), theme); let date = format_date(commit.unix_timestamp); Line::from(vec![ @@ -272,7 +276,7 @@ fn header_line<'a>(commit: &'a Commit, theme: &Theme) -> Line<'a> { ), Span::raw(" "), Span::styled( - commit.author_name.clone(), + author_name, Style::default() .fg(theme.text_strong) .add_modifier(Modifier::BOLD), diff --git a/src/gui/presentation/commits.rs b/src/gui/presentation/commits.rs index 838b9a5..ecd135a 100644 --- a/src/gui/presentation/commits.rs +++ b/src/gui/presentation/commits.rs @@ -8,6 +8,7 @@ use crate::model::Model; use crate::model::commit::{Commit, CommitStatus}; use super::graph; +use super::text::plain_text; #[derive(Default)] pub struct CommitListCache { @@ -38,14 +39,14 @@ impl GraphLayoutCache { } } -pub fn render_sub_commit_list_window( - model: &Model, +pub fn render_sub_commit_list_window<'a>( + model: &'a Model, theme: &Theme, offset: usize, visible_height: usize, full: bool, cache: &mut CommitListCache, -) -> Vec> { +) -> Vec> { cache .sub_commits .update(&model.sub_commits, model.sub_commits_revision); @@ -61,15 +62,15 @@ pub fn render_sub_commit_list_window( ) } -pub fn render_commit_list_window( - model: &Model, +pub fn render_commit_list_window<'a>( + model: &'a Model, theme: &Theme, cherry_picked: &[String], offset: usize, visible_height: usize, full: bool, cache: &mut CommitListCache, -) -> Vec> { +) -> Vec> { cache.commits.update(&model.commits, model.commits_revision); render_commits_window( &model.commits, @@ -90,8 +91,8 @@ pub fn render_commit_list_window( /// same compact/expanded responsiveness as lazygit. /// - Fixed columns (hash/date/author) are padded to the visible window's max /// width so rows align like lazygit's `RenderDisplayStrings`. -fn render_commits_window( - commits: &[Commit], +fn render_commits_window<'a>( + commits: &'a [Commit], head_hash: &str, theme: &Theme, cherry_picked: &[String], @@ -99,7 +100,7 @@ fn render_commits_window( visible_height: usize, full: bool, graph_layout: &GraphLayoutCache, -) -> Vec> { +) -> Vec> { let visible: Vec<(usize, &Commit)> = commits .iter() .enumerate() @@ -121,19 +122,21 @@ fn render_commits_window( } }) .collect(); - let authors: Vec = visible + let authors: Vec<(String, Color)> = visible .iter() .map(|(_, c)| { - if full { - long_author(&c.author_name, 17) + let author = plain_text(&c.author_name); + let label = if full { + long_author(&author, 17) } else { - author_initials(&c.author_name) - } + author_initials(&author) + }; + (label, author_color(&author)) }) .collect(); let date_w = dates.iter().map(|s| s.width()).max().unwrap_or(0); - let author_w = authors.iter().map(|s| s.width()).max().unwrap_or(0); + let author_w = authors.iter().map(|(s, _)| s.width()).max().unwrap_or(0); visible .iter() @@ -141,7 +144,7 @@ fn render_commits_window( .map(|(vi, (i, commit))| { let graph_row = graph_layout.rows.get(*i); let is_head = commit.hash == *head_hash; - let mut spans: Vec> = Vec::new(); + let mut spans: Vec> = Vec::new(); // Hash (8, lazygit default) — color by push status. let is_cherry_picked = cherry_picked.iter().any(|h| *h == commit.hash); @@ -172,10 +175,9 @@ fn render_commits_window( // Author — per-author color like lazygit's AuthorStyle. if author_w > 0 { - let color = author_color(&commit.author_name); spans.push(Span::styled( - pad_to(&authors[vi], author_w), - Style::default().fg(color), + pad_to(&authors[vi].0, author_w), + Style::default().fg(authors[vi].1), )); spans.push(Span::raw(" ")); } @@ -210,7 +212,7 @@ fn render_commits_window( // Message. spans.push(Span::styled( - commit.name.clone(), + plain_text(&commit.name), Style::default().fg(theme.text_strong), )); @@ -436,6 +438,79 @@ mod tests { assert_eq!(items.len(), 1); } + #[test] + fn history_views_strip_ansi_before_clipping_or_wrapping() { + use ratatui::{ + Terminal, + backend::TestBackend, + widgets::{Block, Borders, List}, + }; + + let mut original = commit("31fc03a0", None); + original.name = "Add Vike skill — \x1b[4mhttps://vike.dev/ai#skill\x1b[24m".into(); + original.author_name = "\x1b[4mBlankeos\x1b[24m".into(); + original.author_email = "\x1b[4mdev@example.com\x1b[24m".into(); + let mut clean = original.clone(); + clean.name = plain_text(&clean.name).into_owned(); + clean.author_name = plain_text(&clean.author_name).into_owned(); + clean.author_email = plain_text(&clean.author_email).into_owned(); + + for width in [24, 60, 120] { + for view in 0..5 { + let render = |entry: Commit| { + let mut model = Model::default(); + model.set_commits(vec![entry.clone(), commit("aaaaaaaa", None)]); + model.reflog_commits = model.commits.clone(); + let theme = Theme::default(); + let mut terminal = Terminal::new(TestBackend::new(width, 12)).unwrap(); + terminal + .draw(|frame| { + if view >= 3 { + super::super::commit_details::render_commit_details( + frame, + frame.area(), + &entry, + None, + (view == 4).then_some(entry.name.as_str()), + &theme, + false, + &mut 0, + ); + } else { + let items = if view == 2 { + super::super::reflog::render_reflog_list(&model, &theme) + } else { + render_commit_list_window( + &model, + &theme, + &[], + 0, + 2, + view == 1, + &mut CommitListCache::default(), + ) + }; + frame.render_widget( + List::new(items).block(Block::default().borders(Borders::ALL)), + frame.area(), + ); + } + }) + .unwrap(); + terminal.backend().buffer().clone() + }; + let actual = render(original.clone()); + assert_eq!(actual, render(clean.clone()), "width={width}, view={view}"); + assert!(actual.content.iter().all(|cell| { + !cell.symbol().chars().any(char::is_control) + && !cell.modifier.contains(Modifier::UNDERLINED) + })); + } + } + // Rendering must not change data later used by Git operations. + assert!(original.name.contains('\x1b')); + } + #[test] fn author_initials_match_lazygit() { assert_eq!(author_initials(""), ""); diff --git a/src/gui/presentation/mod.rs b/src/gui/presentation/mod.rs index 1c0c69d..393adbd 100644 --- a/src/gui/presentation/mod.rs +++ b/src/gui/presentation/mod.rs @@ -11,3 +11,4 @@ pub mod remote_branches; pub mod remotes; pub mod stash; pub mod tags; +pub(crate) mod text; diff --git a/src/gui/presentation/reflog.rs b/src/gui/presentation/reflog.rs index 83ec625..350094b 100644 --- a/src/gui/presentation/reflog.rs +++ b/src/gui/presentation/reflog.rs @@ -2,10 +2,11 @@ use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::ListItem; +use super::text::plain_text; use crate::config::Theme; use crate::model::Model; -pub fn render_reflog_list<'a>(model: &Model, theme: &Theme) -> Vec> { +pub fn render_reflog_list<'a>(model: &'a Model, theme: &Theme) -> Vec> { model .reflog_commits .iter() @@ -16,7 +17,7 @@ pub fn render_reflog_list<'a>(model: &Model, theme: &Theme) -> Vec> Style::default().fg(theme.reflog_hash), ), Span::styled( - commit.name.clone(), + plain_text(&commit.name), Style::default().fg(theme.reflog_message), ), ]; diff --git a/src/gui/presentation/stash.rs b/src/gui/presentation/stash.rs index da4018f..300bfd7 100644 --- a/src/gui/presentation/stash.rs +++ b/src/gui/presentation/stash.rs @@ -2,10 +2,11 @@ use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::ListItem; +use super::text::plain_text; use crate::config::Theme; use crate::model::Model; -pub fn render_stash_list<'a>(model: &Model, theme: &Theme) -> Vec> { +pub fn render_stash_list<'a>(model: &'a Model, theme: &Theme) -> Vec> { model .stash_entries .iter() @@ -15,7 +16,10 @@ pub fn render_stash_list<'a>(model: &Model, theme: &Theme) -> Vec> format!(" {} ", entry.ref_name()), Style::default().fg(theme.stash_index), ), - Span::styled(entry.name.clone(), Style::default().fg(theme.stash_message)), + Span::styled( + plain_text(&entry.name), + Style::default().fg(theme.stash_message), + ), ]); ListItem::new(line) diff --git a/src/gui/presentation/text.rs b/src/gui/presentation/text.rs new file mode 100644 index 0000000..803c911 --- /dev/null +++ b/src/gui/presentation/text.rs @@ -0,0 +1,56 @@ +use std::borrow::Cow; + +/// Repository text is data, not terminal instructions. Strip escapes before +/// measuring/clipping it: clipping can otherwise remove an SGR reset while +/// leaving the opening escape to affect the rest of the terminal frame. +/// Keep newlines and expand tabs for multiline messages, but no other controls. +pub(crate) fn plain_text(text: &str) -> Cow<'_, str> { + if !text.chars().any(|c| c.is_control() && c != '\n') { + return Cow::Borrowed(text); + } + let input = if text.contains('\t') { + Cow::Owned(text.replace('\t', " ")) + } else { + Cow::Borrowed(text) + }; + let mut clean = strip_ansi_escapes::strip_str(input); + clean.retain(|c| !c.is_control() || c == '\n'); + Cow::Owned(clean) +} + +#[cfg(test)] +mod tests { + use super::plain_text; + + #[test] + fn clean_text_is_borrowed_including_multiline_unicode() { + for text in ["", "ordinary commit", "héllo 世界\n\nmessage body"] { + assert!( + matches!(plain_text(text), std::borrow::Cow::Borrowed(s) if std::ptr::eq(s, text)) + ); + } + } + + #[test] + fn removes_terminal_instructions_but_preserves_message_text() { + assert_eq!( + plain_text("Add Vike skill — \x1b[4mhttps://vike.dev/ai#skill\x1b[24m"), + "Add Vike skill — https://vike.dev/ai#skill" + ); + assert_eq!( + plain_text("\x1b[4munterminated style"), + "unterminated style" + ); + assert_eq!(plain_text("text\x1b[4"), "text"); + assert_eq!(plain_text("a\x1b[2Jb\x1b[Hc"), "abc"); + assert_eq!( + plain_text("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"), + "link" + ); + assert_eq!(plain_text("\x1b]0;window title\x07text"), "text"); + assert_eq!( + plain_text("héllo 世界\n\tmessage\x07\r"), + "héllo 世界\n message" + ); + } +} diff --git a/src/gui/views.rs b/src/gui/views.rs index 7772094..eb2d754 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -2098,9 +2098,13 @@ fn get_info_content<'a>(model: &Model, ctx_mgr: &ContextManager) -> Vec Line::from(format!(" Commit: {}", commit.short_hash())), Line::from(format!( " Author: {} <{}>", - commit.author_name, commit.author_email + super::presentation::text::plain_text(&commit.author_name), + super::presentation::text::plain_text(&commit.author_email) + )), + Line::from(format!( + " Message: {}", + super::presentation::text::plain_text(&commit.name) )), - Line::from(format!(" Message: {}", commit.name)), ] } else { vec![Line::from(" No commit selected")] From cfef18dfdd5c048aa3971a1da161a88128efb656 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Sat, 3 Oct 2026 03:36:52 +0800 Subject: [PATCH 04/11] feat(gui): align worktree names and branches in list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show each worktree’s directory name instead of its full path, align branch names using Unicode display width, and label the main worktree. Render submodule lists consistently across screen modes and add coverage for both lists. --- src/gui/views.rs | 248 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 220 insertions(+), 28 deletions(-) diff --git a/src/gui/views.rs b/src/gui/views.rs index eb2d754..2ad4155 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -197,6 +197,24 @@ pub fn render( ); } } + ContextId::Worktrees | ContextId::Submodules => { + let items = if ctx_id == ContextId::Worktrees { + render_worktree_list(model, theme) + } else { + render_submodule_list(model, theme) + }; + 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, @@ -492,31 +510,10 @@ pub fn render( ); } 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 = render_submodule_list(model, theme); + render_list_ctx( + frame, rect, block, items, selected, is_active, theme, ctx_mgr, ctx_id, + ); } ContextId::Branches => { // If BranchCommits or BranchCommitFiles is active, render that instead @@ -1216,6 +1213,22 @@ fn wrap_popup_lines(message: &str, width: usize) -> Vec { .collect() } +fn render_submodule_list<'a>(model: &Model, theme: &Theme) -> Vec> { + model + .submodules + .iter() + .map(|sub| { + ListItem::new(Line::from(vec![ + Span::styled( + format!(" {} ", sub.name), + Style::default().fg(theme.accent), + ), + Span::styled(sub.path.clone(), Style::default().fg(theme.text_dimmed)), + ])) + }) + .collect() +} + fn visible_popup_lines(wrapped: &[String], max_lines: usize) -> Vec { if wrapped.len() <= max_lines { return wrapped.to_vec(); @@ -1333,6 +1346,171 @@ mod tests { use ratatui::backend::TestBackend; use ratatui::layout::Rect; + fn render_tab( + model: &crate::model::Model, + context: super::ContextId, + mode: super::ScreenMode, + ) -> Vec { + let mut terminal = Terminal::new(TestBackend::new(160, 40)).unwrap(); + let mut contexts = super::ContextManager::new(); + contexts.set_active(context); + terminal + .draw(|frame| { + super::render( + frame, + model, + &mut contexts, + &super::LayoutState::default(), + &PopupState::None, + &crate::config::AppConfig { + debug: false, + version: String::new(), + user_config: Default::default(), + app_state: Default::default(), + config_dir: Default::default(), + state_dir: Default::default(), + state_path: Default::default(), + }, + &Theme::default(), + &mut super::DiffViewState::default(), + &mut super::presentation::commits::CommitListCache::default(), + mode, + false, + &[], + &Default::default(), + false, + None, + None, + &[], + false, + &[], + false, + &[], + &Default::default(), + "", + "", + "", + "", + super::ContextId::Commits, + 0, + None, + false, + &[], + None, + false, + false, + &Default::default(), + &Default::default(), + &mut 0, + &mut String::new(), + false, + false, + false, + ); + }) + .unwrap(); + terminal + .backend() + .buffer() + .content + .chunks(160) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect() + } + + fn sample_worktree( + path: &str, + branch: &str, + current: bool, + main: bool, + ) -> crate::model::Worktree { + crate::model::Worktree { + path: path.into(), + branch: branch.into(), + hash: String::new(), + is_current: current, + is_main: main, + } + } + + #[test] + fn worktrees_render_names_branches_and_main_label_in_every_mode() { + let model = crate::model::Model { + worktrees: vec![ + sample_worktree("/repos/quarta", "feat/main", true, true), + sample_worktree( + "/repos/worktree-pfparser", + "experiment/parser", + false, + false, + ), + ], + ..Default::default() + }; + for mode in [ + super::ScreenMode::Normal, + super::ScreenMode::Half, + super::ScreenMode::Full, + ] { + let rows = render_tab(&model, super::ContextId::Worktrees, mode); + let main = rows.iter().find(|row| row.contains("* quarta")).unwrap(); + assert!(main.contains("* quarta")); + assert!(main.contains("feat/main (main worktree)")); + assert!(!main.contains("/repos/")); + let linked = rows + .iter() + .find(|row| row.contains("worktree-pfparser experiment/parser")) + .unwrap(); + assert!(linked.contains("worktree-pfparser experiment/parser")); + assert!(!linked.contains("(main worktree)")); + assert!(!linked.contains("/repos/")); + assert_eq!(main.find("feat/main"), linked.find("experiment/parser")); + } + } + + #[test] + fn worktree_columns_use_unicode_display_width() { + let model = crate::model::Model { + worktrees: vec![ + sample_worktree("/repos/树", "branch-one", false, false), + sample_worktree("/repos/abc", "branch-two", true, false), + ], + ..Default::default() + }; + let rows = render_tab(&model, super::ContextId::Worktrees, super::ScreenMode::Full); + assert!(rows.iter().any(|row| row.contains("树 branch-one"))); + assert!(rows.iter().any(|row| row.contains("* abc branch-two"))); + } + + #[test] + fn submodules_render_in_every_mode_and_empty_lists_stay_blank() { + let mut model = crate::model::Model::default(); + for mode in [ + super::ScreenMode::Normal, + super::ScreenMode::Half, + super::ScreenMode::Full, + ] { + let rows = render_tab(&model, super::ContextId::Submodules, mode); + assert!(!rows.join("\n").contains("(no submodules)")); + } + model.submodules.push(crate::git::submodule::Submodule { + name: "shared-lib".into(), + path: "vendor/shared-lib".into(), + url: String::new(), + }); + for mode in [ + super::ScreenMode::Normal, + super::ScreenMode::Half, + super::ScreenMode::Full, + ] { + let rows = render_tab(&model, super::ContextId::Submodules, mode); + assert!( + rows.iter() + .any(|row| row.contains("shared-lib vendor/shared-lib")) + ); + } + } + #[test] fn command_log_is_hidden_when_main_panel_is_absent() { assert_eq!(command_log_geometry(Rect::default(), 1), None); @@ -1822,17 +2000,31 @@ fn render_status_main<'a>( } fn render_worktree_list<'a>(model: &Model, theme: &Theme) -> Vec> { - model + let names: Vec<_> = model .worktrees .iter() .map(|wt| { + std::path::Path::new(&wt.path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&wt.path) + }) + .collect(); + let name_width = names.iter().map(|name| name.width()).max().unwrap_or(0); + model + .worktrees + .iter() + .zip(names) + .map(|(wt, name)| { let marker = if wt.is_current { "* " } else { " " }; let line = Line::from(vec![ Span::styled(marker.to_string(), Style::default().fg(theme.accent)), + Span::styled(name.to_string(), Style::default().fg(theme.text)), + Span::raw(" ".repeat(name_width - name.width() + 1)), Span::styled(wt.branch.clone(), Style::default().fg(theme.ref_head)), Span::styled( - format!(" {}", wt.path), - Style::default().fg(theme.text_dimmed), + if wt.is_main { " (main worktree)" } else { "" }, + Style::default().fg(theme.text), ), ]); ListItem::new(line) From 6270031d6f223a9a8b7eb23cb70437d6e04514ab Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 6 Oct 2026 16:46:49 +0800 Subject: [PATCH 05/11] feat(compare-tool): add commit history panel to compare mode Show divergent commits between two refs with ahead/behind counts, side indicators, paging, and commit-file previews. Add commit-aware diff and grep behavior, panel focus/navigation, and revision validation. --- src/git/commit.rs | 52 +++- src/git/diff.rs | 24 ++ src/git/mod.rs | 253 +++++++++++++++++++ src/gui/controller/diff_grep.rs | 24 +- src/gui/controller/diff_mode.rs | 405 +++++++++++++++++++++++++++--- src/gui/mod.rs | 113 +++++---- src/gui/modes/diff_mode.rs | 304 +++++++++++++++++++++- src/gui/presentation/commits.rs | 35 ++- src/gui/presentation/diff_mode.rs | 323 +++++++++++++++++++++--- 9 files changed, 1393 insertions(+), 140 deletions(-) diff --git a/src/git/commit.rs b/src/git/commit.rs index 9e2fdce..ab484d0 100644 --- a/src/git/commit.rs +++ b/src/git/commit.rs @@ -94,6 +94,40 @@ impl GitCommands { self.load_filtered_commits_page(&CommitFilter::default(), limit, skip) } + /// Load the symmetric difference of two commit revisions in topological order. + /// Left marks A-only commits; Right marks B-only commits. A zero limit is + /// unlimited, matching the other paged commit loaders. + pub fn load_compare_commits( + &self, + ref_a: &str, + ref_b: &str, + limit: usize, + skip: usize, + ) -> Result> { + let range = format!( + "{}...{}", + self.resolve_commit_ref(ref_a)?, + self.resolve_commit_ref(ref_b)? + ); + // NUL field separators preserve pipes in commit subjects and identities. + // %m is '<' for the left side and '>' for the right side of A...B. + let mut cmd = self.git().args(&[ + "log", + "--left-right", + "--topo-order", + "--no-show-signature", + "--format=%H%x00%s%x00%an%x00%ae%x00%at%x00%P%x00%D%x00%m", + ]); + if limit > 0 { + cmd = cmd.arg(&format!("--max-count={limit}")); + } + if skip > 0 { + cmd = cmd.arg(&format!("--skip={skip}")); + } + let result = cmd.arg(&range).arg("--").run_expecting_success()?; + self.parse_commit_log_fields(&result, '\0') + } + pub fn load_filtered_commits_page( &self, filter: &CommitFilter, @@ -165,6 +199,14 @@ impl GitCommands { } fn parse_commit_log(&self, result: &crate::os::cmd::CmdResult) -> Result> { + self.parse_commit_log_fields(result, '|') + } + + fn parse_commit_log_fields( + &self, + result: &crate::os::cmd::CmdResult, + separator: char, + ) -> Result> { if !result.success { return Ok(Vec::new()); } @@ -173,7 +215,9 @@ impl GitCommands { // `git log @{u}..HEAD` per page parse (filter apply hot path). let mut commits = Vec::new(); for line in result.stdout.lines() { - let parts: Vec<&str> = line.splitn(7, '|').collect(); + let parts: Vec<&str> = line + .splitn(if separator == '\0' { 8 } else { 7 }, separator) + .collect(); if parts.len() < 6 { continue; } @@ -201,7 +245,11 @@ impl GitCommands { author_email, unix_timestamp, parents, - divergence: Divergence::None, + divergence: match parts.get(7).copied() { + Some("<") => Divergence::Left, + Some(">") => Divergence::Right, + _ => Divergence::None, + }, }); } diff --git a/src/git/diff.rs b/src/git/diff.rs index 0393cb7..18928b1 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -360,6 +360,30 @@ impl GitCommands { } } + /// Counts commits unique to A and B, respectively (A's ahead/behind B). + /// Both refs must resolve to commits; tree-only comparisons have no ancestry. + pub fn diff_refs_ahead_behind(&self, ref_a: &str, ref_b: &str) -> Result<(usize, usize)> { + let range = format!( + "{}...{}", + self.resolve_commit_ref(ref_a)?, + self.resolve_commit_ref(ref_b)? + ); + let result = self + .git() + .args(&["rev-list", "--left-right", "--count", &range, "--"]) + .run_expecting_success()?; + let mut counts = result.stdout.split_whitespace(); + let ahead = counts + .next() + .ok_or_else(|| anyhow::anyhow!("Missing ahead count"))? + .parse()?; + let behind = counts + .next() + .ok_or_else(|| anyhow::anyhow!("Missing behind count"))? + .parse()?; + Ok((ahead, behind)) + } + /// Get the list of files changed between two refs (for diff/compare mode). pub fn diff_refs_files( &self, diff --git a/src/git/mod.rs b/src/git/mod.rs index e762a45..8a131cd 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -338,6 +338,21 @@ impl GitCommands { Ok(result.stdout_trimmed().to_string()) } + /// Resolve a revision to a verified commit, peeling annotated tags. + /// Unlike `resolve_ref`, this rejects trees and option-like input. + fn resolve_commit_ref(&self, reference: &str) -> Result { + let result = self + .git() + .args(&[ + "rev-parse", + "--verify", + "--end-of-options", + &format!("{reference}^{{commit}}"), + ]) + .run_expecting_success()?; + Ok(result.stdout_trimmed().to_string()) + } + /// Get the name of the previously checked-out branch (`@{-1}`), if any. pub fn previous_branch_name(&self) -> Option { let result = self @@ -444,6 +459,244 @@ mod tests { } } + fn run_test_git(path: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(path) + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + ]) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() + } + + #[test] + fn load_compare_commits_linear_history_and_commit_revisions() { + use crate::model::commit::Divergence; + + let temp = TempDir::new("compare-commits-linear"); + let run = |args: &[&str]| run_test_git(temp.path(), args); + run(&["init", "-b", "main"]); + run(&["commit", "--allow-empty", "-m", "root"]); + let root = run(&["rev-parse", "HEAD"]); + run(&["tag", "-a", "base", "-m", "base"]); + run(&["checkout", "-b", "feature"]); + run(&["commit", "--allow-empty", "-m", "one | pipe"]); + let first = run(&["rev-parse", "HEAD"]); + run(&["commit", "--allow-empty", "-m", "two"]); + let tip = run(&["rev-parse", "HEAD"]); + run(&["tag", "-a", "tip", "-m", "tip"]); + let git = GitCommands::new(temp.path()).unwrap(); + + for (a, b, side) in [ + ("feature", "main", Divergence::Left), + ("base", "feature", Divergence::Right), + (root.as_str(), tip.as_str(), Divergence::Right), + ("HEAD~2", "tip", Divergence::Right), + ] { + let commits = git.load_compare_commits(a, b, 0, 0).unwrap(); + assert_eq!(commits.len(), 2); + assert_eq!(commits[0].hash, tip); + assert_eq!(commits[1].hash, first); + assert!(commits.iter().all(|commit| commit.divergence == side)); + assert_eq!(commits[1].name, "one | pipe"); + assert_eq!(commits[1].author_name, "Test"); + assert_eq!(commits[1].author_email, "test@example.com"); + assert!(commits[1].unix_timestamp > 0); + assert_eq!(commits[1].parents, [root.clone()]); + assert_eq!(commits[0].tags, ["tip"]); + assert!(commits[0].refs.iter().any(|r| r.contains("feature"))); + } + assert!( + git.load_compare_commits("feature", "tip", 10, 0) + .unwrap() + .is_empty() + ); + assert!( + git.load_compare_commits("main", "main", 10, 0) + .unwrap() + .is_empty() + ); + let commits = git.load_compare_commits("HEAD~1", "HEAD", 10, 0).unwrap(); + assert_eq!(commits.len(), 1); + assert_eq!(commits[0].hash, tip); + assert_eq!(commits[0].divergence, Divergence::Right); + } + + #[test] + fn load_compare_commits_diverged_history_and_paging() { + use crate::model::commit::Divergence; + + let temp = TempDir::new("compare-commits-diverged"); + let run = |args: &[&str]| run_test_git(temp.path(), args); + run(&["init", "-b", "main"]); + run(&["commit", "--allow-empty", "-m", "root"]); + let root = run(&["rev-parse", "HEAD"]); + run(&["checkout", "-b", "feature"]); + run(&["commit", "--allow-empty", "-m", "feature one"]); + let feature_one = run(&["rev-parse", "HEAD"]); + run(&["commit", "--allow-empty", "-m", "feature two"]); + let feature_two = run(&["rev-parse", "HEAD"]); + run(&["checkout", "main"]); + run(&["commit", "--allow-empty", "-m", "main one"]); + let main_one = run(&["rev-parse", "HEAD"]); + let git = GitCommands::new(temp.path()).unwrap(); + let commits = git.load_compare_commits("feature", "main", 0, 0).unwrap(); + assert_eq!(commits.len(), 3); + assert!(!commits.iter().any(|commit| commit.hash == root)); + for (hash, side) in [ + (&feature_one, Divergence::Left), + (&feature_two, Divergence::Left), + (&main_one, Divergence::Right), + ] { + assert_eq!( + commits.iter().find(|c| &c.hash == hash).unwrap().divergence, + side + ); + } + let position = |hash: &str| commits.iter().position(|c| c.hash == hash).unwrap(); + assert!(position(&feature_two) < position(&feature_one)); + + for skip in 0..commits.len() { + let page = git + .load_compare_commits("feature", "main", 1, skip) + .unwrap(); + assert_eq!(page.len(), 1); + assert_eq!(page[0].hash, commits[skip].hash); + assert_eq!(page[0].divergence, commits[skip].divergence); + } + assert!( + git.load_compare_commits("feature", "main", 2, 3) + .unwrap() + .is_empty() + ); + assert_eq!( + git.load_compare_commits("feature", "main", 0, 1) + .unwrap() + .len(), + 2 + ); + let reverse = git.load_compare_commits("main", "feature", 0, 0).unwrap(); + for commit in reverse { + let original = commits.iter().find(|c| c.hash == commit.hash).unwrap(); + assert_ne!(original.divergence, commit.divergence); + } + } + + #[test] + fn load_compare_commits_rejects_invalid_and_non_commit_refs() { + let temp = TempDir::new("compare-commits-invalid"); + let run = |args: &[&str]| run_test_git(temp.path(), args); + run(&["init", "-b", "main"]); + run(&["commit", "--allow-empty", "-m", "root"]); + let tree = run(&["rev-parse", "HEAD^{tree}"]); + run(&["tag", "-a", "tree-tag", &tree, "-m", "tree tag"]); + let git = GitCommands::new(temp.path()).unwrap(); + for reference in [ + "missing-ref", + "HEAD^{tree}", + tree.as_str(), + "tree-tag", + "--all", + "HEAD..HEAD", + "", + ] { + assert!( + git.load_compare_commits(reference, "main", 10, 0).is_err(), + "left: {reference}" + ); + assert!( + git.load_compare_commits("main", reference, 10, 0).is_err(), + "right: {reference}" + ); + } + } + + #[test] + fn diff_refs_ahead_behind_counts_commit_ancestry_not_file_changes() { + let temp = TempDir::new("compare-ancestry"); + let run = |args: &[&str]| { + let output = Command::new("git") + .current_dir(temp.path()) + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + ]) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() + }; + run(&["init", "-b", "main"]); + run(&["commit", "--allow-empty", "-m", "root"]); + let root = run(&["rev-parse", "HEAD"]); + run(&["tag", "-a", "base", "-m", "base"]); + run(&["checkout", "-b", "feature"]); + run(&["commit", "--allow-empty", "-m", "feature one"]); + run(&["commit", "--allow-empty", "-m", "feature two"]); + let tip = run(&["rev-parse", "HEAD"]); + run(&["update-ref", "refs/remotes/origin/feature", &tip]); + + let git = GitCommands::new(temp.path()).unwrap(); + assert_eq!( + git.diff_refs_ahead_behind("feature", "main").unwrap(), + (2, 0) + ); + assert_eq!( + git.diff_refs_ahead_behind("base", "feature").unwrap(), + (0, 2) + ); + assert_eq!(git.diff_refs_ahead_behind(&tip, &root).unwrap(), (2, 0)); + assert_eq!( + git.diff_refs_ahead_behind("HEAD~1", "HEAD").unwrap(), + (0, 1) + ); + assert_eq!( + git.diff_refs_ahead_behind("origin/feature", "feature") + .unwrap(), + (0, 0) + ); + assert!(git.diff_refs_files("base", "feature").unwrap().is_empty()); + + run(&["checkout", "main"]); + run(&["commit", "--allow-empty", "-m", "main one"]); + assert_eq!( + git.diff_refs_ahead_behind("feature", "main").unwrap(), + (2, 1) + ); + assert_eq!( + git.diff_refs_ahead_behind("main", "feature").unwrap(), + (1, 2) + ); + assert!(git.diff_refs_ahead_behind("missing-ref", "main").is_err()); + assert!(git.diff_refs_ahead_behind("HEAD^{tree}", "main").is_err()); + assert!(git.diff_refs_files("HEAD^{tree}", "main").is_ok()); + } + #[test] fn new_resolves_repo_path_to_worktree_root_from_subdirectory() { let temp = TempDir::new("repo-root"); diff --git a/src/gui/controller/diff_grep.rs b/src/gui/controller/diff_grep.rs index f6aebfe..3b45e0b 100644 --- a/src/gui/controller/diff_grep.rs +++ b/src/gui/controller/diff_grep.rs @@ -65,13 +65,21 @@ pub fn open_diff_grep_picker(gui: &mut Gui) -> Result<()> { }; return Ok(()); } - let ref_a = gui.diff_mode.ref_a.clone(); - let ref_b = gui.diff_mode.ref_b.clone(); - diff_text = gui - .git - .diff_refs_paths(&ref_a, &ref_b, &[]) - .unwrap_or_default(); - scope_title = format!("Grep {ref_a}..{ref_b}"); + if gui.diff_mode.diff_source == crate::gui::modes::diff_mode::CompareDiffSource::Commit { + super::diff_mode::open_selected_commit_files(gui)?; + } + if let Some(hash) = &gui.diff_mode.files_commit { + diff_text = gui.git.diff_commit(hash).unwrap_or_default(); + scope_title = format!("Grep {}", &hash[..8.min(hash.len())]); + } else { + let ref_a = gui.diff_mode.ref_a.clone(); + let ref_b = gui.diff_mode.ref_b.clone(); + diff_text = gui + .git + .diff_refs_paths(&ref_a, &ref_b, &[]) + .unwrap_or_default(); + scope_title = format!("Grep {ref_a}..{ref_b}"); + } scope = DiffGrepScope::Compare; } else { match gui.context_mgr.active() { @@ -175,6 +183,8 @@ fn confirm_diff_grep(gui: &mut Gui, scope: DiffGrepScope, value: &str) -> Result .position(|f| f.current_path() == path || f.name == path) }; let Some(idx) = idx else { return Ok(()) }; + gui.diff_mode + .set_focus(crate::gui::modes::diff_mode::DiffModeFocus::CommitFiles); if gui.show_file_tree { expand_ancestors(&mut gui.collapsed_dirs, &path); gui.update_file_tree_state(); diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index 04ec95b..bfebdf3 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -5,13 +5,105 @@ use anyhow::Result; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::config::keybindings::parse_key; -use crate::gui::modes::diff_mode::{DiffModeFocus, DiffModeSelector}; +use crate::gui::modes::diff_mode::{CompareDiffSource, DiffModeFocus, DiffModeSelector}; use crate::gui::popup::{CommandEntry, CommandSection, MenuItem, PopupState}; use crate::gui::{DiffPayload, Gui, textarea_input}; use crate::model::FileChangeStatus; use crate::os::platform::Platform; use crate::pager::side_by_side::{DiffPanelLayout, DiffViewState}; +fn handle_commits_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { + let selected = gui.diff_mode.commits_selected; + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + if selected + 1 >= gui.diff_mode.commits.len() { + load_more_commits(gui)?; + } + gui.diff_mode.commits_selected = + (selected + 1).min(gui.diff_mode.commits.len().saturating_sub(1)); + } + KeyCode::Char('k') | KeyCode::Up => { + gui.diff_mode.commits_selected = selected.saturating_sub(1) + } + KeyCode::PageDown => { + if selected + 10 >= gui.diff_mode.commits.len() { + load_more_commits(gui)?; + } + gui.diff_mode.commits_selected = + (selected + 10).min(gui.diff_mode.commits.len().saturating_sub(1)); + } + KeyCode::PageUp => gui.diff_mode.commits_selected = selected.saturating_sub(10), + KeyCode::Char('g') | KeyCode::Home => gui.diff_mode.commits_selected = 0, + KeyCode::Char('G') | KeyCode::End => { + gui.diff_mode.commits_selected = gui.diff_mode.commits.len().saturating_sub(1); + } + KeyCode::Enter => return open_selected_commit_files(gui), + KeyCode::Char('y') => { + if let Some(commit) = gui.diff_mode.selected_commit() { + Platform::copy_to_clipboard(&commit.hash)?; + } + } + _ => return Ok(()), + } + gui.diff_mode.commits_viewport_manually_scrolled = false; + gui.needs_diff_refresh = true; + Ok(()) +} + +pub fn load_more_commits(gui: &mut Gui) -> Result<()> { + if !gui.diff_mode.has_more_commits() { + return Ok(()); + } + let commits = gui.git.load_compare_commits( + &gui.diff_mode.ref_a, + &gui.diff_mode.ref_b, + crate::git::DEFAULT_COMMIT_LIMIT, + gui.diff_mode.commits.len(), + )?; + gui.diff_mode.commits.extend(commits); + gui.diff_mode.commits_revision = gui.diff_mode.commits_revision.wrapping_add(1); + Ok(()) +} + +pub fn open_selected_commit_files(gui: &mut Gui) -> Result<()> { + let Some(hash) = gui + .diff_mode + .selected_commit() + .map(|commit| commit.hash.clone()) + else { + return Ok(()); + }; + let files = gui.git.commit_files(&hash)?; + set_diff_files(gui, files); + gui.diff_mode.files_commit = Some(hash); + gui.diff_mode.set_focus(DiffModeFocus::CommitFiles); + gui.clear_diff_view(); + gui.needs_diff_refresh = true; + gui.needs_diff_refresh = true; + Ok(()) +} + +pub fn restore_comparison_files(gui: &mut Gui) -> Result<()> { + let files = gui + .git + .diff_refs_files(&gui.diff_mode.ref_a, &gui.diff_mode.ref_b)?; + set_diff_files(gui, files); + gui.diff_mode.files_commit = None; + gui.diff_mode.set_focus(DiffModeFocus::CommitFiles); + gui.clear_diff_view(); + Ok(()) +} + +fn set_diff_files(gui: &mut Gui, files: Vec) { + gui.diff_mode.diff_files = files; + gui.diff_mode.diff_files_selected = 0; + gui.diff_mode.diff_files_scroll = 0; + gui.diff_mode.viewport_manually_scrolled = false; + gui.diff_mode.collapsed_dirs.clear(); + gui.diff_mode.clear_list_search(); + update_diff_mode_tree(gui); +} + pub fn handle_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { // Popup takes priority (for ? help) if gui.popup != PopupState::None { @@ -93,15 +185,15 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { // Tab to cycle focus if key.code == KeyCode::Tab { - gui.diff_mode.focus = gui.diff_mode.focus.next(); + gui.diff_mode.set_focus(gui.diff_mode.focus.next()); gui.needs_diff_refresh = true; return Ok(()); } - // Number keys 1-4 to jump to focus panel - if let KeyCode::Char(c @ '1'..='4') = key.code { + // Number keys 1-5 to jump to focus panel + if let KeyCode::Char(c @ '1'..='5') = key.code { if let Some(focus) = DiffModeFocus::from_number(c.to_digit(10).unwrap()) { - gui.diff_mode.focus = focus; + gui.diff_mode.set_focus(focus); gui.needs_diff_refresh = true; return Ok(()); } @@ -148,6 +240,9 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { DiffModeFocus::CommitFiles => { handle_commit_files_key(gui, key)?; } + DiffModeFocus::Commits => { + handle_commits_key(gui, key)?; + } DiffModeFocus::DiffExploration => { handle_diff_exploration_key(gui, key)?; } @@ -166,7 +261,7 @@ fn handle_combobox_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { if gui.diff_mode.has_both_refs() { reload_diff_files(gui)?; // Both refs set — auto-focus commit files - gui.diff_mode.focus = DiffModeFocus::CommitFiles; + gui.diff_mode.set_focus(DiffModeFocus::CommitFiles); } else if gui.diff_mode.ref_a.is_empty() { // B was just set, A still empty — jump to A and start editing gui.diff_mode.focus = DiffModeFocus::SelectorA; @@ -241,7 +336,8 @@ fn handle_file_search_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { // Jump to first match if !gui.diff_mode.file_search_matches.is_empty() { gui.diff_mode.file_search_match_idx = 0; - gui.diff_mode.diff_files_selected = gui.diff_mode.file_search_matches[0]; + gui.diff_mode + .select_list_match(gui.diff_mode.file_search_matches[0]); } gui.diff_mode.file_search_textarea = None; gui.needs_diff_refresh = true; @@ -258,6 +354,11 @@ fn handle_file_search_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { } fn handle_commit_files_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { + if key.code == KeyCode::Esc && gui.diff_mode.files_commit.is_some() { + restore_comparison_files(gui)?; + gui.needs_diff_refresh = true; + return Ok(()); + } let keybindings = &gui.config.user_config.keybinding; // Toggle tree view (backtick) — keep in sync with Files / Commit Files and persist @@ -312,7 +413,7 @@ fn handle_commit_files_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { } } } - gui.diff_mode.focus = DiffModeFocus::DiffExploration; + gui.diff_mode.set_focus(DiffModeFocus::DiffExploration); gui.needs_diff_refresh = true; } KeyCode::Char('g') => { @@ -356,8 +457,11 @@ fn show_commit_file_copy_menu(gui: &mut Gui) -> Result<()> { .map_or_else(|| file.name.clone(), |(old, _)| old.to_string()); let new_path = file.current_path().to_string(); let status = file.status; - let ref_a = gui.diff_mode.ref_a.clone(); - let ref_b = gui.diff_mode.ref_b.clone(); + let (ref_a, ref_b) = match &gui.diff_mode.files_commit { + Some(hash) => (format!("{hash}^1"), hash.clone()), + None => (gui.diff_mode.ref_a.clone(), gui.diff_mode.ref_b.clone()), + }; + let commit_for_diff = gui.diff_mode.files_commit.clone(); let path_for_old = old_path.clone(); let path_for_new = new_path.clone(); let path_for_diff = file_name.clone(); @@ -384,7 +488,12 @@ fn show_commit_file_copy_menu(gui: &mut Gui) -> Result<()> { })), }, MenuItem { - label: "Old content (from A)".to_string(), + label: if commit_for_diff.is_some() { + "Old content (parent)" + } else { + "Old content (from A)" + } + .to_string(), description: if has_old { String::new() } else { @@ -404,7 +513,12 @@ fn show_commit_file_copy_menu(gui: &mut Gui) -> Result<()> { }, }, MenuItem { - label: "New content (from B)".to_string(), + label: if commit_for_diff.is_some() { + "New content (commit)" + } else { + "New content (from B)" + } + .to_string(), description: if has_new { String::new() } else { @@ -428,9 +542,12 @@ fn show_commit_file_copy_menu(gui: &mut Gui) -> Result<()> { description: String::new(), key: Some("d".to_string()), action: Some(Box::new(move |gui| { - let diff = + let diff = if let Some(hash) = &commit_for_diff { + gui.git.diff_commit_file(hash, &path_for_diff)? + } else { gui.git - .diff_refs_file(&ref_a_for_diff, &ref_b_for_diff, &path_for_diff)?; + .diff_refs_file(&ref_a_for_diff, &ref_b_for_diff, &path_for_diff)? + }; Platform::copy_to_clipboard(&diff)?; Ok(()) })), @@ -588,7 +705,13 @@ fn handle_diff_exploration_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { if !gui.diff_view.search_query.is_empty() { gui.diff_view.clear_search(); } else { - gui.diff_mode.focus = DiffModeFocus::CommitFiles; + gui.diff_mode.set_focus( + if gui.diff_mode.diff_source == CompareDiffSource::Commit { + DiffModeFocus::Commits + } else { + DiffModeFocus::CommitFiles + }, + ); } } KeyCode::Char('j') | KeyCode::Down => { @@ -650,6 +773,8 @@ fn handle_diff_exploration_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { pub fn reload_diff_files(gui: &mut Gui) -> Result<()> { let ref_a = gui.diff_mode.ref_a.clone(); let ref_b = gui.diff_mode.ref_b.clone(); + gui.diff_mode.ahead_behind = None; + gui.diff_mode.clear_commits(); if ref_a.is_empty() || ref_b.is_empty() { return Ok(()); } @@ -658,15 +783,20 @@ pub fn reload_diff_files(gui: &mut Gui) -> Result<()> { match gui.git.diff_refs_files(&ref_a, &ref_b) { Ok(files) => { - gui.diff_mode.diff_files = files; - gui.diff_mode.diff_files_selected = 0; - gui.diff_mode.diff_files_scroll = 0; - if gui.diff_mode.show_tree { - update_diff_mode_tree(gui); + // Tree refs can still be diffed even though they have no commit history. + gui.diff_mode.ahead_behind = gui.git.diff_refs_ahead_behind(&ref_a, &ref_b).ok(); + if gui.diff_mode.ahead_behind.is_some() { + gui.diff_mode.commits = gui + .git + .load_compare_commits(&ref_a, &ref_b, crate::git::DEFAULT_COMMIT_LIMIT, 0) + .unwrap_or_default(); + gui.diff_mode.commits_revision = gui.diff_mode.commits_revision.wrapping_add(1); } + set_diff_files(gui, files); } Err(e) => { gui.diff_mode.diff_files.clear(); + gui.diff_mode.tree_nodes.clear(); gui.popup = PopupState::Message { title: "Diff error".to_string(), message: format!("{}", e), @@ -674,6 +804,7 @@ pub fn reload_diff_files(gui: &mut Gui) -> Result<()> { }; } } + gui.diff_mode.set_focus(gui.diff_mode.focus); Ok(()) } @@ -691,6 +822,33 @@ fn update_diff_mode_tree(gui: &mut Gui) { /// Called from the main loop to request diff loading for the currently selected file in diff mode. /// Queues the request on the shared latest-only diff worker. pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { + if gui.diff_mode.diff_source == CompareDiffSource::Commit { + let Some(hash) = gui + .diff_mode + .selected_commit() + .map(|commit| commit.hash.clone()) + else { + gui.diff_loading = false; + gui.diff_loading_since = None; + gui.clear_diff_view(); + return; + }; + let git = Arc::clone(&gui.git); + gui.queue_diff_job(generation, diff_key, move || { + let diff = git.diff_commit(&hash).unwrap_or_default(); + if diff.is_empty() { + DiffPayload::Empty + } else { + DiffPayload::Parsed(DiffViewState::parse_diff_output( + &hash[..8.min(hash.len())], + &diff, + 4, + false, + )) + } + }); + return; + } if !gui.diff_mode.has_both_refs() || gui.diff_mode.diff_files.is_empty() { gui.diff_loading = false; gui.diff_loading_since = None; @@ -701,6 +859,8 @@ pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { let ref_a = gui.diff_mode.ref_a.clone(); let ref_b = gui.diff_mode.ref_b.clone(); + let files_commit = gui.diff_mode.files_commit.clone(); + // Resolve file index (tree view maps node -> file index) let selected = gui.diff_mode.diff_files_selected; let file_idx = if gui.diff_mode.show_tree { @@ -727,13 +887,21 @@ pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { let current_path = file.current_path().to_string(); gui.queue_diff_job(generation, diff_key, move || { - match git.diff_refs_file(&ref_a, &ref_b, &name) { + let result = if let Some(hash) = &files_commit { + git.diff_commit_file(hash, &name) + } else { + git.diff_refs_file(&ref_a, &ref_b, &name) + }; + match result { Ok(diff) if diff.is_empty() => DiffPayload::Empty, Ok(diff) => { let exists = git.repo_path().join(¤t_path).exists(); // Pure renames between refs: show file content at ref_b. if crate::pager::side_by_side::is_rename_only_diff(&diff) { - if let Ok(content) = git.file_content_at_commit(&ref_b, ¤t_path) { + if let Ok(content) = git.file_content_at_commit( + files_commit.as_deref().unwrap_or(&ref_b), + ¤t_path, + ) { if !content.is_empty() { return DiffPayload::Parsed(DiffViewState::parse_content( ¤t_path, @@ -772,9 +940,12 @@ pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { Some(p) => vec![p], None => Vec::new(), }; - let combined_diff = git - .diff_refs_paths(&ref_a, &ref_b, &paths) - .unwrap_or_default(); + let combined_diff = if let Some(hash) = &files_commit { + git.diff_commit_paths(hash, &paths) + } else { + git.diff_refs_paths(&ref_a, &ref_b, &paths) + } + .unwrap_or_default(); if combined_diff.is_empty() { DiffPayload::Empty } else { @@ -807,12 +978,26 @@ fn show_diff_mode_command_palette(gui: &mut Gui) { title: "Compare / Diff Mode".into(), entries: vec![ CommandEntry::keybinding("q".into(), "Exit diff mode".into()), - CommandEntry::keybinding("Tab".into(), "Cycle focus (A → B → Files → Diff)".into()), - CommandEntry::keybinding("1-4".into(), "Jump to panel".into()), + CommandEntry::keybinding( + "Tab".into(), + "Cycle focus (A → B → Files → Commits → Diff)".into(), + ), + CommandEntry::keybinding("1-5".into(), "Jump to panel (4 Commits, 5 Diff)".into()), CommandEntry::keybinding("".into(), "Swap A and B".into()), - CommandEntry::keybinding("".into(), "Edit selector / Focus diff".into()), + CommandEntry::keybinding( + "".into(), + "Edit ref / Open commit files / Focus diff".into(), + ), + CommandEntry::keybinding( + "".into(), + "Back from diff / Restore comparison files".into(), + ), CommandEntry::keybinding("`".into(), "Toggle file tree view".into()), - CommandEntry::keybinding("j/k".into(), "Navigate files / Scroll diff".into()), + CommandEntry::keybinding( + "j/k".into(), + "Navigate files or commits / Scroll diff".into(), + ), + CommandEntry::keybinding("PgUp/PgDn".into(), "Page through commits".into()), CommandEntry::keybinding("{/}".into(), "Previous / next hunk".into()), CommandEntry::keybinding("[/]".into(), "Toggle old / new only view".into()), CommandEntry::keybinding( @@ -826,7 +1011,10 @@ fn show_diff_mode_command_palette(gui: &mut Gui) { ), CommandEntry::keybinding("z".into(), "Toggle line wrap".into()), CommandEntry::keybinding("g/G".into(), "Go to top / bottom".into()), - CommandEntry::keybinding("/".into(), "Search (files or diff content)".into()), + CommandEntry::keybinding( + "/".into(), + "Search files, loaded commits, or diff content".into(), + ), CommandEntry::keybinding("n/N".into(), "Next / previous search match".into()), CommandEntry::keybinding("".into(), "Grep diff contents".into()), CommandEntry::keybinding("y".into(), "Copy to clipboard".into()), @@ -862,3 +1050,160 @@ fn matches_key(key: KeyEvent, binding: &str) -> bool { false } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{AppConfig, AppState, UserConfig}; + use crate::git::GitCommands; + use crate::gui::modes::diff_mode::CompareLayout; + use std::path::PathBuf; + use std::process::Command; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + struct TempRepo(PathBuf); + impl Drop for TempRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn compare_controller_previews_commits_opens_files_and_routes_mouse() { + let path = std::env::temp_dir().join(format!( + "lazygitrs-compare-viewer-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + let repo = TempRepo(path); + let run = |args: &[&str]| { + let output = Command::new("git") + .current_dir(&repo.0) + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + run(&["init", "-b", "main"]); + std::fs::write(repo.0.join("file.txt"), "base\n").unwrap(); + run(&["add", "file.txt"]); + run(&["commit", "-m", "root"]); + run(&["checkout", "-b", "feature"]); + std::fs::write(repo.0.join("file.txt"), "feature\n").unwrap(); + run(&["add", "file.txt"]); + run(&["commit", "-m", "feature change"]); + std::fs::write(repo.0.join("file.txt"), "base\n").unwrap(); + run(&["add", "file.txt"]); + run(&["commit", "-m", "restore content"]); + let config = AppConfig { + debug: false, + version: String::new(), + user_config: UserConfig::default(), + app_state: AppState::default(), + config_dir: repo.0.clone(), + state_dir: repo.0.clone(), + state_path: repo.0.join("state.yml"), + }; + let mut gui = Gui::new(config, GitCommands::new(&repo.0).unwrap(), None).unwrap(); + gui.diff_mode.enter(false); + gui.diff_mode.ref_a = "main".into(); + gui.diff_mode.ref_b = "feature".into(); + reload_diff_files(&mut gui).unwrap(); + assert_eq!(gui.diff_mode.commits.len(), 2); + assert!(gui.diff_mode.diff_files.is_empty()); + gui.diff_mode.commits.truncate(1); + load_more_commits(&mut gui).unwrap(); + assert_eq!(gui.diff_mode.commits.len(), 2); + assert!(!gui.diff_mode.has_more_commits()); + let press = + |gui: &mut Gui, key| handle_key(gui, KeyEvent::new(key, KeyModifiers::NONE)).unwrap(); + press(&mut gui, KeyCode::Char('4')); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::Commits); + let commit_key = gui.current_diff_key(); + maybe_request_diff(&mut gui, 0, commit_key.clone()); + let result = gui.diff_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(result.diff_key, commit_key); + assert!(matches!(result.payload, DiffPayload::Parsed(_))); + press(&mut gui, KeyCode::Char('5')); + assert_eq!(gui.current_diff_key(), commit_key); + press(&mut gui, KeyCode::Esc); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::Commits); + press(&mut gui, KeyCode::Enter); + assert_eq!(gui.diff_mode.diff_source, CompareDiffSource::CommitFiles); + assert_eq!(gui.diff_mode.diff_files.len(), 1); + let file_key = gui.current_diff_key(); + maybe_request_diff(&mut gui, 0, file_key.clone()); + let result = gui.diff_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(result.diff_key, file_key); + assert!(matches!(result.payload, DiffPayload::Parsed(_))); + press(&mut gui, KeyCode::Esc); + assert_eq!(gui.diff_mode.diff_source, CompareDiffSource::Comparison); + assert!(gui.diff_mode.diff_files.is_empty()); + + press(&mut gui, KeyCode::Char('4')); + handle_key( + &mut gui, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), + ) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::Commits); + assert_eq!(gui.diff_mode.diff_source, CompareDiffSource::Commit); + assert_eq!(gui.diff_mode.ahead_behind, Some((2, 0))); + + gui.layout.update_size(150, 24); + let rect = CompareLayout::new(ratatui::layout::Rect::new(0, 0, 150, 24)).sidebar[3]; + gui.handle_diff_mode_mouse(crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), + column: rect.x + 1, + row: rect.y + 2, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::Commits); + assert_eq!(gui.diff_mode.commits_selected, 1); + assert_eq!(gui.diff_mode.diff_source, CompareDiffSource::Commit); + let commit = gui.diff_mode.commits[0].clone(); + gui.diff_mode.commits = vec![commit; 30]; + gui.diff_mode.ahead_behind = Some((30, 0)); + for kind in [ + crossterm::event::MouseEventKind::ScrollDown, + crossterm::event::MouseEventKind::ScrollUp, + ] { + gui.handle_diff_mode_mouse(crossterm::event::MouseEvent { + kind, + column: rect.x + 1, + row: rect.y + 2, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(gui.diff_mode.commits_selected, 1); + assert_eq!( + gui.diff_mode.commits_scroll, + if kind == crossterm::event::MouseEventKind::ScrollDown { + 3 + } else { + 0 + } + ); + } + gui.diff_mode.ref_a = "missing".into(); + reload_diff_files(&mut gui).unwrap(); + assert!(gui.diff_mode.commits.is_empty()); + assert!(gui.diff_mode.tree_nodes.is_empty()); + assert!(gui.diff_mode.ahead_behind.is_none()); + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs index ff0674f..cc5edf4 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1409,6 +1409,7 @@ impl Gui { &theme, self.diff_loading, diff_loading_show, + &mut self.commit_list_cache, ); // Render popup overlay on top of diff mode (for ? help, errors, etc.) if self.popup != PopupState::None { @@ -1756,28 +1757,7 @@ impl Gui { fn current_diff_key(&self) -> String { if self.diff_mode.active { - let item_key = if self.diff_mode.show_tree { - self.diff_mode - .tree_nodes - .get(self.diff_mode.diff_files_selected) - .map(|node| { - node.file_index - .and_then(|index| self.diff_mode.diff_files.get(index)) - .map(|file| format!("file:{}", file.name)) - .unwrap_or_else(|| format!("dir:{}", node.path)) - }) - .unwrap_or_else(|| "none".to_string()) - } else { - self.diff_mode - .diff_files - .get(self.diff_mode.diff_files_selected) - .map(|file| format!("file:{}", file.name)) - .unwrap_or_else(|| "none".to_string()) - }; - return format!( - "DiffMode:{}..{}:{}", - self.diff_mode.ref_a, self.diff_mode.ref_b, item_key - ); + return self.diff_mode.diff_key(); } let active = self.context_mgr.active(); @@ -7176,9 +7156,9 @@ impl Gui { } fn handle_diff_mode_mouse(&mut self, mouse: MouseEvent) { - use self::modes::diff_mode::{DiffModeFocus, DiffModeSelector}; + use self::modes::diff_mode::{CompareLayout, DiffModeFocus, DiffModeSelector}; use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; - use ratatui::layout::{Constraint, Direction, Layout, Rect}; + use ratatui::layout::Rect; // Help popup intercepts mouse scroll if let PopupState::CommandPalette { @@ -7237,30 +7217,14 @@ impl Gui { let area = Rect::new(0, 0, self.layout.width, self.layout.height); - // Replicate the diff mode layout to determine regions - let outer = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(1)]) - .split(area); - - let content = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(33), Constraint::Percentage(67)]) - .split(outer[0]); - - let sidebar = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(1), - ]) - .split(content[0]); + let compare_layout = CompareLayout::new(area); + let sidebar = compare_layout.sidebar; let selector_a_rect = sidebar[0]; let selector_b_rect = sidebar[1]; let files_rect = sidebar[2]; - let diff_rect = content[1]; + let commits_rect = sidebar[3]; + let diff_rect = compare_layout.diff; let col = mouse.column; let row = mouse.row; @@ -7297,9 +7261,9 @@ impl Gui { self.diff_mode.confirm_selection(); if self.diff_mode.has_both_refs() { let _ = crate::gui::controller::diff_mode::reload_diff_files(self); - self.diff_mode.focus = DiffModeFocus::CommitFiles; + self.diff_mode.set_focus(DiffModeFocus::CommitFiles); } else if self.diff_mode.ref_a.is_empty() { - self.diff_mode.focus = DiffModeFocus::SelectorA; + self.diff_mode.set_focus(DiffModeFocus::SelectorA); self.diff_mode.start_editing(DiffModeSelector::A); let model = self.model.lock().unwrap(); self.diff_mode.search_refs( @@ -7310,7 +7274,7 @@ impl Gui { &model.head_branch_name, ); } else { - self.diff_mode.focus = DiffModeFocus::SelectorB; + self.diff_mode.set_focus(DiffModeFocus::SelectorB); self.diff_mode.start_editing(DiffModeSelector::B); let model = self.model.lock().unwrap(); self.diff_mode.search_refs( @@ -7353,12 +7317,12 @@ impl Gui { 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; + self.diff_mode.set_focus(DiffModeFocus::DiffExploration); return; } if self.diff_view.is_sticky_row(row, &pl) { self.diff_view.selection = None; - self.diff_mode.focus = DiffModeFocus::DiffExploration; + self.diff_mode.set_focus(DiffModeFocus::DiffExploration); return; } if let Some(panel) = pl.panel_at_x(col) { @@ -7377,13 +7341,13 @@ impl Gui { } else { self.diff_view.selection = None; } - self.diff_mode.focus = DiffModeFocus::DiffExploration; + self.diff_mode.set_focus(DiffModeFocus::DiffExploration); } else { self.diff_view.selection = None; // Click on panels to switch focus if rect_contains(selector_a_rect, col, row) { - self.diff_mode.focus = DiffModeFocus::SelectorA; + self.diff_mode.set_focus(DiffModeFocus::SelectorA); // Start editing on click self.diff_mode.start_editing(DiffModeSelector::A); let model = self.model.lock().unwrap(); @@ -7395,7 +7359,7 @@ impl Gui { &model.head_branch_name, ); } else if rect_contains(selector_b_rect, col, row) { - self.diff_mode.focus = DiffModeFocus::SelectorB; + self.diff_mode.set_focus(DiffModeFocus::SelectorB); // Start editing on click self.diff_mode.start_editing(DiffModeSelector::B); let model = self.model.lock().unwrap(); @@ -7407,7 +7371,8 @@ impl Gui { &model.head_branch_name, ); } else if rect_contains(files_rect, col, row) { - self.diff_mode.focus = DiffModeFocus::CommitFiles; + self.diff_mode.set_focus(DiffModeFocus::CommitFiles); + self.needs_diff_refresh = true; // Click to select a file — use stored scroll offset let inner_y = row.saturating_sub(files_rect.y + 1); let len = self.diff_mode.visible_files_len(); @@ -7417,8 +7382,20 @@ impl Gui { self.diff_mode.viewport_manually_scrolled = false; self.needs_diff_refresh = true; } + } else if rect_contains(commits_rect, col, row) { + self.diff_mode.set_focus(DiffModeFocus::Commits); + let inner_y = row.saturating_sub(commits_rect.y + 1); + let clicked_idx = self.diff_mode.commits_scroll + inner_y as usize; + if row > commits_rect.y + && row < commits_rect.bottom().saturating_sub(1) + && clicked_idx < self.diff_mode.commits.len() + { + self.diff_mode.commits_selected = clicked_idx; + self.diff_mode.commits_viewport_manually_scrolled = false; + } + self.needs_diff_refresh = true; } else if rect_contains(diff_rect, col, row) { - self.diff_mode.focus = DiffModeFocus::DiffExploration; + self.diff_mode.set_focus(DiffModeFocus::DiffExploration); } } } @@ -7464,6 +7441,14 @@ impl Gui { visible_height, ); self.diff_mode.viewport_manually_scrolled = true; + } else if rect_contains(commits_rect, col, row) { + scroll::scroll_viewport( + &mut self.diff_mode.commits_scroll, + -3, + self.diff_mode.commits.len(), + commits_rect.height.saturating_sub(2) as usize, + ); + self.diff_mode.commits_viewport_manually_scrolled = true; } } MouseEventKind::ScrollDown => { @@ -7485,6 +7470,26 @@ impl Gui { visible_height, ); self.diff_mode.viewport_manually_scrolled = true; + } else if rect_contains(commits_rect, col, row) { + let visible_height = commits_rect.height.saturating_sub(2) as usize; + if self.diff_mode.commits_scroll + visible_height + 3 + >= self.diff_mode.commits.len() + { + if let Err(error) = controller::diff_mode::load_more_commits(self) { + self.popup = PopupState::Message { + title: "Compare commits".into(), + message: error.to_string(), + kind: popup::MessageKind::Error, + }; + } + } + scroll::scroll_viewport( + &mut self.diff_mode.commits_scroll, + 3, + self.diff_mode.commits.len(), + visible_height, + ); + self.diff_mode.commits_viewport_manually_scrolled = true; } } MouseEventKind::ScrollLeft => { diff --git a/src/gui/modes/diff_mode.rs b/src/gui/modes/diff_mode.rs index 10d48b5..218e8a2 100644 --- a/src/gui/modes/diff_mode.rs +++ b/src/gui/modes/diff_mode.rs @@ -6,16 +6,272 @@ pub enum DiffModeFocus { SelectorA, SelectorB, CommitFiles, + Commits, DiffExploration, } +/// The diff being previewed remains stable when focus moves to the diff panel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompareDiffSource { + Comparison, + Commit, + CommitFiles, +} + +/// Shared by rendering and mouse hit-testing. +pub struct CompareLayout { + pub sidebar: std::rc::Rc<[ratatui::layout::Rect]>, + pub diff: ratatui::layout::Rect, + pub status: ratatui::layout::Rect, +} + +impl CompareLayout { + pub fn new(area: ratatui::layout::Rect) -> Self { + use ratatui::layout::{Constraint, Direction, Layout}; + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(area); + let content = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(33), Constraint::Percentage(67)]) + .split(outer[0]); + let sidebar = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Fill(1), + ]) + .split(content[0]); + Self { + sidebar, + diff: content[1], + status: outer[1], + } + } +} + +impl DiffModeState { + pub fn clear_commits(&mut self) { + self.commits.clear(); + self.commits_selected = 0; + self.commits_scroll = 0; + self.commits_viewport_manually_scrolled = false; + self.commits_revision = self.commits_revision.wrapping_add(1); + self.diff_source = CompareDiffSource::Comparison; + self.files_commit = None; + self.clear_list_search(); + } + + pub fn clear_list_search(&mut self) { + self.file_search_active = false; + self.file_search_query.clear(); + self.file_search_matches.clear(); + self.file_search_match_idx = 0; + self.file_search_textarea = None; + } + + pub fn set_focus(&mut self, focus: DiffModeFocus) { + if (self.focus == DiffModeFocus::Commits) != (focus == DiffModeFocus::Commits) { + self.clear_list_search(); + } + self.focus = focus; + match focus { + DiffModeFocus::Commits => self.diff_source = CompareDiffSource::Commit, + DiffModeFocus::CommitFiles => { + self.diff_source = if self.files_commit.is_some() { + CompareDiffSource::CommitFiles + } else { + CompareDiffSource::Comparison + }; + } + _ => {} + } + } + + pub fn selected_commit(&self) -> Option<&Commit> { + self.commits.get(self.commits_selected) + } + + pub fn has_more_commits(&self) -> bool { + self.ahead_behind + .is_some_and(|(ahead, behind)| self.commits.len() < ahead + behind) + } + + pub fn select_list_match(&mut self, index: usize) { + if self.focus == DiffModeFocus::Commits { + self.commits_selected = index; + self.commits_viewport_manually_scrolled = false; + } else { + self.diff_files_selected = index; + self.viewport_manually_scrolled = false; + } + } + + pub fn diff_key(&self) -> String { + if self.diff_source == CompareDiffSource::Commit { + return format!( + "DiffMode:commit:{}", + self.selected_commit() + .map(|c| c.hash.as_str()) + .unwrap_or("none") + ); + } + let item = if self.show_tree { + self.tree_nodes.get(self.diff_files_selected).map(|node| { + node.file_index + .and_then(|i| self.diff_files.get(i)) + .map(|file| format!("file:{}", file.name)) + .unwrap_or_else(|| format!("dir:{}", node.path)) + }) + } else { + self.diff_files + .get(self.diff_files_selected) + .map(|file| format!("file:{}", file.name)) + } + .unwrap_or_else(|| "none".into()); + if self.diff_source == CompareDiffSource::CommitFiles { + format!( + "DiffMode:commit-files:{}:{item}", + self.files_commit.as_deref().unwrap_or("none") + ) + } else { + format!("DiffMode:{}..{}:{item}", self.ref_a, self.ref_b) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn commit(hash: &str, name: &str) -> Commit { + Commit { + hash: hash.into(), + name: name.into(), + status: crate::model::CommitStatus::Pushed, + action: String::new(), + tags: vec![], + refs: vec![], + extra_info: String::new(), + author_name: "Test".into(), + author_email: String::new(), + unix_timestamp: 0, + parents: vec![], + divergence: crate::model::commit::Divergence::Left, + } + } + + #[test] + fn compare_four_focuses_commits_and_tab_visits_all_five_panels() { + let panels = [ + DiffModeFocus::SelectorA, + DiffModeFocus::SelectorB, + DiffModeFocus::CommitFiles, + DiffModeFocus::Commits, + DiffModeFocus::DiffExploration, + ]; + for (i, panel) in panels.iter().enumerate() { + assert_eq!(DiffModeFocus::from_number(i as u32 + 1), Some(*panel)); + assert_eq!(panel.next(), panels[(i + 1) % panels.len()]); + } + assert_eq!(DiffModeFocus::from_number(6), None); + } + + #[test] + fn compare_commit_preview_and_files_have_independent_diff_keys() { + let mut state = DiffModeState::new(); + state.ref_a = "main".into(); + state.ref_b = "feature".into(); + state.commits = vec![commit("a", "first"), commit("b", "second")]; + let comparison_key = state.diff_key(); + state.set_focus(DiffModeFocus::Commits); + assert_eq!(state.diff_key(), "DiffMode:commit:a"); + state.commits_selected = 1; + state.set_focus(DiffModeFocus::DiffExploration); + assert_eq!(state.diff_key(), "DiffMode:commit:b"); + state.set_focus(DiffModeFocus::CommitFiles); + assert_eq!(state.diff_key(), comparison_key); + state.files_commit = Some("b".into()); + state.set_focus(DiffModeFocus::CommitFiles); + let commit_file_key = state.diff_key(); + assert_ne!(commit_file_key, comparison_key); + state.set_focus(DiffModeFocus::DiffExploration); + assert_eq!(state.diff_key(), commit_file_key); + } + + #[test] + fn compare_commit_search_targets_commits_and_clears_on_focus_change() { + let mut state = DiffModeState::new(); + state.commits = vec![commit("a", "first"), commit("b", "second")]; + state.set_focus(DiffModeFocus::Commits); + state.file_search_query = "second".into(); + state.update_file_search_matches(); + assert_eq!(state.file_search_matches, vec![1]); + assert_eq!(state.commits_selected, 1); + assert_eq!(state.diff_files_selected, 0); + state.set_focus(DiffModeFocus::CommitFiles); + assert!(state.file_search_query.is_empty()); + assert!(state.file_search_matches.is_empty()); + } + + #[test] + fn compare_ref_changes_clear_commit_selection_files_and_search() { + let mut state = DiffModeState::new(); + state.commits = vec![commit("a", "first")]; + state.files_commit = Some("a".into()); + state.commits_scroll = 5; + state.file_search_query = "stale".into(); + state.start_editing(DiffModeSelector::A); + state.textarea.as_mut().unwrap().insert_str("HEAD"); + state.confirm_selection(); + assert!(state.commits.is_empty()); + assert!(state.files_commit.is_none()); + assert_eq!(state.commits_scroll, 0); + assert!(state.file_search_query.is_empty()); + assert_eq!(state.diff_source, CompareDiffSource::Comparison); + } + + #[test] + fn compare_counts_reverse_when_swapping_refs() { + let mut state = DiffModeState::new(); + state.ref_a = "main".into(); + state.ref_b = "feature".into(); + state.ahead_behind = Some((1, 3)); + state.swap_refs(); + assert_eq!(state.ref_a, "feature"); + assert_eq!(state.ref_b, "main"); + assert_eq!(state.ahead_behind, Some((3, 1))); + } + + #[test] + fn compare_counts_clear_when_selecting_refs_or_reentering() { + let mut state = DiffModeState::new(); + state.ahead_behind = Some((2, 1)); + state.start_editing(DiffModeSelector::A); + state.textarea.as_mut().unwrap().insert_str("HEAD"); + state.confirm_selection(); + assert_eq!(state.ref_a, "HEAD"); + assert_eq!(state.ahead_behind, None); + state.ahead_behind = Some((2, 1)); + state.exit(); + assert_eq!(state.ahead_behind, None); + state.ahead_behind = Some((2, 1)); + state.enter(false); + assert_eq!(state.ahead_behind, None); + } +} + impl DiffModeFocus { pub fn from_number(n: u32) -> Option { match n { 1 => Some(Self::SelectorA), 2 => Some(Self::SelectorB), 3 => Some(Self::CommitFiles), - 4 => Some(Self::DiffExploration), + 4 => Some(Self::Commits), + 5 => Some(Self::DiffExploration), _ => None, } } @@ -24,7 +280,8 @@ impl DiffModeFocus { match self { Self::SelectorA => Self::SelectorB, Self::SelectorB => Self::CommitFiles, - Self::CommitFiles => Self::DiffExploration, + Self::CommitFiles => Self::Commits, + Self::Commits => Self::DiffExploration, Self::DiffExploration => Self::SelectorA, } } @@ -64,6 +321,16 @@ pub struct DiffModeState { pub ref_b: String, pub ref_a_display: String, pub ref_b_display: String, + /// Commits unique to A and B. None when refs aren't both commit-ish. + pub ahead_behind: Option<(usize, usize)>, + pub commits: Vec, + pub commits_selected: usize, + pub commits_scroll: usize, + pub commits_viewport_manually_scrolled: bool, + pub commits_revision: u64, + pub diff_source: CompareDiffSource, + /// When inspecting a commit's files, the file list is relative to its first parent. + pub files_commit: Option, // Combobox editing state pub editing: Option, @@ -103,6 +370,14 @@ impl DiffModeState { ref_b: String::new(), ref_a_display: String::new(), ref_b_display: String::new(), + ahead_behind: None, + commits: Vec::new(), + commits_selected: 0, + commits_scroll: 0, + commits_viewport_manually_scrolled: false, + commits_revision: 0, + diff_source: CompareDiffSource::Comparison, + files_commit: None, editing: None, textarea: None, search_results: Vec::new(), @@ -125,11 +400,13 @@ impl DiffModeState { } pub fn enter(&mut self, show_tree: bool) { + self.clear_commits(); self.active = true; self.ref_a.clear(); self.ref_b.clear(); self.ref_a_display.clear(); self.ref_b_display.clear(); + self.ahead_behind = None; self.editing = None; self.textarea = None; self.search_results.clear(); @@ -151,7 +428,9 @@ impl DiffModeState { } pub fn exit(&mut self) { + self.clear_commits(); self.active = false; + self.ahead_behind = None; self.editing = None; self.textarea = None; self.search_results.clear(); @@ -161,8 +440,10 @@ impl DiffModeState { } pub fn swap_refs(&mut self) { + self.clear_commits(); std::mem::swap(&mut self.ref_a, &mut self.ref_b); std::mem::swap(&mut self.ref_a_display, &mut self.ref_b_display); + self.ahead_behind = self.ahead_behind.map(|(ahead, behind)| (behind, ahead)); self.diff_files.clear(); self.diff_files_selected = 0; self.diff_files_scroll = 0; @@ -236,6 +517,8 @@ impl DiffModeState { } } + self.clear_commits(); + self.ahead_behind = None; self.editing = None; self.textarea = None; self.search_results.clear(); @@ -378,7 +661,16 @@ impl DiffModeState { let query = self.file_search_query.to_lowercase(); - if self.show_tree { + if self.focus == DiffModeFocus::Commits { + for (i, commit) in self.commits.iter().enumerate() { + if [&commit.hash, &commit.name, &commit.author_name] + .iter() + .any(|text| text.to_lowercase().contains(&query)) + { + self.file_search_matches.push(i); + } + } + } else if self.show_tree { for (i, node) in self.tree_nodes.iter().enumerate() { if node.path.to_lowercase().contains(&query) || node.name.to_lowercase().contains(&query) @@ -397,7 +689,7 @@ impl DiffModeState { // Auto-jump to first match if !self.file_search_matches.is_empty() { self.file_search_match_idx = 0; - self.diff_files_selected = self.file_search_matches[0]; + self.select_list_match(self.file_search_matches[0]); } } @@ -408,7 +700,7 @@ impl DiffModeState { } self.file_search_match_idx = (self.file_search_match_idx + 1) % self.file_search_matches.len(); - self.diff_files_selected = self.file_search_matches[self.file_search_match_idx]; + self.select_list_match(self.file_search_matches[self.file_search_match_idx]); } /// Go to previous file search match. @@ -421,6 +713,6 @@ impl DiffModeState { } else { self.file_search_match_idx -= 1; } - self.diff_files_selected = self.file_search_matches[self.file_search_match_idx]; + self.select_list_match(self.file_search_matches[self.file_search_match_idx]); } } diff --git a/src/gui/presentation/commits.rs b/src/gui/presentation/commits.rs index ecd135a..e57c952 100644 --- a/src/gui/presentation/commits.rs +++ b/src/gui/presentation/commits.rs @@ -5,7 +5,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::config::Theme; use crate::model::Model; -use crate::model::commit::{Commit, CommitStatus}; +use crate::model::commit::{Commit, CommitStatus, Divergence}; use super::graph; use super::text::plain_text; @@ -14,6 +14,28 @@ use super::text::plain_text; pub struct CommitListCache { commits: GraphLayoutCache, sub_commits: GraphLayoutCache, + compare_commits: GraphLayoutCache, +} + +pub fn render_compare_commit_list_window<'a>( + commits: &'a [Commit], + revision: u64, + theme: &Theme, + offset: usize, + visible_height: usize, + cache: &mut CommitListCache, +) -> Vec> { + cache.compare_commits.update(commits, revision); + render_commits_window( + commits, + "", + theme, + &[], + offset, + visible_height, + false, + &cache.compare_commits, + ) } #[derive(Default)] @@ -146,6 +168,17 @@ fn render_commits_window<'a>( let is_head = commit.hash == *head_hash; let mut spans: Vec> = Vec::new(); + match commit.divergence { + Divergence::Left => { + spans.push(Span::styled("A ", Style::default().fg(theme.accent))) + } + Divergence::Right => spans.push(Span::styled( + "B ", + Style::default().fg(theme.accent_secondary), + )), + Divergence::None => {} + } + // Hash (8, lazygit default) — color by push status. let is_cherry_picked = cherry_picked.iter().any(|h| *h == commit.hash); let hash_style = if is_cherry_picked { diff --git a/src/gui/presentation/diff_mode.rs b/src/gui/presentation/diff_mode.rs index ab61c81..2cf4984 100644 --- a/src/gui/presentation/diff_mode.rs +++ b/src/gui/presentation/diff_mode.rs @@ -1,12 +1,15 @@ use ratatui::Frame; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; use crate::config::Theme; -use crate::gui::modes::diff_mode::{DiffModeFocus, DiffModeState, RefKind}; +use crate::gui::modes::diff_mode::{ + CompareDiffSource, CompareLayout, DiffModeFocus, DiffModeState, RefKind, +}; use crate::gui::presentation::commit_files::commit_file_status_display; +use crate::gui::presentation::commits::{CommitListCache, render_compare_commit_list_window}; use crate::gui::presentation::files::append_file_stats; use crate::model::file_tree::CommitFileTreeNode; use crate::pager::side_by_side::{self, DiffViewState}; @@ -14,6 +17,172 @@ use crate::pager::side_by_side::{self, DiffViewState}; /// Max items visible in the dropdown at once. const DROPDOWN_MAX_VISIBLE: usize = 10; +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Commit, CommitStatus, commit::Divergence}; + use ratatui::{Terminal, backend::TestBackend}; + + fn commit(hash: &str, name: &str, divergence: Divergence) -> Commit { + Commit { + hash: hash.into(), + name: name.into(), + status: CommitStatus::Pushed, + action: String::new(), + tags: vec![], + refs: vec![], + extra_info: String::new(), + author_name: "Test Author".into(), + author_email: String::new(), + unix_timestamp: 0, + parents: vec![], + divergence, + } + } + + fn buffer_text(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer.cell((x, y)).unwrap().symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn compare_commits_panel_shows_sides_and_default_commit_rows() { + let mut state = DiffModeState::new(); + state.ref_a = "main".into(); + state.ref_b = "feature".into(); + state.ahead_behind = Some((1, 1)); + state.commits = vec![ + commit("aaaaaaaa", "Main change", Divergence::Left), + commit("bbbbbbbb", "Feature change", Divergence::Right), + ]; + let mut cache = CommitListCache::default(); + let mut terminal = Terminal::new(TestBackend::new(60, 6)).unwrap(); + terminal + .draw(|frame| { + render_compare_commits( + frame, + frame.area(), + &mut state, + &Theme::default(), + &mut cache, + ) + }) + .unwrap(); + let text = buffer_text(&terminal); + assert!(text.contains("4 Commits · 1 ahead, 1 behind"), "{text}"); + assert!(text.contains("A aaaaaaaa"), "{text}"); + assert!(text.contains("B bbbbbbbb"), "{text}"); + assert!(text.contains("Main change")); + assert!(text.contains("Feature change")); + assert!(!text.contains("Enter: files")); + assert!(!text.contains("A-only / B-only")); + let bottom_border: String = (0..60) + .map(|x| terminal.backend().buffer().cell((x, 5)).unwrap().symbol()) + .collect(); + assert_eq!(bottom_border, format!("└{}┘", "─".repeat(58))); + } + + #[test] + fn compare_help_bar_shows_commit_actions_without_duplicate_counts() { + for (focus, counts, more) in [ + (DiffModeFocus::Commits, Some((3, 1)), true), + (DiffModeFocus::Commits, Some((0, 0)), false), + (DiffModeFocus::CommitFiles, Some((3, 1)), true), + (DiffModeFocus::Commits, None, false), + ] { + let mut state = DiffModeState::new(); + state.set_focus(focus); + state.ahead_behind = counts; + let mut terminal = Terminal::new(TestBackend::new(80, 1)).unwrap(); + terminal + .draw(|frame| { + render_status_bar( + frame, + frame.area(), + &state, + &DiffViewState::default(), + &Theme::default(), + ) + }) + .unwrap(); + let text = buffer_text(&terminal); + assert!(!text.contains("A vs B:"), "{text}"); + assert!(!text.contains("ahead"), "{text}"); + assert!(!text.contains("behind"), "{text}"); + assert_eq!( + text.contains("Enter files"), + focus == DiffModeFocus::Commits, + "{text}" + ); + assert_eq!( + text.contains("PgDn more"), + focus == DiffModeFocus::Commits && more, + "{text}" + ); + } + } + + #[test] + fn compare_empty_commit_states_are_distinct() { + for (counts, expected) in [ + (Some((0, 0)), "Same commit"), + (None, "Commit history unavailable"), + ] { + let mut state = DiffModeState::new(); + state.ref_a = "A".into(); + state.ref_b = "B".into(); + state.ahead_behind = counts; + let mut terminal = Terminal::new(TestBackend::new(60, 5)).unwrap(); + terminal + .draw(|frame| { + render_compare_commits( + frame, + frame.area(), + &mut state, + &Theme::default(), + &mut CommitListCache::default(), + ) + }) + .unwrap(); + assert!(buffer_text(&terminal).contains(expected)); + } + } + + #[test] + fn compare_layout_renders_four_sidebar_panels_and_handles_small_terminals() { + for (width, height) in [(180, 30), (80, 14), (30, 8), (5, 3), (1, 1)] { + let mut state = DiffModeState::new(); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + render( + frame, + &mut state, + &mut DiffViewState::default(), + &Theme::default(), + false, + false, + &mut CommitListCache::default(), + ) + }) + .unwrap(); + if width == 180 { + let text = buffer_text(&terminal); + for title in ["1 A", "2 B", "3 Files", "4 Commits", "5 Diff"] { + assert!(text.contains(title), "Missing {title}: {text}"); + } + } + } + } +} + pub fn render( frame: &mut Frame, state: &mut DiffModeState, @@ -21,38 +190,20 @@ pub fn render( theme: &Theme, diff_loading: bool, diff_loading_show: bool, + commit_cache: &mut CommitListCache, ) { - let area = frame.area(); - - // Overall layout: sidebar (left) | diff panel (right) | status bar at bottom - let outer = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(1)]) - .split(area); - - let content = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(33), Constraint::Percentage(67)]) - .split(outer[0]); - - // Left sidebar: [A selector (3 lines)] [B selector (3 lines)] [Commit Files (rest)] - let sidebar = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(1), - ]) - .split(content[0]); + let layout = CompareLayout::new(frame.area()); + let sidebar = layout.sidebar; render_selector(frame, sidebar[0], state, DiffModeFocus::SelectorA, theme); render_selector(frame, sidebar[1], state, DiffModeFocus::SelectorB, theme); render_commit_files(frame, sidebar[2], state, theme); + render_compare_commits(frame, sidebar[3], state, theme, commit_cache); // Right panel: diff exploration render_diff_panel( frame, - content[1], + layout.diff, state, diff_view, theme, @@ -61,10 +212,10 @@ pub fn render( ); // Text selection highlight overlay and tooltip (must be before popups/dropdowns) - crate::gui::views::render_selection_overlay(frame, diff_view, content[1], theme); + crate::gui::views::render_selection_overlay(frame, diff_view, layout.diff, theme); // Status bar - render_status_bar(frame, outer[1], state, diff_view, theme); + render_status_bar(frame, layout.status, state, diff_view, theme); // Render combobox dropdown overlay on top of the sidebar if state.editing.is_some() { @@ -79,9 +230,8 @@ fn render_selector( which: DiffModeFocus, theme: &Theme, ) { - let (is_a, focused, editing, display, number_label) = match which { + let (focused, editing, display, number_label) = match which { DiffModeFocus::SelectorA => ( - true, state.focus == DiffModeFocus::SelectorA, matches!( state.editing, @@ -91,7 +241,6 @@ fn render_selector( " 1 A ", ), DiffModeFocus::SelectorB => ( - false, state.focus == DiffModeFocus::SelectorB, matches!( state.editing, @@ -135,6 +284,86 @@ fn render_selector( } } +fn render_compare_commits( + frame: &mut Frame, + area: Rect, + state: &mut DiffModeState, + theme: &Theme, + cache: &mut CommitListCache, +) { + let border = if state.focus == DiffModeFocus::Commits { + theme.active_border + } else { + Style::default().fg(theme.text_dimmed) + }; + let summary = state + .ahead_behind + .map(|(ahead, behind)| format!("{ahead} ahead, {behind} behind")); + let title = summary + .map(|summary| format!(" 4 Commits · {summary} ")) + .unwrap_or_else(|| " 4 Commits ".into()); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border); + let inner = block.inner(area); + if state.commits.is_empty() { + let message = if !state.has_both_refs() { + "Select refs A and B to compare" + } else if state.ahead_behind == Some((0, 0)) { + "Same commit — no unique commits" + } else if state.ahead_behind.is_none() { + "Commit history unavailable for these refs" + } else { + "No commits loaded" + }; + frame.render_widget( + Paragraph::new(Span::styled( + format!(" {message}"), + Style::default().fg(theme.text_dimmed), + )) + .block(block), + area, + ); + return; + } + let height = inner.height as usize; + if height == 0 { + frame.render_widget(block, area); + return; + } + if !state.commits_viewport_manually_scrolled { + crate::gui::scroll::ensure_visible( + state.commits_selected, + &mut state.commits_scroll, + height, + ); + } + state.commits_scroll = state + .commits_scroll + .min(state.commits.len().saturating_sub(height)); + let items = render_compare_commit_list_window( + &state.commits, + state.commits_revision, + theme, + state.commits_scroll, + height, + cache, + ); + let selection = state + .commits_selected + .checked_sub(state.commits_scroll) + .filter(|&i| i < items.len()); + let mut list_state = ListState::default().with_selected(selection); + frame.render_stateful_widget( + List::new(items) + .block(block) + .highlight_style(theme.selected_line), + area, + &mut list_state, + ); +} + fn render_commit_files(frame: &mut Frame, area: Rect, state: &mut DiffModeState, theme: &Theme) { let focused = state.focus == DiffModeFocus::CommitFiles; let border = if focused { @@ -143,16 +372,20 @@ fn render_commit_files(frame: &mut Frame, area: Rect, state: &mut DiffModeState, Style::default().fg(theme.text_dimmed) }; let tree_indicator = if state.show_tree { " (tree)" } else { "" }; - let title = format!( - " 3 Commit Files ({}{}) ", - state.diff_files.len(), - tree_indicator - ); + let title = format!(" 3 Files ({}{}) ", state.diff_files.len(), tree_indicator); let block = Block::default() .title(title) .borders(Borders::ALL) .border_style(border); let content_width = area.width.saturating_sub(2) as usize; + let block = if let Some(hash) = &state.files_commit { + block.title_bottom(format!( + " {} · Esc: comparison ", + &hash[..8.min(hash.len())] + )) + } else { + block + }; if state.diff_files.is_empty() { let msg = if state.has_both_refs() { @@ -342,13 +575,15 @@ fn render_diff_panel( Style::default().fg(theme.text_dimmed) }; let block = Block::default() - .title(" 4 Diff ") + .title(" 5 Diff ") .borders(Borders::ALL) .border_style(border); let msg = if diff_loading_show { " Loading diff..." + } else if state.diff_source == CompareDiffSource::Commit { + " No patch for this commit" } else if !state.has_both_refs() || state.diff_files.is_empty() { - " Select a file to view diff" + " Select a file or commit to view diff" } else { "" }; @@ -431,15 +666,23 @@ fn render_status_bar( side_by_side::DiffViewLayout::SideBySide => "unified view", side_by_side::DiffViewLayout::Unified => "split view", }; - vec![ + let mut hints = Vec::new(); + if state.focus == DiffModeFocus::Commits { + hints.push(("Enter", "files")); + if state.has_more_commits() { + hints.push(("PgDn", "more")); + } + } + hints.extend([ ("q", "exit"), ("Tab", "cycle"), - ("1-4", "panel"), + ("1-5", "panel"), ("", "swap"), ("`", "tree"), ("\\", view_layout_hint), ("?", "help"), - ] + ]); + hints }; let key_style = Style::default().fg(theme.text).add_modifier(Modifier::BOLD); From 85f1dacf4225e96dabe9611811ece5891521df5f Mon Sep 17 00:00:00 2001 From: Blankeos Date: Tue, 6 Oct 2026 17:32:34 +0800 Subject: [PATCH 06/11] feat(compare-tool): polish compare mode layout and navigation - Shift + / shift - work - Mouse resizing - Responsive layout based on size - shift-tab cycles backwards --- src/gui/controller/diff_mode.rs | 286 +++++++++++++++++++++++++++--- src/gui/layout.rs | 2 +- src/gui/mod.rs | 121 +++++++++---- src/gui/modes/diff_mode.rs | 268 ++++++++++++++++++++++++++-- src/gui/presentation/diff_mode.rs | 124 ++++++++----- 5 files changed, 678 insertions(+), 123 deletions(-) diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index bfebdf3..3e1671d 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -137,6 +137,13 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { return Ok(()); } + // Keep text-entry shortcuts local, including diff-content search. + if gui.diff_mode.focus == DiffModeFocus::DiffExploration && gui.diff_view.search_active { + return handle_diff_search_key(gui, key); + } + if gui.try_handle_layout_key(key) { + return Ok(()); + } let keybindings = &gui.config.user_config.keybinding; if matches_key(key, &keybindings.universal.toggle_diff_view_layout) { @@ -183,9 +190,23 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { } } - // Tab to cycle focus - if key.code == KeyCode::Tab { - gui.diff_mode.set_focus(gui.diff_mode.focus.next()); + // Use the same configurable forward/reverse bindings as the main view. + let mut panel_key = key; + if panel_key.code == KeyCode::BackTab + || (panel_key.code == KeyCode::Tab && panel_key.modifiers.contains(KeyModifiers::SHIFT)) + { + panel_key.code = KeyCode::BackTab; + panel_key.modifiers.insert(KeyModifiers::SHIFT); + } + let forward = matches_key(panel_key, &keybindings.universal.toggle_panel); + let reverse = matches_key(panel_key, &keybindings.universal.toggle_panel_reverse); + if forward || reverse { + let focus = if reverse { + gui.diff_mode.focus.prev() + } else { + gui.diff_mode.focus.next() + }; + gui.diff_mode.set_focus(focus); gui.needs_diff_refresh = true; return Ok(()); } @@ -264,7 +285,7 @@ fn handle_combobox_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { gui.diff_mode.set_focus(DiffModeFocus::CommitFiles); } else if gui.diff_mode.ref_a.is_empty() { // B was just set, A still empty — jump to A and start editing - gui.diff_mode.focus = DiffModeFocus::SelectorA; + gui.diff_mode.set_focus(DiffModeFocus::SelectorA); gui.diff_mode.start_editing(DiffModeSelector::A); let model = gui.model.lock().unwrap(); gui.diff_mode.search_refs( @@ -276,7 +297,7 @@ fn handle_combobox_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { ); } else { // A was just set, B still empty — jump to B and start editing - gui.diff_mode.focus = DiffModeFocus::SelectorB; + gui.diff_mode.set_focus(DiffModeFocus::SelectorB); gui.diff_mode.start_editing(DiffModeSelector::B); let model = gui.model.lock().unwrap(); gui.diff_mode.search_refs( @@ -605,22 +626,7 @@ fn handle_diff_exploration_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { let line = sel_ref.edit_line_number; // Compute column from terminal position using the same layout as the mouse handler let (top_row, top_col, _, _) = sel_ref.normalized(); - let area = ratatui::layout::Rect::new(0, 0, gui.layout.width, gui.layout.height); - let outer = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Vertical) - .constraints([ - ratatui::layout::Constraint::Min(1), - ratatui::layout::Constraint::Length(1), - ]) - .split(area); - let content = ratatui::layout::Layout::default() - .direction(ratatui::layout::Direction::Horizontal) - .constraints([ - ratatui::layout::Constraint::Percentage(33), - ratatui::layout::Constraint::Percentage(67), - ]) - .split(outer[0]); - let diff_rect = content[1]; + let diff_rect = gui.compute_compare_layout().diff; let pl = DiffPanelLayout::compute(diff_rect, &gui.diff_view); let (content_start, _) = pl.content_range(sel_ref.panel); let column = if top_col >= content_start { @@ -979,9 +985,66 @@ fn show_diff_mode_command_palette(gui: &mut Gui) { entries: vec![ CommandEntry::keybinding("q".into(), "Exit diff mode".into()), CommandEntry::keybinding( - "Tab".into(), + gui.config + .user_config + .keybinding + .universal + .toggle_panel + .clone(), "Cycle focus (A → B → Files → Commits → Diff)".into(), ), + CommandEntry::keybinding( + gui.config + .user_config + .keybinding + .universal + .toggle_panel_reverse + .clone(), + "Cycle focus backward".into(), + ), + CommandEntry::keybinding( + gui.config + .user_config + .keybinding + .universal + .next_screen_mode + .clone(), + "Next screen mode (normal / half / full)".into(), + ), + CommandEntry::keybinding( + gui.config + .user_config + .keybinding + .universal + .prev_screen_mode + .clone(), + "Previous screen mode".into(), + ), + CommandEntry::keybinding( + format!( + "{} / {}", + gui.config + .user_config + .keybinding + .universal + .shrink_side_panel, + gui.config + .user_config + .keybinding + .universal + .expand_side_panel + ), + "Shrink / expand sidebar (or drag its divider)".into(), + ), + CommandEntry::keybinding( + gui.config + .user_config + .keybinding + .universal + .reset_side_panel + .clone(), + "Reset sidebar size".into(), + ), CommandEntry::keybinding("1-5".into(), "Jump to panel (4 Commits, 5 Diff)".into()), CommandEntry::keybinding("".into(), "Swap A and B".into()), CommandEntry::keybinding( @@ -1056,7 +1119,6 @@ mod tests { use super::*; use crate::config::{AppConfig, AppState, UserConfig}; use crate::git::GitCommands; - use crate::gui::modes::diff_mode::CompareLayout; use std::path::PathBuf; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -1167,7 +1229,7 @@ mod tests { assert_eq!(gui.diff_mode.ahead_behind, Some((2, 0))); gui.layout.update_size(150, 24); - let rect = CompareLayout::new(ratatui::layout::Rect::new(0, 0, 150, 24)).sidebar[3]; + let rect = gui.compute_compare_layout().sidebar[3]; gui.handle_diff_mode_mouse(crossterm::event::MouseEvent { kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left), column: rect.x + 1, @@ -1200,6 +1262,182 @@ mod tests { } ); } + // Exercise the real GUI dispatch path, including normalized terminal + // events, custom bindings, input priority and divider dragging. + use crate::gui::ScreenMode; + use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + gui.diff_mode.set_focus(DiffModeFocus::SelectorA); + for modifiers in [KeyModifiers::SHIFT, KeyModifiers::NONE] { + gui.handle_key(KeyEvent::new(KeyCode::BackTab, modifiers)) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::DiffExploration); + gui.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::SelectorA); + } + gui.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::DiffExploration); + let stable_key = gui.current_diff_key(); + for (key, modifiers, expected) in [ + ('+', KeyModifiers::NONE, ScreenMode::Half), + ('+', KeyModifiers::SHIFT, ScreenMode::Full), + ('_', KeyModifiers::SHIFT, ScreenMode::Half), + ('_', KeyModifiers::NONE, ScreenMode::Normal), + ] { + gui.handle_key(KeyEvent::new(KeyCode::Char(key), modifiers)) + .unwrap(); + assert_eq!(gui.screen_mode, expected); + assert_eq!(gui.current_diff_key(), stable_key); + } + let config = Arc::get_mut(&mut gui.config).unwrap(); + config.user_config.keybinding.universal.toggle_panel_reverse = "".into(); + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .next_screen_mode = "".into(); + gui.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(gui.diff_mode.focus, DiffModeFocus::Commits); + gui.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(gui.screen_mode, ScreenMode::Half); + gui.screen_mode = ScreenMode::Normal; + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .next_screen_mode = "+".into(); + gui.diff_mode.start_editing(DiffModeSelector::A); + gui.handle_key(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.screen_mode, ScreenMode::Normal); + gui.diff_mode.cancel_editing(); + gui.diff_mode.file_search_active = true; + gui.diff_mode.file_search_textarea = Some(tui_textarea::TextArea::default()); + gui.handle_key(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.screen_mode, ScreenMode::Normal); + gui.diff_mode.clear_list_search(); + gui.diff_mode.set_focus(DiffModeFocus::DiffExploration); + gui.diff_view.search_active = true; + gui.handle_key(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.screen_mode, ScreenMode::Normal); + gui.diff_view.search_active = false; + + let mouse = |gui: &mut Gui, kind, column, row| { + gui.handle_mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }) + }; + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .gui + .mouse_events = true; + for (width, height) in [(150, 40), (80, 40)] { + gui.layout.update_size(width, height); + gui.layout.side_panel_ratio = 0.4; + let layout = gui.compute_compare_layout(); + let (col, row) = if layout.portrait { + (5, layout.diff.y) + } else { + (layout.diff.x, 5) + }; + mouse(&mut gui, MouseEventKind::Down(MouseButton::Left), col, row); + assert!(gui.sidebar_resizing); + let (col, row) = if layout.portrait { (5, 25) } else { (100, 5) }; + mouse(&mut gui, MouseEventKind::Drag(MouseButton::Left), col, row); + let expected = if layout.portrait { + 25.0 / 39.0 + } else { + 100.0 / 150.0 + }; + assert!((gui.layout.side_panel_ratio - expected).abs() < 1e-8); + mouse(&mut gui, MouseEventKind::Up(MouseButton::Left), col, row); + assert!(!gui.sidebar_resizing); + // Grabbing the sidebar's bottom border must map to the same + // portrait split, rather than jumping by one row on mouse-down. + if layout.portrait { + let split = gui.compute_compare_layout().diff.y; + mouse( + &mut gui, + MouseEventKind::Down(MouseButton::Left), + 5, + split - 1, + ); + assert!(gui.sidebar_resizing); + assert_eq!(gui.compute_compare_layout().diff.y, split); + mouse( + &mut gui, + MouseEventKind::Up(MouseButton::Left), + 5, + split - 1, + ); + } + // Half/Full mode and disabled mouse input must not start resizing. + gui.screen_mode = ScreenMode::Half; + let half = gui.compute_compare_layout(); + let (col, row) = if half.portrait { + (5, half.diff.y) + } else { + (half.diff.x, 5) + }; + mouse(&mut gui, MouseEventKind::Down(MouseButton::Left), col, row); + assert!(!gui.sidebar_resizing); + gui.screen_mode = ScreenMode::Normal; + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .gui + .mouse_events = false; + let normal = gui.compute_compare_layout(); + let (col, row) = if normal.portrait { + (5, normal.diff.y) + } else { + (normal.diff.x, 5) + }; + mouse(&mut gui, MouseEventKind::Down(MouseButton::Left), col, row); + assert!(!gui.sidebar_resizing); + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .gui + .mouse_events = true; + let before = gui.layout.side_panel_ratio; + gui.handle_key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT)) + .unwrap(); + assert!((gui.layout.side_panel_ratio - (before - 0.05)).abs() < 1e-8); + gui.handle_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::ALT)) + .unwrap(); + assert!((gui.layout.side_panel_ratio - before).abs() < 1e-8); + gui.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::ALT)) + .unwrap(); + assert_eq!( + gui.layout.side_panel_ratio, + if layout.portrait { 0.0 } else { 1.0 } + ); + gui.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::ALT)) + .unwrap(); + assert_eq!( + gui.layout.side_panel_ratio, + if layout.portrait { 1.0 } else { 0.0 } + ); + gui.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::ALT)) + .unwrap(); + assert_eq!( + gui.layout.side_panel_ratio, + gui.config.user_config.gui.side_panel_width + ); + } + gui.diff_mode.ref_a = "missing".into(); reload_diff_files(&mut gui).unwrap(); assert!(gui.diff_mode.commits.is_empty()); diff --git a/src/gui/layout.rs b/src/gui/layout.rs index 67ecb10..7e17af0 100644 --- a/src/gui/layout.rs +++ b/src/gui/layout.rs @@ -47,7 +47,7 @@ const STATUS_PANEL_HEIGHT: u16 = 3; /// Portrait mode threshold: narrow terminal with enough vertical space. /// Available in both Normal and Half modes (Full mode stays full-screen). -fn should_use_portrait(width: u16, height: u16, screen_mode: ScreenMode) -> bool { +pub(crate) fn should_use_portrait(width: u16, height: u16, screen_mode: ScreenMode) -> bool { screen_mode != ScreenMode::Full && width <= 84 && height > 25 } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index cc5edf4..7106f7f 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1410,6 +1410,8 @@ impl Gui { self.diff_loading, diff_loading_show, &mut self.commit_list_cache, + self.layout.side_panel_ratio, + self.screen_mode, ); // Render popup overlay on top of diff mode (for ? help, errors, etc.) if self.popup != PopupState::None { @@ -3314,6 +3316,64 @@ impl Gui { Ok(false) } + /// Split-resize shortcuts shared by the main UI and compare mode. + fn try_handle_panel_resize_key(&mut self, key: KeyEvent) -> bool { + let keybindings = &self.config.user_config.keybinding; + // Side-panel resize: orientation-aware. + // Portrait (vertical stack): side on top, diff on bottom. + // Alt+h/l → shrink/expand by step + // Alt+k → diff pane full (ratio 0.0), Alt+j → side pane full (ratio 1.0) + // Landscape (horizontal split): side on left, diff on right. + // Alt+h/l → shrink/expand by step, Alt+k → side full, Alt+j → main full + let portrait = + layout::should_use_portrait(self.layout.width, self.layout.height, self.screen_mode); + let shrink_key = matches_key(key, &keybindings.universal.shrink_side_panel); + let expand_key = matches_key(key, &keybindings.universal.expand_side_panel); + if shrink_key || expand_key { + const STEP: f64 = 0.05; + let delta = if shrink_key { -STEP } else { STEP }; + self.layout.side_panel_ratio = (self.layout.side_panel_ratio + delta).clamp(0.0, 1.0); + return true; + } + if matches_key(key, &keybindings.universal.side_panel_full) { + // Alt+k: diff full in portrait, side full in landscape + self.layout.side_panel_ratio = if portrait { 0.0 } else { 1.0 }; + return true; + } + if matches_key(key, &keybindings.universal.main_panel_full) { + // Alt+j: side full in portrait, main full in landscape + self.layout.side_panel_ratio = if portrait { 1.0 } else { 0.0 }; + return true; + } + if matches_key(key, &keybindings.universal.reset_side_panel) { + self.layout.side_panel_ratio = self.config.user_config.gui.side_panel_width; + return true; + } + + false + } + + /// Compare layout shortcuts run only after its text-input handlers. + fn try_handle_layout_key(&mut self, mut key: KeyEvent) -> bool { + if self.try_handle_panel_resize_key(key) { + return true; + } + // Terminals may report shifted punctuation with or without SHIFT. + if matches!(key.code, KeyCode::Char('+') | KeyCode::Char('_')) { + key.modifiers.remove(KeyModifiers::SHIFT); + } + let keybindings = &self.config.user_config.keybinding; + if matches_key(key, &keybindings.universal.next_screen_mode) { + self.next_screen_mode(); + return true; + } + if matches_key(key, &keybindings.universal.prev_screen_mode) { + self.prev_screen_mode(); + return true; + } + false + } + fn handle_key(&mut self, key: KeyEvent) -> Result<()> { if self.handle_ai_commit_cancel_key(key) { return Ok(()); @@ -3350,39 +3410,10 @@ impl Gui { return controller::diff_mode::handle_key(self, key); } - let keybindings = &self.config.user_config.keybinding; - - // Side-panel resize: orientation-aware. - // Portrait (vertical stack): side on top, diff on bottom. - // Alt+h/l → shrink/expand by step - // Alt+k → diff pane full (ratio 0.0), Alt+j → side pane full (ratio 1.0) - // Landscape (horizontal split): side on left, diff on right. - // Alt+h/l → shrink/expand by step, Alt+k → side full, Alt+j → main full - let portrait = self.screen_mode != ScreenMode::Full - && self.layout.width <= 84 - && self.layout.height > 25; - let shrink_key = matches_key(key, &keybindings.universal.shrink_side_panel); - let expand_key = matches_key(key, &keybindings.universal.expand_side_panel); - if shrink_key || expand_key { - const STEP: f64 = 0.05; - let delta = if shrink_key { -STEP } else { STEP }; - self.layout.side_panel_ratio = (self.layout.side_panel_ratio + delta).clamp(0.0, 1.0); - return Ok(()); - } - if matches_key(key, &keybindings.universal.side_panel_full) { - // Alt+k: diff full in portrait, side full in landscape - self.layout.side_panel_ratio = if portrait { 0.0 } else { 1.0 }; - return Ok(()); - } - if matches_key(key, &keybindings.universal.main_panel_full) { - // Alt+j: side full in portrait, main full in landscape - self.layout.side_panel_ratio = if portrait { 1.0 } else { 0.0 }; - return Ok(()); - } - if matches_key(key, &keybindings.universal.reset_side_panel) { - self.layout.side_panel_ratio = self.config.user_config.gui.side_panel_width; + if self.try_handle_panel_resize_key(key) { return Ok(()); } + let keybindings = &self.config.user_config.keybinding; if matches_key(key, &keybindings.universal.toggle_diff_view_layout) { self.diff_view.toggle_view_layout(); @@ -6676,6 +6707,7 @@ impl Gui { } } else if self.popup == PopupState::None && self.screen_mode == ScreenMode::Normal + && (!self.diff_mode.active || self.diff_mode.editing.is_none()) && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && self.sidebar_divider_hit(mouse.column, mouse.row) { @@ -7156,7 +7188,7 @@ impl Gui { } fn handle_diff_mode_mouse(&mut self, mouse: MouseEvent) { - use self::modes::diff_mode::{CompareLayout, DiffModeFocus, DiffModeSelector}; + use self::modes::diff_mode::{DiffModeFocus, DiffModeSelector}; use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; use ratatui::layout::Rect; @@ -7217,7 +7249,7 @@ impl Gui { let area = Rect::new(0, 0, self.layout.width, self.layout.height); - let compare_layout = CompareLayout::new(area); + let compare_layout = self.compute_compare_layout(); let sidebar = compare_layout.sidebar; let selector_a_rect = sidebar[0]; @@ -7642,6 +7674,15 @@ impl Gui { ) } + fn compute_compare_layout(&self) -> modes::diff_mode::CompareLayout { + modes::diff_mode::CompareLayout::new( + ratatui::layout::Rect::new(0, 0, self.layout.width, self.layout.height), + self.layout.side_panel_ratio, + self.screen_mode, + &self.diff_mode, + ) + } + /// Content area above the status bar (side + main live here). fn content_area_rect(&self) -> ratatui::layout::Rect { ratatui::layout::Rect::new( @@ -7664,6 +7705,9 @@ impl Gui { return false; } + if self.diff_mode.active { + return self.compute_compare_layout().divider_hit(content, col, row); + } let fl = self.compute_current_frame_layout(); if fl.portrait { @@ -7713,6 +7757,9 @@ impl Gui { /// Main/diff top border: 0 (row is already the split). Expanded panel bottom: /// 1 + trailing collapsed panels so both grabs drive the same ratio. fn portrait_sidebar_resize_offset(&self, row: u16) -> u16 { + if self.diff_mode.active { + return self.compute_compare_layout().resize_row_offset(row); + } let fl = self.compute_current_frame_layout(); if !fl.portrait { return 0; @@ -7738,8 +7785,12 @@ impl Gui { if content.width == 0 || content.height == 0 { return; } - let fl = self.compute_current_frame_layout(); - let ratio = if fl.portrait { + let portrait = if self.diff_mode.active { + self.compute_compare_layout().portrait + } else { + self.compute_current_frame_layout().portrait + }; + let ratio = if portrait { let side_end = row .saturating_sub(content.y) .saturating_add(self.sidebar_resize_row_offset) diff --git a/src/gui/modes/diff_mode.rs b/src/gui/modes/diff_mode.rs index 218e8a2..ed59637 100644 --- a/src/gui/modes/diff_mode.rs +++ b/src/gui/modes/diff_mode.rs @@ -18,37 +18,135 @@ pub enum CompareDiffSource { CommitFiles, } -/// Shared by rendering and mouse hit-testing. +/// Shared by rendering, mouse hit-testing, and diff text-selection geometry. pub struct CompareLayout { - pub sidebar: std::rc::Rc<[ratatui::layout::Rect]>, + pub sidebar: [ratatui::layout::Rect; 4], pub diff: ratatui::layout::Rect, pub status: ratatui::layout::Rect, + pub portrait: bool, } impl CompareLayout { - pub fn new(area: ratatui::layout::Rect) -> Self { - use ratatui::layout::{Constraint, Direction, Layout}; + pub fn new( + area: ratatui::layout::Rect, + side_ratio: f64, + screen_mode: crate::gui::ScreenMode, + state: &DiffModeState, + ) -> Self { + use crate::gui::ScreenMode; + use ratatui::layout::{Constraint, Direction, Layout, Rect}; let outer = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Min(1), Constraint::Length(1)]) .split(area); - let content = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(33), Constraint::Percentage(67)]) - .split(outer[0]); - let sidebar = Layout::default() - .direction(Direction::Vertical) - .constraints([ + let content = outer[0]; + let portrait = + crate::gui::layout::should_use_portrait(area.width, area.height, screen_mode); + let mut result = Self { + sidebar: [Rect::default(); 4], + diff: Rect::default(), + status: outer[1], + portrait, + }; + if screen_mode == ScreenMode::Full { + if state.focus == DiffModeFocus::DiffExploration { + result.diff = content; + } else { + let index = state.focus.sidebar_index().unwrap(); + // Keep ref inputs compact with room below for their dropdowns. + result.sidebar[index] = if index < 2 { + Rect { + height: content.height.min(3), + ..content + } + } else { + content + }; + } + return result; + } + let ratio = if screen_mode == ScreenMode::Half { + 0.5 + } else { + side_ratio.clamp(0.0, 1.0) + }; + let direction = if portrait { + Direction::Vertical + } else { + Direction::Horizontal + }; + let extent = if portrait { + content.height + } else { + content.width + }; + let side_extent = (f64::from(extent) * ratio).round() as u16; + let split = Layout::default() + .direction(direction) + .constraints([Constraint::Length(side_extent), Constraint::Min(0)]) + .split(content); + result.diff = split[1]; + let constraints = if portrait { + // Like the main view, collapse inactive panels to title rows when + // stacked above the diff, giving the last-focused list the space. + let active = state.sidebar_focus.sidebar_index().unwrap_or(2); + std::array::from_fn(|i| { + if i == active { + if i < 2 { + Constraint::Length(3) + } else { + Constraint::Min(1) + } + } else if active < 2 && i == 2 { + Constraint::Min(1) + } else { + Constraint::Length(1) + } + }) + } else { + [ Constraint::Length(3), Constraint::Length(3), Constraint::Fill(1), Constraint::Fill(1), - ]) - .split(content[0]); - Self { - sidebar, - diff: content[1], - status: outer[1], + ] + }; + result.sidebar.copy_from_slice( + &Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(split[0]), + ); + result + } + + /// Both borders of the actual side/diff split can start a resize. At + /// either extreme, the outermost border allows the hidden pane to reopen. + pub fn divider_hit(&self, content: ratatui::layout::Rect, col: u16, row: u16) -> bool { + if content.width == 0 || content.height == 0 || !content.contains((col, row).into()) { + return false; + } + if self.portrait { + let split = self + .sidebar + .iter() + .map(|r| r.bottom()) + .max() + .unwrap_or(content.y); + row == split.min(content.bottom().saturating_sub(1)) + || (split > content.y && row == split - 1) + } else { + let split = self.diff.x; + col == split.min(content.right().saturating_sub(1)) + || (split > content.x && col == split - 1) + } + } + + pub fn resize_row_offset(&self, row: u16) -> u16 { + if self.portrait && self.diff.height > 0 && row < self.diff.y { + 1 + } else { + 0 } } } @@ -78,6 +176,9 @@ impl DiffModeState { self.clear_list_search(); } self.focus = focus; + if focus != DiffModeFocus::DiffExploration { + self.sidebar_focus = focus; + } match focus { DiffModeFocus::Commits => self.diff_source = CompareDiffSource::Commit, DiffModeFocus::CommitFiles => { @@ -176,10 +277,118 @@ mod tests { for (i, panel) in panels.iter().enumerate() { assert_eq!(DiffModeFocus::from_number(i as u32 + 1), Some(*panel)); assert_eq!(panel.next(), panels[(i + 1) % panels.len()]); + assert_eq!(panel.prev(), panels[(i + panels.len() - 1) % panels.len()]); + assert_eq!(panel.next().prev(), *panel); } assert_eq!(DiffModeFocus::from_number(6), None); } + #[test] + fn compare_layout_modes_ratios_and_responsive_threshold_match_main_view() { + use crate::gui::ScreenMode; + use ratatui::layout::Rect; + let mut state = DiffModeState::new(); + state.set_focus(DiffModeFocus::Commits); + for (width, height, portrait) in [ + (150, 40, false), + (84, 26, true), + (85, 26, false), + (84, 25, false), + ] { + let area = Rect::new(5, 7, width, height); + for mode in [ScreenMode::Normal, ScreenMode::Half] { + let layout = CompareLayout::new(area, 0.4, mode, &state); + assert_eq!(layout.portrait, portrait); + assert_eq!(layout.status, Rect::new(5, 7 + height - 1, width, 1)); + let ratio = if mode == ScreenMode::Half { 0.5 } else { 0.4 }; + if portrait { + assert_eq!(layout.diff.x, area.x); + assert_eq!(layout.diff.width, width); + assert_eq!( + layout.diff.y - area.y, + (f64::from(height - 1) * ratio).round() as u16 + ); + assert_eq!(layout.sidebar[2].height, 1); + assert!(layout.sidebar[3].height > 1); + state.set_focus(DiffModeFocus::DiffExploration); + assert_eq!( + CompareLayout::new(area, 0.4, mode, &state).sidebar, + layout.sidebar + ); + state.set_focus(DiffModeFocus::Commits); + } else { + assert_eq!( + layout.diff.x - area.x, + (f64::from(width) * ratio).round() as u16 + ); + assert_eq!(layout.diff.y, area.y); + } + } + for focus in [ + DiffModeFocus::SelectorA, + DiffModeFocus::SelectorB, + DiffModeFocus::CommitFiles, + DiffModeFocus::Commits, + DiffModeFocus::DiffExploration, + ] { + state.set_focus(focus); + let layout = CompareLayout::new(area, 0.4, ScreenMode::Full, &state); + assert!(!layout.portrait); + if let Some(index) = focus.sidebar_index() { + assert!(layout.diff.is_empty()); + assert_eq!(layout.sidebar[index].width, width); + for i in 0..4 { + assert_eq!(layout.sidebar[i].is_empty(), i != index); + } + } else { + assert!(layout.sidebar.iter().all(|r| r.is_empty())); + assert_eq!(layout.diff, Rect::new(5, 7, width, height - 1)); + } + } + state.set_focus(DiffModeFocus::Commits); + } + } + + #[test] + fn compare_divider_hits_only_split_borders_and_reopens_collapsed_panes() { + use crate::gui::ScreenMode; + use ratatui::layout::Rect; + let state = DiffModeState::new(); + for (width, height) in [(150, 40), (80, 40)] { + let area = Rect::new(0, 0, width, height); + let content = Rect::new(0, 0, width, height - 1); + for ratio in [0.0, 0.4, 1.0] { + let layout = CompareLayout::new(area, ratio, ScreenMode::Normal, &state); + let split = if layout.portrait { + layout.diff.y + } else { + layout.diff.x + }; + let end = if layout.portrait { + content.height + } else { + content.width + }; + let border = split.min(end - 1); + let (col, row) = if layout.portrait { + (5, border) + } else { + (border, 5) + }; + assert!(layout.divider_hit(content, col, row)); + assert!(!layout.divider_hit(content, col, height - 1)); + if ratio == 0.4 { + let (col, row) = if layout.portrait { + (5, split - 2) + } else { + (split - 2, 5) + }; + assert!(!layout.divider_hit(content, col, row)); + } + } + } + } + #[test] fn compare_commit_preview_and_files_have_independent_diff_keys() { let mut state = DiffModeState::new(); @@ -276,6 +485,26 @@ impl DiffModeFocus { } } + pub fn sidebar_index(self) -> Option { + match self { + Self::SelectorA => Some(0), + Self::SelectorB => Some(1), + Self::CommitFiles => Some(2), + Self::Commits => Some(3), + Self::DiffExploration => None, + } + } + + pub fn prev(&self) -> Self { + match self { + Self::SelectorA => Self::DiffExploration, + Self::SelectorB => Self::SelectorA, + Self::CommitFiles => Self::SelectorB, + Self::Commits => Self::CommitFiles, + Self::DiffExploration => Self::Commits, + } + } + pub fn next(&self) -> Self { match self { Self::SelectorA => Self::SelectorB, @@ -341,6 +570,8 @@ pub struct DiffModeState { // Focus pub focus: DiffModeFocus, + /// Retain the expanded portrait list while inspecting its diff. + pub sidebar_focus: DiffModeFocus, // Commit files for A..B diff pub diff_files: Vec, @@ -384,6 +615,7 @@ impl DiffModeState { search_selected: 0, dropdown_scroll: 0, focus: DiffModeFocus::SelectorA, + sidebar_focus: DiffModeFocus::SelectorA, diff_files: Vec::new(), diff_files_selected: 0, diff_files_scroll: 0, @@ -412,7 +644,7 @@ impl DiffModeState { self.search_results.clear(); self.search_selected = 0; self.dropdown_scroll = 0; - self.focus = DiffModeFocus::SelectorA; + self.set_focus(DiffModeFocus::SelectorA); self.diff_files.clear(); self.diff_files_selected = 0; self.diff_files_scroll = 0; diff --git a/src/gui/presentation/diff_mode.rs b/src/gui/presentation/diff_mode.rs index 2cf4984..94227e5 100644 --- a/src/gui/presentation/diff_mode.rs +++ b/src/gui/presentation/diff_mode.rs @@ -157,26 +157,55 @@ mod tests { #[test] fn compare_layout_renders_four_sidebar_panels_and_handles_small_terminals() { - for (width, height) in [(180, 30), (80, 14), (30, 8), (5, 3), (1, 1)] { - let mut state = DiffModeState::new(); - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal - .draw(|frame| { - render( - frame, - &mut state, - &mut DiffViewState::default(), - &Theme::default(), - false, - false, - &mut CommitListCache::default(), - ) - }) - .unwrap(); - if width == 180 { - let text = buffer_text(&terminal); - for title in ["1 A", "2 B", "3 Files", "4 Commits", "5 Diff"] { - assert!(text.contains(title), "Missing {title}: {text}"); + use crate::gui::ScreenMode; + for (width, height) in [(180, 30), (80, 40), (80, 14), (30, 8), (5, 3), (1, 1)] { + for mode in [ScreenMode::Normal, ScreenMode::Half, ScreenMode::Full] { + for focus in [ + DiffModeFocus::SelectorA, + DiffModeFocus::SelectorB, + DiffModeFocus::CommitFiles, + DiffModeFocus::Commits, + DiffModeFocus::DiffExploration, + ] { + let mut state = DiffModeState::new(); + state.set_focus(focus); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + render( + frame, + &mut state, + &mut DiffViewState::default(), + &Theme::default(), + false, + false, + &mut CommitListCache::default(), + 1.0 / 3.0, + mode, + ) + }) + .unwrap(); + let text = buffer_text(&terminal); + if width == 180 { + for (panel_focus, title) in [ + (DiffModeFocus::SelectorA, "1 A"), + (DiffModeFocus::SelectorB, "2 B"), + (DiffModeFocus::CommitFiles, "3 Files"), + (DiffModeFocus::Commits, "4 Commits"), + (DiffModeFocus::DiffExploration, "5 Diff"), + ] { + assert_eq!( + text.contains(title), + mode != ScreenMode::Full || focus == panel_focus, + "{mode:?} {focus:?}: {text}" + ); + } + } + if width == 80 && height == 40 && mode != ScreenMode::Full { + for title in ["1 A", "2 B", "3 Files", "4 Commits", "5 Diff"] { + assert!(text.contains(title), "{mode:?} {focus:?}: {text}"); + } + } } } } @@ -191,34 +220,44 @@ pub fn render( diff_loading: bool, diff_loading_show: bool, commit_cache: &mut CommitListCache, + side_ratio: f64, + screen_mode: crate::gui::ScreenMode, ) { - let layout = CompareLayout::new(frame.area()); + let layout = CompareLayout::new(frame.area(), side_ratio, screen_mode, state); let sidebar = layout.sidebar; - render_selector(frame, sidebar[0], state, DiffModeFocus::SelectorA, theme); - render_selector(frame, sidebar[1], state, DiffModeFocus::SelectorB, theme); - render_commit_files(frame, sidebar[2], state, theme); - render_compare_commits(frame, sidebar[3], state, theme, commit_cache); - - // Right panel: diff exploration - render_diff_panel( - frame, - layout.diff, - state, - diff_view, - theme, - diff_loading, - diff_loading_show, - ); + if !sidebar[0].is_empty() { + render_selector(frame, sidebar[0], state, DiffModeFocus::SelectorA, theme); + } + if !sidebar[1].is_empty() { + render_selector(frame, sidebar[1], state, DiffModeFocus::SelectorB, theme); + } + if !sidebar[2].is_empty() { + render_commit_files(frame, sidebar[2], state, theme); + } + if !sidebar[3].is_empty() { + render_compare_commits(frame, sidebar[3], state, theme, commit_cache); + } - // Text selection highlight overlay and tooltip (must be before popups/dropdowns) - crate::gui::views::render_selection_overlay(frame, diff_view, layout.diff, theme); + if !layout.diff.is_empty() { + render_diff_panel( + frame, + layout.diff, + state, + diff_view, + theme, + diff_loading, + diff_loading_show, + ); + // Selection overlay must be before popups/dropdowns. + crate::gui::views::render_selection_overlay(frame, diff_view, layout.diff, theme); + } // Status bar render_status_bar(frame, layout.status, state, diff_view, theme); // Render combobox dropdown overlay on top of the sidebar - if state.editing.is_some() { + if state.editing.is_some() && sidebar.iter().any(|r| !r.is_empty()) { render_dropdown(frame, sidebar, state, theme); } } @@ -701,12 +740,7 @@ fn render_status_bar( frame.render_widget(bar, area); } -fn render_dropdown( - frame: &mut Frame, - sidebar: std::rc::Rc<[Rect]>, - state: &DiffModeState, - theme: &Theme, -) { +fn render_dropdown(frame: &mut Frame, sidebar: [Rect; 4], state: &DiffModeState, theme: &Theme) { // Position dropdown below the relevant selector let anchor = if matches!( state.editing, From a84188fe646bef27fe65bff5cd57b8244bb39e73 Mon Sep 17 00:00:00 2001 From: Carlo Taleon <38070918+Blankeos@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:25:11 +0800 Subject: [PATCH 07/11] feat(pager): add inline image diff previews (#39) * feat(pager): add inline image diff previews for raster files - Render Before/After previews for PNG, JPEG, GIF, WebP, BMP, ICO, and TIFF in Kitty and Ghostty, with Added/Deleted full-width states backed by worktree, index, and revision blobs - Add background decode with memory/dimension limits, 20 MiB cap, and striped binary fallback for unsupported terminals, oversized files, and corrupt images - Support capped inline image sections in folder diffs across split/unified and wrapped views with viewport-driven loading and cache-aware unloading - Add diff path parsing for quoted/renamed headers, terminal graphics protocol handling, `LAZYGITRS_IMAGE_PREVIEW` opt-out/experimental flags, and tmux/screen/Zellij fallback * refactor(pager): isolate image preview capabilities and sources * fix(input): preserve Alt shortcuts when filtering Kitty replies --- Cargo.lock | 385 ++++ Cargo.toml | 2 + README.md | 24 + src/git/diff.rs | 2 +- src/git/diff_paths.rs | 491 +++++ src/git/mod.rs | 1 + src/gui/controller/diff_mode.rs | 33 +- src/gui/input.rs | 344 +++- src/gui/mod.rs | 157 +- src/pager/image_preview.rs | 2499 +++++++++++++++++++++++ src/pager/image_preview/capabilities.rs | 488 +++++ src/pager/image_preview/inline_diff.rs | 174 ++ src/pager/image_preview/source.rs | 234 +++ src/pager/mod.rs | 1 + src/pager/side_by_side.rs | 521 ++++- 15 files changed, 5285 insertions(+), 71 deletions(-) create mode 100644 src/git/diff_paths.rs create mode 100644 src/pager/image_preview.rs create mode 100644 src/pager/image_preview/capabilities.rs create mode 100644 src/pager/image_preview/inline_diff.rs create mode 100644 src/pager/image_preview/source.rs diff --git a/Cargo.lock b/Cargo.lock index bfc8b4c..6bec133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -73,6 +79,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bitflags" version = "2.13.2" @@ -90,6 +108,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -167,6 +197,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -187,6 +223,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -215,6 +260,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "darling" version = "0.24.1" @@ -303,6 +354,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -320,6 +386,17 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -343,6 +420,27 @@ dependencies = [ "wasi", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -453,12 +551,47 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +[[package]] +name = "icy_sixel" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc0a9c4770bc47b0a933256a496cfb8b6531f753ea9bccb19c6dff0ff7273fc" + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "include_dir" version = "0.7.4" @@ -545,10 +678,12 @@ dependencies = [ "clap", "crossterm", "dirs", + "image", "include_dir", "libc", "once_cell", "ratatui", + "ratatui-image", "serde", "serde_json", "serde_yaml", @@ -642,6 +777,26 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.3" @@ -654,6 +809,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -663,6 +828,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -716,6 +890,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -727,6 +914,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -736,6 +932,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.47" @@ -745,6 +953,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "ratatui" version = "0.29.0" @@ -766,6 +1004,22 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "ratatui-image" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8fe71c551c67f34e4fa49797227f614cd064b82855d7b72d424e40d08ec0542" +dependencies = [ + "base64", + "icy_sixel", + "image", + "rand", + "ratatui", + "rustix", + "thiserror 1.0.69", + "windows", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -948,6 +1202,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "similar" version = "2.7.0" @@ -1128,6 +1388,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -1455,6 +1729,12 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -1477,12 +1757,76 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1594,6 +1938,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -1648,8 +2012,29 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 8118aea..084f991 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ clap = { version = "4", features = ["derive"] } crossterm = { version = "0.28", features = ["event-stream", "libc", "use-dev-tty"] } dirs = "6" ratatui = "0.29" +ratatui-image = { version = "=8.0.1", default-features = false } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "ico", "tiff"] } serde = { version = "1", features = ["derive"] } serde_yaml = "0.9" serde_json = "1" diff --git a/README.md b/README.md index 6603148..adfe4b1 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,30 @@ lazygitrs upgrade 0.0.32 # specific version - [x] 🎨 Themes + Theme-Picker! - [x] **Grep diff contents** — `Ctrl-F` in Files / Commit Files / Compare searches hunk lines in-context, `Enter` jumps to the file in the current list. +### Image diffs + +Selecting a PNG, JPEG, GIF (first frame), WebP, BMP, ICO, or TIFF file shows +**Before / After** previews in Kitty and Ghostty. Added/deleted files show the +available image full-width, labeled **Added** or **Deleted**. Staged previews use the index; +commit-file and ref comparisons use the corresponding Git blobs. + +Other binaries, unsupported/corrupt images, files over 20 MiB, and unsupported +terminal environments keep the striped binary placeholder. Images are decoded +with memory/dimension limits and resized in the background. SVG and video +previews are not included yet. + +Folder previews in the file tree mix text diffs with inline image sections. +Each image section is capped at 12 rows; visible sections load in the background +(two at a time) and offscreen pixels are released. This works in split/unified +and wrapped views, including commit/stash folders and ref-comparison folders. +Whole-commit overview buffers still retain binary placeholders. Inline images +require Kitty/Ghostty placeholder graphics; other terminals keep stripes. + +Set `LAZYGITRS_IMAGE_PREVIEW=off` to disable image previews. tmux, screen, and +Zellij currently fall back to stripes. iTerm2/WezTerm and Sixel are experimental +(`LAZYGITRS_IMAGE_PREVIEW=experimental`); their overlay cleanup is not yet fully +verified. Nested editor launches avoid terminal capability queries. + ### Configuration Config goes in `~/.config/lazygitrs/config.yml` or `~/.config/lazygit/config.yml` — both work, using either only won't break anything so you can reference the [original lazygit config guide](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md). diff --git a/src/git/diff.rs b/src/git/diff.rs index 18928b1..09b848d 100644 --- a/src/git/diff.rs +++ b/src/git/diff.rs @@ -510,7 +510,7 @@ impl GitCommands { } } -fn diff_paths_for_label(path: &str) -> Vec<&str> { +pub(crate) fn diff_paths_for_label(path: &str) -> Vec<&str> { match path.split_once(" -> ") { Some((old, new)) => vec![old, new], None => vec![path], diff --git a/src/git/diff_paths.rs b/src/git/diff_paths.rs new file mode 100644 index 0000000..6f34812 --- /dev/null +++ b/src/git/diff_paths.rs @@ -0,0 +1,491 @@ +//! Parse file paths from `git diff` output. +//! +//! Only the first file section's headers are inspected. Hunk bodies (from the +//! first `@@` line on) are never read so hunk content that looks like +//! metadata cannot be mistaken for headers. + +/// Old (`a/` side) and new (`b/` side) paths for the first file in a diff. +/// +/// `None` means that side is `/dev/null`: +/// - new file → `old` is `None` +/// - deleted file → `new` is `None` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiffFilePaths { + pub old: Option, + pub new: Option, +} + +/// Extract the first file section's paths from `git diff` output. +/// +/// Looks at the first `diff --git a/… b/…` header (including C-style quoted +/// paths and unquoted paths with spaces), then applies `rename from/to`, +/// `copy from/to`, `---`/`+++` (`/dev/null` → `None`) and +/// `new file mode` (`old` → `None`) / `deleted file mode` (`new` → `None`) +/// overrides. Stops at the first `@@` hunk header and at the next +/// `diff --git` (second file). Returns `None` when no headers are found. +pub(crate) fn paths_from_diff(diff: &str) -> Option { + let mut old: Option = None; + let mut new: Option = None; + let mut seen_diff_git = false; + let mut seen_any = false; + + for line in diff.lines() { + if line.starts_with("diff --git ") { + if seen_diff_git { + break; + } + if let Some((o, n)) = parse_diff_git_line(line) { + old = Some(o); + new = Some(n); + } + seen_diff_git = true; + seen_any = true; + continue; + } + + if line.starts_with("@@") { + break; + } + + if let Some(rest) = line.strip_prefix("rename from ") { + if let Some(p) = decode_single_path(rest) { + old = Some(p); + seen_any = true; + } + continue; + } + if let Some(rest) = line.strip_prefix("rename to ") { + if let Some(p) = decode_single_path(rest) { + new = Some(p); + seen_any = true; + } + continue; + } + if let Some(rest) = line.strip_prefix("copy from ") { + if let Some(p) = decode_single_path(rest) { + old = Some(p); + seen_any = true; + } + continue; + } + if let Some(rest) = line.strip_prefix("copy to ") { + if let Some(p) = decode_single_path(rest) { + new = Some(p); + seen_any = true; + } + continue; + } + + if is_mode_line(line, "new file mode") { + old = None; + seen_any = true; + continue; + } + if is_mode_line(line, "deleted file mode") { + new = None; + seen_any = true; + continue; + } + + if let Some(rest) = line.strip_prefix("--- ") { + if let Some(opt) = parse_ext_path(rest, "a/") { + old = opt; + seen_any = true; + } + continue; + } + if let Some(rest) = line.strip_prefix("+++ ") { + if let Some(opt) = parse_ext_path(rest, "b/") { + new = opt; + seen_any = true; + } + continue; + } + + if line.starts_with("Binary files ") || line.starts_with("Binary file ") { + // Binary marker carries no paths beyond `/dev/null` handling. + // Synthetic binary diffs still provide `diff --git` + file modes, + // so this only needs to cover `/dev/null` sides. + if line.starts_with("Binary files /dev/null and ") + || line.starts_with("Binary file /dev/null and ") + { + old = None; + seen_any = true; + } else if line.contains(" and /dev/null differ") { + new = None; + seen_any = true; + } + continue; + } + } + + if !seen_any { + return None; + } + Some(DiffFilePaths { old, new }) +} + +/// Return the new (`b/` side) path for a diff header. +/// +/// Accepts either a single `diff --git …` line or a full (single-file) diff. +/// Falls back to the old path for deleted files so callers always get a +/// filename when one is known. +pub(crate) fn new_path_from_header(header: &str) -> Option { + let paths = paths_from_diff(header)?; + paths.new.or(paths.old) +} + +fn is_mode_line(line: &str, kind: &str) -> bool { + if let Some(rest) = line.strip_prefix(kind) { + rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') + } else { + false + } +} + +fn strip_prefix_or_raw(path: &str, prefix: &str) -> String { + if let Some(rest) = path.strip_prefix(prefix) { + rest.to_string() + } else { + path.to_string() + } +} + +/// Decode a lone path token (for `rename from/to`, `copy from/to`). +/// C-quoted (`"…"`) tokens are C-unescaped; anything else is kept byte for +/// byte (no trimming) so trailing spaces survive. +fn decode_single_path(rest: &str) -> Option { + if rest.is_empty() { + return None; + } + if rest.starts_with('"') { + let (decoded, _) = parse_c_quoted(rest)?; + Some(decoded) + } else { + Some(rest.to_string()) + } +} + +/// Parse an extended-header path (`---`/`+++` remainder after the marker). +/// Returns `None` for malformed input, `Some(None)` for `/dev/null`, +/// `Some(Some(path))` otherwise. `prefix` is `a/` for `---`, `b/` for `+++`. +fn parse_ext_path(rest: &str, prefix: &str) -> Option> { + if rest.is_empty() { + return None; + } + if rest.starts_with('"') { + let (decoded, _) = parse_c_quoted(rest)?; + if decoded == "/dev/null" { + return Some(None); + } + return Some(Some(strip_prefix_or_raw(&decoded, prefix))); + } + let path_part = rest.split('\t').next().unwrap_or(rest); + if path_part == "/dev/null" { + return Some(None); + } + Some(Some(strip_prefix_or_raw(path_part, prefix))) +} + +fn parse_diff_git_line(line: &str) -> Option<(String, String)> { + let rest = line.strip_prefix("diff --git ")?; + if rest.is_empty() { + return None; + } + if rest.starts_with('"') { + let (first_raw, after_first) = parse_c_quoted(rest)?; + let after = after_first.strip_prefix(' ')?; + if after.is_empty() { + return None; + } + if after.starts_with('"') { + let (second_raw, _) = parse_c_quoted(after)?; + let old = strip_prefix_or_raw(&first_raw, "a/"); + let new = strip_prefix_or_raw(&second_raw, "b/"); + Some((old, new)) + } else { + let old = strip_prefix_or_raw(&first_raw, "a/"); + let new = strip_prefix_or_raw(after, "b/"); + Some((old, new)) + } + } else { + let mut candidates = Vec::new(); + for (idx, _) in rest.match_indices(" b/") { + candidates.push(idx); + } + if candidates.is_empty() { + return None; + } + if candidates.len() == 1 { + let idx = candidates[0]; + let left = &rest[..idx]; + let right = &rest[idx + 3..]; + let old = strip_prefix_or_raw(left, "a/"); + return Some((old, right.to_string())); + } + for idx in &candidates { + let left = &rest[..*idx]; + let right = &rest[idx + 3..]; + let old_cand = strip_prefix_or_raw(left, "a/"); + if old_cand == right { + return Some((old_cand, right.to_string())); + } + } + let idx = candidates[0]; + let left = &rest[..idx]; + let right = &rest[idx + 3..]; + let old = strip_prefix_or_raw(left, "a/"); + Some((old, right.to_string())) + } +} + +/// Parse a C-style quoted string starting at the opening `"`. +/// Returns the decoded string plus the remainder after the closing quote. +fn parse_c_quoted(input: &str) -> Option<(String, &str)> { + if !input.starts_with('"') { + return None; + } + let bytes = input.as_bytes(); + let mut out: Vec = Vec::new(); + let mut i = 1; + while i < bytes.len() { + let b = bytes[i]; + if b == b'"' { + let remainder = &input[i + 1..]; + let s = String::from_utf8_lossy(&out).into_owned(); + return Some((s, remainder)); + } else if b == b'\\' { + i += 1; + if i >= bytes.len() { + return None; + } + let e = bytes[i]; + match e { + b'n' => out.push(b'\n'), + b't' => out.push(b'\t'), + b'r' => out.push(b'\r'), + b'a' => out.push(0x07), + b'b' => out.push(0x08), + b'f' => out.push(0x0C), + b'v' => out.push(0x0B), + b'\\' => out.push(b'\\'), + b'"' => out.push(b'"'), + b'0'..=b'7' => { + let mut val: u32 = (e - b'0') as u32; + let mut count = 1; + while count < 3 + && i + 1 < bytes.len() + && bytes[i + 1] >= b'0' + && bytes[i + 1] <= b'7' + { + i += 1; + val = val * 8 + (bytes[i] - b'0') as u32; + count += 1; + } + out.push((val & 0xFF) as u8); + } + _ => out.push(e), + } + } else { + out.push(b); + } + i += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header_diff(body: &str) -> String { + body.to_string() + } + + #[test] + fn unquoted_simple() { + let diff = header_diff( + "diff --git a/foo.txt b/foo.txt\nindex abc..def 100644\n--- a/foo.txt\n+++ b/foo.txt\n@@ -1 +1 @@\n-old\n+new\n", + ); + let p = paths_from_diff(&diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo.txt")); + assert_eq!(p.new.as_deref(), Some("foo.txt")); + } + + #[test] + fn unquoted_with_spaces() { + let diff = "diff --git a/foo bar/baz qux.txt b/foo bar/baz qux.txt\n--- a/foo bar/baz qux.txt\n+++ b/foo bar/baz qux.txt\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo bar/baz qux.txt")); + assert_eq!(p.new.as_deref(), Some("foo bar/baz qux.txt")); + assert_eq!( + new_path_from_header("diff --git a/foo bar/baz qux.txt b/foo bar/baz qux.txt"), + Some("foo bar/baz qux.txt".to_string()) + ); + } + + #[test] + fn filename_containing_b_slash_uses_equality() { + // Raw path is `x b/y`; the separator is ambiguous without equality. + let diff = + "diff --git a/x b/y b/x b/y\n--- a/x b/y\n+++ b/x b/y\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("x b/y")); + assert_eq!(p.new.as_deref(), Some("x b/y")); + assert_eq!( + new_path_from_header("diff --git a/x b/y b/x b/y"), + Some("x b/y".to_string()) + ); + } + + #[test] + fn escaped_quote_and_backslash() { + // File `foo"bar\baz` + let diff = "diff --git \"a/foo\\\"bar\\\\baz\" \"b/foo\\\"bar\\\\baz\"\n--- \"a/foo\\\"bar\\\\baz\"\n+++ \"b/foo\\\"bar\\\\baz\"\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo\"bar\\baz")); + assert_eq!(p.new.as_deref(), Some("foo\"bar\\baz")); + } + + #[test] + fn escaped_newline_and_tab() { + // File `foo\nbar\tbaz` (real newline/tab via escapes) + let diff = "diff --git \"a/foo\\nbar\\tbaz\" \"b/foo\\nbar\\tbaz\"\n--- \"a/foo\\nbar\\tbaz\"\n+++ \"b/foo\\nbar\\tbaz\"\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo\nbar\tbaz")); + assert_eq!(p.new.as_deref(), Some("foo\nbar\tbaz")); + } + + #[test] + fn utf8_octal_decodes() { + // `caf\u{e9}` with é as octal bytes \303\251 + let diff = "diff --git \"a/caf\\303\\251.txt\" \"b/caf\\303\\251.txt\"\n--- \"a/caf\\303\\251.txt\"\n+++ \"b/caf\\303\\251.txt\"\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("café.txt")); + assert_eq!(p.new.as_deref(), Some("café.txt")); + } + + #[test] + fn added_file() { + let diff = "diff --git a/new.txt b/new.txt\nnew file mode 100644\nindex 0000000..abc\n--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1 @@\n+hello\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old, None); + assert_eq!(p.new.as_deref(), Some("new.txt")); + } + + #[test] + fn deleted_file() { + let diff = "diff --git a/old.txt b/old.txt\ndeleted file mode 100644\nindex abc..0000000\n--- a/old.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-hello\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("old.txt")); + assert_eq!(p.new, None); + // new_path falls back to old so deleted files still have a name. + assert_eq!(new_path_from_header(diff), Some("old.txt".to_string())); + } + + #[test] + fn new_file_mode_without_dashes_binary_style() { + let diff = "diff --git a/img.png b/img.png\nnew file mode 100644\nindex 0000000..abc\nBinary files /dev/null and b/img.png differ\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old, None); + assert_eq!(p.new.as_deref(), Some("img.png")); + } + + #[test] + fn renamed_paths_override() { + let diff = "diff --git a/old name.txt b/new name.txt\nsimilarity index 90%\nrename from old name.txt\nrename to new name.txt\n--- a/old name.txt\n+++ b/new name.txt\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("old name.txt")); + assert_eq!(p.new.as_deref(), Some("new name.txt")); + assert_eq!(new_path_from_header(diff), Some("new name.txt".to_string())); + } + + #[test] + fn renamed_quoted_override() { + let diff = "diff --git \"a/foo\\\"old\" \"b/foo\\\"new\"\nsimilarity index 90%\nrename from \"foo\\\"old\"\nrename to \"foo\\\"new\"\n--- \"a/foo\\\"old\"\n+++ \"b/foo\\\"new\"\n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo\"old")); + assert_eq!(p.new.as_deref(), Some("foo\"new")); + } + + #[test] + fn rename_only_without_dashes() { + let diff = "diff --git a/old.txt b/new.txt\nsimilarity index 100%\nrename from old.txt\nrename to new.txt\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("old.txt")); + assert_eq!(p.new.as_deref(), Some("new.txt")); + } + + #[test] + fn copy_from_to_override() { + let diff = "diff --git a/orig.txt b/copy.txt\nsimilarity index 95%\ncopy from orig.txt\ncopy to copy.txt\n--- a/orig.txt\n+++ b/copy.txt\n@@ -1 +1 @@\n-x\n+y\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("orig.txt")); + assert_eq!(p.new.as_deref(), Some("copy.txt")); + } + + #[test] + fn hunk_lines_pretending_metadata_are_ignored() { + let diff = "diff --git a/real.txt b/real.txt\n--- a/real.txt\n+++ b/real.txt\n@@ -1,3 +1,3 @@\n context\ndiff --git a/fake b/fake\n--- a/fake\n+++ b/fake\n-rename from fake\n-rename to fake\n-new file mode 100644\n-deleted file mode 100644\n+newcontent\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("real.txt")); + assert_eq!(p.new.as_deref(), Some("real.txt")); + } + + #[test] + fn hunk_minus_plus_pretending_headers_ignored() { + let diff = "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n---- a/fake\n-+++ b/fake\n old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("a.txt")); + assert_eq!(p.new.as_deref(), Some("a.txt")); + } + + #[test] + fn no_headers_returns_none() { + assert_eq!(paths_from_diff(""), None); + assert_eq!(paths_from_diff("just some text\nno diff here\n"), None); + assert_eq!(new_path_from_header("nothing"), None); + } + + #[test] + fn first_section_wins_for_multi_file() { + let diff = "diff --git a/first.txt b/first.txt\n--- a/first.txt\n+++ b/first.txt\n@@ -1 +1 @@\n-a\n+b\ndiff --git a/second.txt b/second.txt\n--- a/second.txt\n+++ b/second.txt\n@@ -1 +1 @@\n-c\n+d\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("first.txt")); + assert_eq!(p.new.as_deref(), Some("first.txt")); + } + + #[test] + fn trailing_spaces_preserved() { + let diff = "diff --git a/foo b/foo \n--- a/foo \n+++ b/foo \n@@ -1 +1 @@\n-old\n+new\n"; + let p = paths_from_diff(diff).expect("paths"); + assert_eq!(p.old.as_deref(), Some("foo ")); + assert_eq!(p.new.as_deref(), Some("foo ")); + } + + #[test] + fn new_path_from_single_line() { + assert_eq!( + new_path_from_header("diff --git a/foo.txt b/foo.txt"), + Some("foo.txt".to_string()) + ); + assert_eq!( + new_path_from_header("diff --git \"a/foo\\nbar\" \"b/foo\\nbar\""), + Some("foo\nbar".to_string()) + ); + } + + #[test] + fn binary_dev_null_sides() { + let added = "diff --git a/new.bin b/new.bin\nBinary files /dev/null and b/new.bin differ\n"; + let p = paths_from_diff(added).expect("paths"); + assert_eq!(p.old, None); + assert_eq!(p.new.as_deref(), Some("new.bin")); + + let deleted = + "diff --git a/old.bin b/old.bin\nBinary files a/old.bin and /dev/null differ\n"; + let p = paths_from_diff(deleted).expect("paths"); + assert_eq!(p.old.as_deref(), Some("old.bin")); + assert_eq!(p.new, None); + } +} diff --git a/src/git/mod.rs b/src/git/mod.rs index 8a131cd..552084d 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -3,6 +3,7 @@ pub mod bisect; pub mod branch; pub mod commit; pub mod diff; +pub(crate) mod diff_paths; pub mod file; pub mod loader; pub mod rebase; diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index 3e1671d..abe46ed 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -902,6 +902,24 @@ pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { Ok(diff) if diff.is_empty() => DiffPayload::Empty, Ok(diff) => { let exists = git.repo_path().join(¤t_path).exists(); + use crate::pager::image_preview::ImageSource; + let paths = crate::git::diff::diff_paths_for_label(&name); + if let Some(parsed) = super::super::image_diff_payload( + &git, + &name, + &diff, + exists, + ImageSource::Revision { + revision: &ref_a, + path: paths[0], + }, + ImageSource::Revision { + revision: &ref_b, + path: ¤t_path, + }, + ) { + return DiffPayload::Parsed(parsed); + } // Pure renames between refs: show file content at ref_b. if crate::pager::side_by_side::is_rename_only_diff(&diff) { if let Ok(content) = git.file_content_at_commit( @@ -955,12 +973,17 @@ pub fn maybe_request_diff(gui: &mut Gui, generation: u64, diff_key: String) { if combined_diff.is_empty() { DiffPayload::Empty } else { - DiffPayload::Parsed(DiffViewState::parse_diff_output( - &dir_name, + let mut parsed = + DiffViewState::parse_diff_output(&dir_name, &combined_diff, 4, true); + crate::pager::image_preview::attach_inline_image_previews( + &mut parsed, + git.repo_path(), &combined_diff, - 4, - true, - )) + &ref_a, + Some(&ref_b), + &std::collections::HashSet::new(), + ); + DiffPayload::Parsed(parsed) } }); } else { diff --git a/src/gui/input.rs b/src/gui/input.rs index d8fc62b..ae8d5a9 100644 --- a/src/gui/input.rs +++ b/src/gui/input.rs @@ -20,6 +20,8 @@ //! sequence is dropped rather than replayed byte by byte. It is also lossless //! for real keys — anything that cannot appear inside the sequence being parsed //! is handed back as the keypress it is. +//! An Alt+underscore is ambiguous with an APC introducer, so its lookahead is +//! lossless: only a complete, validated Kitty reply is suppressed. use std::io; use std::sync::Arc; @@ -52,6 +54,9 @@ const SEQUENCE_DEADLINE: Duration = Duration::from_millis(400); /// Guard against a pathological run of parameter bytes. const MAX_SEQUENCE_BYTES: usize = 64; +/// Bound speculative Kitty reply buffering, including printable error text. +const MAX_KITTY_REPLY_BYTES: usize = 1024; + /// Most events handed to the UI in one batch. Keeps a paste storm or a very long /// auto-repeat burst from starving the renderer. const MAX_BATCH: usize = 512; @@ -170,28 +175,33 @@ fn next_events_interruptible( break event; } }; - let Event::Key(key) = first else { - return Ok(Some(vec![first])); - }; - if key.code != KeyCode::Esc || key.kind != KeyEventKind::Press { - return Ok(Some(vec![Event::Key(key)])); - } - resolve_escape(source).map(Some) + resolve_first_event(source, first).map(Some) } /// Blocking read of the next logical event(s). fn next_events(source: &mut impl EventSource) -> io::Result> { - // A bare `Esc` is the only event that can be the head of a sequence that - // crossterm failed to keep together, so everything else passes straight - // through. + // Both entry points share reassembly and lossless Kitty reply recognition. let first = loop { if let Some(event) = source.next(Duration::from_secs(3600))? { break event; } }; + resolve_first_event(source, first) +} + +fn resolve_first_event(source: &mut impl EventSource, first: Event) -> io::Result> { let Event::Key(key) = first else { return Ok(vec![first]); }; + // Crossterm parses an intact ESC _ (APC) as Alt+_, not as a control + // string. Validate the reply rather than swallowing genuine Alt shortcuts + // and whatever input follows them. + if key.kind == KeyEventKind::Press + && key.modifiers == KeyModifiers::ALT + && key.code == KeyCode::Char('_') + { + return resolve_kitty_reply(source, key); + } if key.code != KeyCode::Esc || key.kind != KeyEventKind::Press { return Ok(vec![Event::Key(key)]); } @@ -219,9 +229,14 @@ fn resolve_escape(source: &mut impl EventSource) -> io::Result> { match introducer { '[' => read_csi(source, deadline), 'O' => read_ss3(source, deadline), - // OSC/DCS/PM/APC carry a string payload that is never a shortcut. Read - // to its terminator and emit nothing. - ']' | 'P' | '^' | '_' => { + // A split APC and a genuine Alt+underscore use the same recognizer as + // the already-parsed Alt event. Do not infer a reply from '_' alone. + '_' => { + key.modifiers |= KeyModifiers::ALT; + resolve_kitty_reply(source, key) + } + // Existing OSC/DCS/PM reassembly remains unchanged. + ']' | 'P' | '^' => { consume_control_string(source, deadline)?; Ok(Vec::new()) } @@ -234,6 +249,94 @@ fn resolve_escape(source: &mut impl EventSource) -> io::Result> { } } +/// Keep all speculative events until a complete Kitty reply is confirmed. +/// No caller needs to track terminal query state or manage a replay queue. +fn resolve_kitty_reply( + source: &mut impl EventSource, + introducer: KeyEvent, +) -> io::Result> { + let mut events = vec![Event::Key(introducer)]; + let mut payload = String::new(); + let mut saw_esc = false; + let deadline = Instant::now() + SEQUENCE_DEADLINE; + loop { + let budget = if events.len() == 1 { + INTRODUCER_PROBE + } else { + CONTINUATION_WINDOW + }; + let Some(event) = + source.next(budget.min(deadline.saturating_duration_since(Instant::now())))? + else { + return Ok(events); + }; + events.push(event.clone()); + let Event::Key(key) = event else { + return Ok(events); + }; + if key.kind != KeyEventKind::Press { + return Ok(events); + } + if key.code == KeyCode::Char('\\') && (saw_esc || key.modifiers == KeyModifiers::ALT) { + return Ok(if is_kitty_reply(&payload) { + Vec::new() + } else { + events + }); + } + if saw_esc || key.modifiers != KeyModifiers::NONE { + return Ok(events); + } + if key.code == KeyCode::Esc { + saw_esc = true; + continue; + } + let KeyCode::Char(ch @ ' '..='~') = key.code else { + return Ok(events); + }; + payload.push(ch); + // Fail fast before waiting for a terminator on ordinary typed text. + let valid_prefix = match payload.len() { + 1 => payload == "G", + 2 => matches!(payload.as_str(), "Gi" | "GI"), + 3 => matches!(payload.as_str(), "Gi=" | "GI="), + _ => true, + }; + if !valid_prefix || payload.len() >= MAX_KITTY_REPLY_BYTES { + return Ok(events); + } + } +} + +/// Kitty acknowledges image identities with optional placement identities and +/// an ASCII status. Only this reply shape, not arbitrary APC text, is filtered. +fn is_kitty_reply(payload: &str) -> bool { + let Some((fields, status)) = payload.strip_prefix('G').and_then(|s| s.split_once(';')) else { + return false; + }; + if status.is_empty() || !status.bytes().all(|b| matches!(b, b' '..=b'~')) { + return false; + } + let mut has_image_id = false; + for field in fields.split(',') { + let Some((key, value)) = field.split_once('=') else { + return false; + }; + if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { + return false; + } + let Ok(value) = value.parse::() else { + return false; + }; + match key { + "i" | "I" if value != 0 => has_image_id = true, + "p" => {} + _ => return false, + } + } + has_image_id +} + /// Read the body of a `CSI` sequence and turn it into the event it encodes. fn read_csi(source: &mut impl EventSource, deadline: Instant) -> io::Result> { let mut body = String::new(); @@ -312,7 +415,9 @@ fn consume_control_string(source: &mut impl EventSource, deadline: Instant) -> i }; match key.code { KeyCode::Esc => saw_esc = true, - KeyCode::Char('\\') if saw_esc => return Ok(()), + KeyCode::Char('\\') if saw_esc || key.modifiers == KeyModifiers::ALT => { + return Ok(()); + } KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => return Ok(()), _ => saw_esc = false, } @@ -606,4 +711,215 @@ mod tests { assert!(is_csi_final('u')); assert!(!is_csi_final('3')); } + + #[test] + fn kitty_reply_parsed_as_alt_introducer_is_not_a_shortcut() { + for interruptible in [false, true] { + let introducer = Event::Key(KeyEvent::new(KeyCode::Char('_'), KeyModifiers::ALT)); + let terminator = Event::Key(KeyEvent::new(KeyCode::Char('\\'), KeyModifiers::ALT)); + let mut items = vec![Some(introducer)]; + items.extend("Gi=31;OK".chars().map(ch)); + items.extend([Some(terminator), ch('q')]); + let mut source = Scripted::new(items); + let read = |source: &mut Scripted| { + if interruptible { + next_events_interruptible(source, &AtomicBool::new(false)).map(Option::unwrap) + } else { + next_events(source) + } + }; + assert!(read(&mut source).unwrap().is_empty()); + assert_eq!(read(&mut source).unwrap(), vec![key(KeyCode::Char('q'))]); + } + } + + #[test] + fn split_kitty_reply_is_not_a_shortcut() { + let mut items = vec![Some(key(KeyCode::Esc)), ch('_')]; + items.extend("Gi=31;OK".chars().map(ch)); + items.extend([Some(key(KeyCode::Esc)), ch('\\'), ch('q')]); + let mut source = Scripted::new(items); + assert!(next_events(&mut source).unwrap().is_empty()); + assert_eq!( + next_events(&mut source).unwrap(), + vec![key(KeyCode::Char('q'))] + ); + } + + #[test] + fn unrelated_alt_key_still_reaches_the_ui() { + let event = Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)); + assert_eq!( + next_events(&mut Scripted::new(vec![Some(event.clone())])).unwrap(), + vec![event] + ); + } + + fn read_scripted(source: &mut Scripted, interruptible: bool) -> Vec { + if interruptible { + next_events_interruptible(source, &AtomicBool::new(false)) + .unwrap() + .unwrap() + } else { + next_events(source).unwrap() + } + } + + #[test] + fn genuine_alt_control_introducers_and_following_input_are_preserved() { + for interruptible in [false, true] { + for c in [']', 'P', '^', '_'] { + let alt = Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::ALT)); + let next = key(KeyCode::Char('q')); + let mut source = Scripted::new(vec![Some(alt.clone()), Some(next.clone())]); + let mut received = read_scripted(&mut source, interruptible); + if !source.0.is_empty() { + received.extend(read_scripted(&mut source, interruptible)); + } + assert_eq!(received, vec![alt.clone(), next]); + + let mut source = Scripted::new(vec![Some(alt.clone()), None]); + assert_eq!(read_scripted(&mut source, interruptible), vec![alt]); + } + } + } + + #[test] + fn split_apc_introducer_preserves_genuine_alt_underscore() { + let mut source = Scripted::new(vec![Some(key(KeyCode::Esc)), ch('_'), ch('q')]); + assert_eq!( + next_events(&mut source).unwrap(), + vec![ + Event::Key(KeyEvent::new(KeyCode::Char('_'), KeyModifiers::ALT)), + key(KeyCode::Char('q')), + ] + ); + } + + #[test] + fn unconfirmed_kitty_reply_replays_every_event() { + let alt = Event::Key(KeyEvent::new(KeyCode::Char('_'), KeyModifiers::ALT)); + let terminator = Event::Key(KeyEvent::new(KeyCode::Char('\\'), KeyModifiers::ALT)); + for interruptible in [false, true] { + for payload in ["G", "Gi=", "Gi=31;OK", "Gi=x;OK", "Gi=31;"] { + let mut items = vec![Some(alt.clone())]; + items.extend(payload.chars().map(ch)); + if matches!(payload, "Gi=x;OK" | "Gi=31;") { + items.push(Some(terminator.clone())); + } else { + items.push(None); + } + let expected: Vec<_> = items.iter().flatten().cloned().collect(); + let mut source = Scripted::new(items); + let mut received = read_scripted(&mut source, interruptible); + while source.0.iter().any(Option::is_some) { + received.extend(read_scripted(&mut source, interruptible)); + } + assert_eq!(received, expected, "payload: {payload}"); + } + } + } + + #[test] + fn kitty_probe_preserves_non_payload_events() { + let alt = Event::Key(KeyEvent::new(KeyCode::Char('_'), KeyModifiers::ALT)); + for next in [ + Event::FocusGained, + Event::Resize(80, 24), + Event::Paste("paste".into()), + key(KeyCode::Enter), + ] { + let mut items = vec![Some(alt.clone())]; + items.extend("Gi=31;".chars().map(ch)); + items.push(Some(next)); + let expected: Vec<_> = items.iter().flatten().cloned().collect(); + assert_eq!(next_events(&mut Scripted::new(items)).unwrap(), expected); + } + } + #[test] + fn kitty_success_and_error_replies_accept_both_terminator_encodings() { + for interruptible in [false, true] { + for split_terminator in [false, true] { + for payload in [ + "Gi=31;OK", + "Gi=4294967295,p=2;ENOENT: image not found", + "GI=42,i=99;OK", + ] { + let mut items = vec![Some(Event::Key(KeyEvent::new( + KeyCode::Char('_'), + KeyModifiers::ALT, + )))]; + items.extend(payload.chars().map(ch)); + if split_terminator { + items.extend([Some(key(KeyCode::Esc)), ch('\\')]); + } else { + items.push(Some(Event::Key(KeyEvent::new( + KeyCode::Char('\\'), + KeyModifiers::ALT, + )))); + } + items.push(ch('q')); + let mut source = Scripted::new(items); + assert!(read_scripted(&mut source, interruptible).is_empty()); + assert_eq!( + read_scripted(&mut source, interruptible), + vec![key(KeyCode::Char('q'))] + ); + } + } + } + } + + #[test] + fn kitty_reply_validation_rejects_non_reply_metadata() { + for payload in [ + "G;OK", + "Gi=0;OK", + "Gi=4294967296;OK", + "Gi=-1;OK", + "Gi=1,p=x;OK", + "Gp=1;OK", + "Gi=1,a=t;OK", + "Gi=1;", + "Gi=1;OK\n", + ] { + assert!(!is_kitty_reply(payload), "{payload:?}"); + } + } + + #[test] + fn oversized_kitty_candidate_is_bounded_and_replayed() { + let mut items = vec![Some(Event::Key(KeyEvent::new( + KeyCode::Char('_'), + KeyModifiers::ALT, + )))]; + let payload = format!("Gi=31;{}", "x".repeat(MAX_KITTY_REPLY_BYTES)); + items.extend(payload.chars().map(ch)); + let expected: Vec<_> = items.iter().flatten().cloned().collect(); + let mut source = Scripted::new(items); + let mut received = next_events(&mut source).unwrap(); + assert_eq!(received.len(), MAX_KITTY_REPLY_BYTES + 1); + while !source.0.is_empty() { + received.extend(next_events(&mut source).unwrap()); + } + assert_eq!(received, expected); + } + + #[test] + fn alt_underscore_uses_only_the_short_initial_probe() { + struct TimedSource(Vec); + impl EventSource for TimedSource { + fn next(&mut self, timeout: Duration) -> io::Result> { + self.0.push(timeout); + Ok(None) + } + } + let alt = KeyEvent::new(KeyCode::Char('_'), KeyModifiers::ALT); + let mut source = TimedSource(Vec::new()); + assert_eq!( + resolve_first_event(&mut source, Event::Key(alt)).unwrap(), + vec![Event::Key(alt)] + ); + assert_eq!(source.0, vec![INTRODUCER_PROBE]); + } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 7106f7f..361b778 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -292,7 +292,10 @@ struct DiffPreviewCache { } impl DiffPreviewCache { - fn insert(&mut self, key: String, view: DiffViewState) { + fn insert(&mut self, key: String, mut view: DiffViewState) { + for image in view.inline_images.values_mut() { + image.unload_for_cache(); + } self.remove(&key); let estimated_bytes = estimate_diff_view_bytes(&view); if estimated_bytes > MAX_CACHED_DIFF_BYTES { @@ -380,6 +383,17 @@ fn estimate_diff_view_bytes(view: &DiffViewState) -> usize { .saturating_add(view.new_content.len()) .saturating_add(line_bytes) .saturating_mul(2) + .saturating_add( + view.inline_images + .values() + .map(|image| image.estimated_bytes()) + .sum::(), + ) + .saturating_add( + view.image_preview + .as_ref() + .map_or(0, |p| p.estimated_bytes()), + ) } type BackgroundJob = Box; @@ -866,6 +880,24 @@ fn parse_commit_file_diff_payload( current_path: &str, diff: &str, ) -> DiffPayload { + let paths = crate::git::diff::diff_paths_for_label(name); + let parent = format!("{hash}^1"); + if let Some(parsed) = image_diff_payload( + git, + name, + diff, + false, + crate::pager::image_preview::ImageSource::Revision { + revision: &parent, + path: paths[0], + }, + crate::pager::image_preview::ImageSource::Revision { + revision: hash, + path: current_path, + }, + ) { + return DiffPayload::Parsed(parsed); + } if is_rename_only_diff(diff) { if let Ok(content) = git.file_content_at_commit(hash, current_path) { if !content.is_empty() { @@ -882,6 +914,41 @@ fn parse_commit_file_diff_payload( DiffPayload::Parsed(DiffViewState::parse_diff_output(name, diff, 4, false)) } +/// Only single raster-file selections are previewed; multi-file buffers retain +/// their ordinary binary placeholders. Decoding validates the actual bytes. +fn image_diff_payload( + git: &GitCommands, + name: &str, + diff: &str, + exists: bool, + old: crate::pager::image_preview::ImageSource<'_>, + new: crate::pager::image_preview::ImageSource<'_>, +) -> Option { + let paths = crate::git::diff::diff_paths_for_label(name); + if !paths + .iter() + .any(|p| crate::pager::image_preview::is_image_path(p)) + { + return None; + } + let preview = crate::pager::image_preview::load(git, old, new)?; + // A rename-only image has no binary marker, but must still be a nonempty + // diff view without text hunks (image content cannot be staged by hunk). + let placeholder = synthesize_binary_file_diff(name); + let mut parsed = DiffViewState::parse_diff_output( + name, + if diff.is_empty() || is_rename_only_diff(diff) { + &placeholder + } else { + diff + }, + 4, + exists, + ); + parsed.image_preview = Some(preview); + Some(parsed) +} + impl Gui { fn show_error(&mut self, title: &str, err: anyhow::Error) { self.popup = PopupState::Message { @@ -1117,6 +1184,9 @@ impl Gui { let mut keyboard_enhanced = keyboard_enhanced; let result = self.main_loop(&mut terminal, &input, &mut keyboard_enhanced); + self.diff_preview_cache.clear(); + self.diff_view.reset_keep_prefs(); + self.displayed_diff_key.clear(); restore_terminal(&mut terminal, keyboard_enhanced)?; result } @@ -1154,6 +1224,13 @@ impl Gui { // event reader is process-wide and would steal hx/nvim's keystrokes. input.pause(); input.drain(); + // Dropping the cached protocols queues scoped deletes and ensures they + // are freshly transmitted after returning from another alternate screen. + self.diff_preview_cache.clear(); + self.diff_view.reset_keep_prefs(); + self.displayed_diff_key.clear(); + self.needs_diff_refresh = true; + crate::pager::image_preview::flush_cleanup(terminal.backend_mut()); restore_terminal(terminal, *keyboard_enhanced)?; // LeaveAlternateScreen restores the previous buffer; wipe the primary @@ -1354,6 +1431,18 @@ impl Gui { // Render let theme = self.active_theme(); + crate::pager::image_preview::flush_cleanup(terminal.backend_mut()); + self.diff_view.image_preview_hidden = self.popup != PopupState::None + || (self.show_command_log + && self + .diff_view + .image_preview + .as_ref() + .is_some_and(|p| !p.uses_placeholders())) + || self.ai_commit_generation_active() + || self.remote_op_label.is_some() + || self.rebase_mode.active + || self.patch_building.active; terminal.draw(|frame| { if self.rebase_mode.active { presentation::rebase_mode::render(frame, &mut self.rebase_mode, &theme); @@ -2933,6 +3022,7 @@ impl Gui { let has_staged = file.has_staged_changes; let has_unstaged = file.has_unstaged_changes; let tracked = file.tracked; + let conflicted = file.has_merge_conflicts; drop(model); let git = Arc::clone(&self.git); @@ -2941,6 +3031,39 @@ impl Gui { self.diff_loading_since = Some(Instant::now()); self.queue_diff_job(generation, diff_key, move || { let path_refs: Vec<&str> = diff_paths.iter().map(String::as_str).collect(); + use crate::pager::image_preview::ImageSource; + let old_path = path_refs[0]; + let old = if !tracked { + ImageSource::Missing + } else { + ImageSource::Revision { + revision: if has_staged { "HEAD" } else { "" }, + path: old_path, + } + }; + let new = if has_staged && !has_unstaged { + ImageSource::Revision { + revision: "", + path: ¤t_path, + } + } else { + ImageSource::Worktree(¤t_path) + }; + if let Some(parsed) = (!conflicted) + .then(|| { + image_diff_payload( + &git, + &name, + "", + git.repo_path().join(¤t_path).exists(), + old, + new, + ) + }) + .flatten() + { + return DiffPayload::Parsed(parsed); + } // Single HEAD buffer with coherent line numbers; // hunks are dimmed/tinted staged vs unstaged via // overlap with the unstaged diff. Falls back to the @@ -3051,6 +3174,13 @@ impl Gui { .filter(|f| !f.tracked) .map(|f| f.current_path().to_string()) .collect(); + let excluded: std::collections::HashSet = node + .child_file_indices + .iter() + .filter_map(|&i| model.files.get(i)) + .filter(|f| f.has_merge_conflicts) + .flat_map(|f| f.diff_paths().into_iter().map(str::to_string)) + .collect(); let pathspec = pathspec_for_tree_path(&node.path); let dir_name = node.name.clone(); drop(model); @@ -3119,6 +3249,14 @@ impl Gui { if parsed.hunk_staged.len() != parsed.hunk_starts.len() { parsed.hunk_staged = vec![false; parsed.hunk_starts.len()]; } + crate::pager::image_preview::attach_inline_image_previews( + &mut parsed, + git.repo_path(), + &combined_diff, + "HEAD", + None, + &excluded, + ); if parsed.lines.is_empty() { DiffPayload::Empty } else { @@ -3275,12 +3413,22 @@ impl Gui { if combined_diff.is_empty() { DiffPayload::Empty } else { - DiffPayload::Parsed(DiffViewState::parse_diff_output( + let mut parsed = DiffViewState::parse_diff_output( &dir_name, &combined_diff, 4, true, - )) + ); + let parent = format!("{hash}^1"); + crate::pager::image_preview::attach_inline_image_previews( + &mut parsed, + git.repo_path(), + &combined_diff, + &parent, + Some(&hash), + &HashSet::new(), + ); + DiffPayload::Parsed(parsed) } }); } else { @@ -9080,6 +9228,8 @@ fn setup_terminal() -> Result<(Term, bool)> { crossterm::event::EnableBracketedPaste, cursor::Hide )?; + // Detect graphics before Crossterm creates its internal response reader. + crate::pager::image_preview::initialize(); // Helix leaves progressive kitty keyboard enhancement enabled across // `:insert-output` and keeps a `/dev/tty` EventStream open. Probing races // that reader (blank hang); instead pop leftover stacks and push our flags @@ -9109,6 +9259,7 @@ fn setup_terminal() -> Result<(Term, bool)> { /// process-wide mutex that the input thread holds for the duration of its /// blocking read, so any drain from this thread would silently no-op. fn restore_terminal(terminal: &mut Term, keyboard_enhanced: bool) -> Result<()> { + crate::pager::image_preview::flush_cleanup(terminal.backend_mut()); // Helix `:insert-output` keeps its own alt-screen / raw mode / mouse / focus // / bracketed-paste / kitty stack across the child. If we tear those down // here, Helix resumes drawing into a world that no longer exists and the diff --git a/src/pager/image_preview.rs b/src/pager/image_preview.rs new file mode 100644 index 0000000..d597ca5 --- /dev/null +++ b/src/pager/image_preview.rs @@ -0,0 +1,2499 @@ +//! Before/after image preview for binary diffs. +//! +//! Draws a centered, aspect-preserving image per pane inside the diff panel. +//! The caller owns the outer border/title and hides the preview under +//! overlays via `image_preview_hidden`. Only scoped Kitty deletes are +//! supported here (`ImagePreview::cleanup`); Sixel/iTerm2 have no scoped +//! delete in ratatui-image 8.0.1 and persist once drawn. +//! +//! Private modules own capability probing, bounded source loading, and inline +//! diff eligibility. This module owns rendering and graphics lifetimes. + +use std::io::Write; +use std::path::Path; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +use image::ImageFormat; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui_image::picker::{Picker, ProtocolType}; +use ratatui_image::protocol::StatefulProtocolType; +use ratatui_image::thread::{ResizeRequest, ResizeResponse, ThreadProtocol}; +use ratatui_image::{Resize, ResizeEncodeRender, StatefulImage}; + +mod capabilities; +mod inline_diff; +mod source; + +use capabilities::global_picker_ref; +pub use capabilities::initialize; +pub(crate) use inline_diff::{attach_inline_image_previews, is_image_path}; +pub use source::ImageSource; +use source::{DecodedImage, format_label_for, load_one_side}; + +use super::side_by_side::DiffSideView; +use crate::config::Theme; + +static PENDING_DELETES: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Flush scoped deletes on the UI thread, never on decoding/resize workers. +pub fn flush_cleanup(out: &mut dyn Write) { + if let Ok(mut pending) = PENDING_DELETES.lock() { + for id in pending.drain(..) { + let _ = write!(out, "\x1b_Ga=d,d=I,i={id}\x1b\\"); + } + } + let _ = out.flush(); +} + +struct PreviewSide { + width: u32, + height: u32, + orig_width: u32, + orig_height: u32, + format: Option, + thread: ThreadProtocol, + rx: Receiver, + created: Instant, + // Stored before the protocol moves into the worker so scoped Kitty + // deletes stay possible while a resize is inflight. + kitty_id: Option, +} + +impl PreviewSide { + fn new(picker: &Picker, decoded: DecodedImage) -> Self { + let (tx_req, rx_req) = mpsc::channel::(); + let (tx_resp, rx_resp) = mpsc::channel::(); + // Move the image into the protocol: no retained clone, so memory and + // cache accounting stay single-copy. + let width = decoded.image.width(); + let height = decoded.image.height(); + let orig_width = decoded.orig_width; + let orig_height = decoded.orig_height; + let format = decoded.format; + let proto = picker.new_resize_protocol(decoded.image); + let kitty_id = match proto.protocol_type() { + StatefulProtocolType::Kitty(k) => Some(k.unique_id), + _ => None, + }; + let thread = ThreadProtocol::new(tx_req, Some(proto)); + // Worker only resize+encodes (CPU); it never touches the terminal. + // It exits when the preview is dropped (sender disconnect). + std::thread::Builder::new() + .name("lazygitrs-image-preview".into()) + .spawn(move || { + while let Ok(req) = rx_req.recv() { + match req.resize_encode() { + Ok(resp) => { + let _ = tx_resp.send(resp); + } + Err(_) => { + // Encode failed; the protocol is consumed by the + // ratatui-image API. The UI shows a fallback + // message instead of hanging (see `render_side`). + } + } + } + }) + .ok(); + Self { + width, + height, + orig_width, + orig_height, + format, + thread, + rx: rx_resp, + created: Instant::now(), + kitty_id, + } + } + + fn poll_completed(&mut self) { + while let Ok(resp) = self.rx.try_recv() { + let _ = self.thread.update_resized_protocol(resp); + } + } + + fn estimated_bytes(&self) -> usize { + // Only the thumbnail and its encoded payload are retained. + let dec = (self.width as usize) * (self.height as usize) * 4; + dec + dec * 4 / 3 + 1024 + } + + fn format_label(&self) -> &'static str { + format_label_for(self.format) + } +} + +/// Before/after image preview. `Send` so the diff loader can move it across +/// threads; rendering stays on the UI thread. Drop queues scoped deletes; +/// the UI flushes them without any worker performing terminal I/O. +pub struct ImagePreview { + old: Option, + new: Option, + font_size: (u16, u16), +} + +impl Drop for ImagePreview { + fn drop(&mut self) { + if let Ok(mut pending) = PENDING_DELETES.lock() { + pending.extend( + [&self.old, &self.new] + .into_iter() + .flatten() + .filter_map(|s| s.kitty_id), + ); + } + } +} + +const _: fn() = || { + fn assert_send() {} + assert_send::(); +}; + +impl ImagePreview { + /// Load both sides with the global picker. `None` when graphics are + /// unavailable, both sides are absent, or any present side fails. + pub fn load( + git: &crate::git::GitCommands, + old: ImageSource<'_>, + new: ImageSource<'_>, + ) -> Option { + let picker = global_picker_ref().cloned()?; + Self::load_with_picker(git, old, new, &picker) + } + + /// Picker-injecting load (tests use this, never the global). + pub(crate) fn load_with_picker( + git: &crate::git::GitCommands, + old: ImageSource<'_>, + new: ImageSource<'_>, + picker: &Picker, + ) -> Option { + if picker.protocol_type() == ProtocolType::Halfblocks { + return None; + } + let repo = git.repo_path(); + let old_dec = load_one_side(repo, old)?; + let new_dec = load_one_side(repo, new)?; + Self::from_decoded(old_dec, new_dec, picker) + } + + /// Build from already-decoded sides. `None` when both are `None` or the + /// picker is Halfblocks. + pub(crate) fn from_decoded( + old: Option, + new: Option, + picker: &Picker, + ) -> Option { + if picker.protocol_type() == ProtocolType::Halfblocks { + return None; + } + if old.is_none() && new.is_none() { + return None; + } + let font_size = picker.font_size(); + Some(Self { + old: old.map(|d| PreviewSide::new(picker, d)), + new: new.map(|d| PreviewSide::new(picker, d)), + font_size, + }) + } + + /// Approx memory held: decoded + encoded estimates for both sides. + pub fn estimated_bytes(&self) -> usize { + let mut n = 0usize; + if let Some(s) = &self.old { + n += s.estimated_bytes(); + } + if let Some(s) = &self.new { + n += s.estimated_bytes(); + } + n + std::mem::size_of::() + } + + pub fn uses_placeholders(&self) -> bool { + [&self.old, &self.new] + .into_iter() + .flatten() + .all(|side| side.kitty_id.is_some()) + } + + /// Scoped cleanup: deletes only this preview's Kitty image ids. Safe on + /// any output; non-Kitty sides are ignored. Never issues a global + /// delete-all. Call on the UI thread (never from a worker). + #[cfg(test)] + fn cleanup(&self, out: &mut dyn Write) { + for id in [&self.old, &self.new] + .into_iter() + .flatten() + .filter_map(|s| s.kitty_id) + { + let _ = write!(out, "\x1b_Ga=d,d=I,i={id}\x1b\\"); + } + let _ = out.flush(); + } +} + +/// Free-function load (forwards to [`ImagePreview::load`]). +pub fn load( + git: &crate::git::GitCommands, + old: ImageSource<'_>, + new: ImageSource<'_>, +) -> Option { + ImagePreview::load(git, old, new) +} + +/// Split `area` into `(old_pane, new_pane)` honoring `side`. +pub(crate) fn split_preview_area(area: Rect, side: DiffSideView) -> (Option, Option) { + if area.width == 0 || area.height == 0 { + return (None, None); + } + match side { + DiffSideView::OldOnly => (Some(area), None), + DiffSideView::NewOnly => (None, Some(area)), + DiffSideView::Both => { + if area.width < 2 { + return (Some(area), None); + } + let left_w = area.width / 2; + let right_w = area.width - left_w; + ( + Some(Rect::new(area.x, area.y, left_w, area.height)), + Some(Rect::new(area.x + left_w, area.y, right_w, area.height)), + ) + } + } +} + +/// Center `inner` inside `outer`. +pub(crate) fn centered_rect(outer: Rect, inner_w: u16, inner_h: u16) -> Rect { + let w = inner_w.min(outer.width); + let h = inner_h.min(outer.height); + let x = outer.x + outer.width.saturating_sub(w) / 2; + let y = outer.y + outer.height.saturating_sub(h) / 2; + Rect::new(x, y, w, h) +} + +/// Cell-space fit preserving aspect, given decoded pixels + font size. +pub(crate) fn fit_cells( + img_w: u32, + img_h: u32, + font: (u16, u16), + pane_w: u16, + pane_h: u16, +) -> (u16, u16) { + if img_w == 0 || img_h == 0 || font.0 == 0 || font.1 == 0 || pane_w == 0 || pane_h == 0 { + return (pane_w.min(1), pane_h.min(1)); + } + let max_px_w = pane_w as u64 * font.0 as u64; + let max_px_h = pane_h as u64 * font.1 as u64; + let scale = (max_px_w as f64 / img_w as f64).min(max_px_h as f64 / img_h as f64); + let scale = scale.min(1.0); + let fit_px_w = ((img_w as f64 * scale).round() as u64).max(1); + let fit_px_h = ((img_h as f64 * scale).round() as u64).max(1); + let cells_w = ((fit_px_w + font.0 as u64 - 1) / font.0 as u64).min(pane_w as u64) as u16; + let cells_h = ((fit_px_h + font.1 as u64 - 1) / font.1 as u64).min(pane_h as u64) as u16; + (cells_w.max(1), cells_h.max(1)) +} + +/// Draw the image body (outer border/title owned by the caller). +pub fn render( + frame: &mut Frame, + area: Rect, + preview: &mut ImagePreview, + side: DiffSideView, + theme: &Theme, +) { + if area.width == 0 || area.height == 0 { + return; + } + if let Some(s) = preview.old.as_mut() { + s.poll_completed(); + } + if let Some(s) = preview.new.as_mut() { + s.poll_completed(); + } + + let added = preview.old.is_none() && preview.new.is_some(); + let deleted = preview.new.is_none() && preview.old.is_some(); + // A missing version isn't a comparison: let the available image use the + // full pane, just like added text files. Explicit side controls still win. + let effective_side = match (side, added, deleted) { + (DiffSideView::Both, true, _) => DiffSideView::NewOnly, + (DiffSideView::Both, _, true) => DiffSideView::OldOnly, + _ => side, + }; + let (old_area, new_area) = split_preview_area(area, effective_side); + if let Some(a) = old_area { + render_side( + frame, + a, + preview.old.as_mut(), + true, + if deleted { "Deleted" } else { "Before" }, + preview.font_size, + theme, + ); + } + if let Some(a) = new_area { + render_side( + frame, + a, + preview.new.as_mut(), + false, + if added { "Added" } else { "After" }, + preview.font_size, + theme, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn render_side( + frame: &mut Frame, + pane: Rect, + side: Option<&mut PreviewSide>, + is_old: bool, + title: &str, + font_size: (u16, u16), + theme: &Theme, +) { + if pane.width == 0 || pane.height == 0 { + return; + } + + let Some(state) = side else { + let status = if is_old { "Added" } else { "Deleted" }; + let header = Line::from(vec![ + Span::styled(format!("{title} "), Style::default().fg(theme.text_strong)), + Span::styled("Missing", Style::default().fg(theme.text_dimmed)), + Span::styled( + format!(" · {status}"), + Style::default().fg(theme.accent_secondary), + ), + ]); + if pane.height >= 1 { + frame.render_widget( + Paragraph::new(header), + Rect::new(pane.x, pane.y, pane.width, 1), + ); + } + if pane.height >= 3 { + let msg = Paragraph::new(Line::from(Span::styled( + "—", + Style::default().fg(theme.text_dimmed), + ))); + let centered = Rect::new( + pane.x, + pane.y + pane.height / 2, + pane.width, + 1.min(pane.height.saturating_sub(1)), + ); + frame.render_widget(msg, centered); + } + return; + }; + + let dims = format!("{}x{}", state.orig_width, state.orig_height); + let fmt = state.format_label(); + let header = Line::from(vec![ + Span::styled(format!("{title} "), Style::default().fg(theme.text_strong)), + Span::styled(dims, Style::default().fg(theme.text)), + Span::styled(format!(" · {fmt}"), Style::default().fg(theme.text_dimmed)), + ]); + let header_h: u16 = 1; + if pane.height <= header_h { + frame.render_widget(Paragraph::new(header), pane); + return; + } + frame.render_widget( + Paragraph::new(header), + Rect::new(pane.x, pane.y, pane.width, header_h), + ); + let img_area = Rect::new( + pane.x, + pane.y + header_h, + pane.width, + pane.height.saturating_sub(header_h), + ); + if img_area.width == 0 || img_area.height == 0 { + return; + } + + let fitted: Option = state + .thread + .size_for(Resize::Fit(None), img_area) + .map(|r| centered_rect(img_area, r.width, r.height)) + .or_else(|| { + let (cw, ch) = fit_cells( + state.width, + state.height, + font_size, + img_area.width, + img_area.height, + ); + Some(centered_rect(img_area, cw, ch)) + }); + let Some(dst) = fitted else { + return; + }; + if dst.width == 0 || dst.height == 0 { + return; + } + + // Pending first encode: brief Loading, then a bounded fallback message. + // Encode failures drop the protocol (ratatui-image API), so this never + // blocks; it just shows the message. + let pending = state.thread.protocol_type().is_none(); + if pending { + let waiting = state.created.elapsed(); + let label = if waiting > Duration::from_secs(2) { + "Preview unavailable" + } else { + "Loading…" + }; + let w = (label.chars().count() as u16).min(dst.width).max(1); + let msg_area = centered_rect(dst, w, 1); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + label, + Style::default().fg(theme.text_dimmed), + ))), + msg_area, + ); + state.thread.resize_encode(&Resize::Fit(None), dst); + frame.render_stateful_widget( + StatefulImage::new().resize(Resize::Fit(None)), + dst, + &mut state.thread, + ); + return; + } + + frame.render_stateful_widget( + StatefulImage::new().resize(Resize::Fit(None)), + dst, + &mut state.thread, + ); +} + +/// Fixed scroll height of an inline image section, including labels. +pub const INLINE_IMAGE_ROWS: usize = 12; +const MAX_INLINE_LOADS: usize = 2; +static INLINE_LOADS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Owned source identity; delayed loads must not borrow a diff job's buffers. +#[derive(Debug, Clone, PartialEq, Eq)] +enum InlineImageSource { + Worktree(String), + Revision { revision: String, path: String }, + Missing, +} + +impl InlineImageSource { + fn borrowed(&self) -> ImageSource<'_> { + match self { + Self::Worktree(path) => ImageSource::Worktree(path), + Self::Revision { revision, path } => ImageSource::Revision { revision, path }, + Self::Missing => ImageSource::Missing, + } + } +} + +enum InlineLoadState { + Idle, + Loading(Receiver>), + Ready(ImagePreview), + Unavailable, +} + +/// Lazy, viewport-driven image state for one file in a multi-file diff. +/// Offscreen sections keep only source metadata; at most two decodes run at once. +pub struct InlineImagePreview { + repo: std::path::PathBuf, + old: InlineImageSource, + new: InlineImageSource, + picker: Picker, + state: InlineLoadState, +} + +impl InlineImagePreview { + fn new(repo: &Path, old: InlineImageSource, new: InlineImageSource) -> Option { + let picker = global_picker_ref()?.clone(); + // Inline overlays need placeholder semantics to clear individual rows + // without erasing adjacent text. Experimental Sixel/iTerm2 stay striped. + if picker.protocol_type() != ProtocolType::Kitty { + return None; + } + Some(Self::with_picker(repo, old, new, picker)) + } + + fn with_picker( + repo: &Path, + old: InlineImageSource, + new: InlineImageSource, + picker: Picker, + ) -> Self { + Self { + repo: repo.to_path_buf(), + old, + new, + picker, + state: InlineLoadState::Idle, + } + } + + /// Release pixel/protocol memory when this section leaves the viewport. + pub fn unload(&mut self) { + if let InlineLoadState::Loading(rx) = &self.state { + match rx.try_recv() { + Err(mpsc::TryRecvError::Empty) => return, // Keep a pending job; don't duplicate it. + Ok(None) => { + self.state = InlineLoadState::Unavailable; + return; + } + _ => {} // Discard offscreen pixels, including a completed result. + } + } + if !matches!(self.state, InlineLoadState::Unavailable) { + self.state = InlineLoadState::Idle; + } + } + + /// Cached folders retain identities, not image pixels or in-flight results. + pub fn unload_for_cache(&mut self) { + if !matches!(self.state, InlineLoadState::Unavailable) { + self.state = InlineLoadState::Idle; + } + } + + #[cfg(test)] + pub(crate) fn from_preview_for_test(preview: ImagePreview) -> Self { + let mut picker = Picker::from_fontsize((8, 16)); + picker.set_protocol_type(ProtocolType::Kitty); + let mut inline = Self::with_picker( + Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker, + ); + inline.state = InlineLoadState::Ready(preview); + inline + } + + pub fn estimated_bytes(&self) -> usize { + let pixels = match &self.state { + InlineLoadState::Ready(preview) => preview.estimated_bytes(), + _ => 0, + }; + pixels + + std::mem::size_of::() + + self.repo.as_os_str().len() + + source_bytes(&self.old) + + source_bytes(&self.new) + } + + pub fn render(&mut self, frame: &mut Frame, area: Rect, side: DiffSideView, theme: &Theme) { + if let InlineLoadState::Loading(rx) = &self.state { + match rx.try_recv() { + Ok(Some(preview)) => self.state = InlineLoadState::Ready(preview), + Ok(None) | Err(mpsc::TryRecvError::Disconnected) => { + self.state = InlineLoadState::Unavailable + } + Err(mpsc::TryRecvError::Empty) => {} + } + } + if matches!(self.state, InlineLoadState::Idle) && area.height > 1 { + self.start_load(); + } + match &mut self.state { + InlineLoadState::Ready(preview) => render(frame, area, preview, side, theme), + InlineLoadState::Unavailable => { + inline_placeholder(frame, area, "Binary cannot be previewed", theme) + } + _ => inline_placeholder(frame, area, "Loading image…", theme), + } + } + + fn start_load(&mut self) { + use std::sync::atomic::Ordering; + if INLINE_LOADS + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| { + (n < MAX_INLINE_LOADS).then_some(n + 1) + }) + .is_err() + { + return; + } + struct LoadPermit; + impl Drop for LoadPermit { + fn drop(&mut self) { + INLINE_LOADS.fetch_sub(1, Ordering::AcqRel); + } + } + let permit = LoadPermit; + let repo = self.repo.clone(); + let old = self.old.clone(); + let new = self.new.clone(); + let picker = self.picker.clone(); + let (tx, rx) = mpsc::channel(); + match std::thread::Builder::new() + .name("inline-image-load".into()) + .spawn(move || { + let _permit = permit; + let preview = (|| { + let old = load_one_side(&repo, old.borrowed())?; + let new = load_one_side(&repo, new.borrowed())?; + ImagePreview::from_decoded(old, new, &picker) + })(); + let _ = tx.send(preview); + }) { + Ok(_) => self.state = InlineLoadState::Loading(rx), + Err(_) => self.state = InlineLoadState::Unavailable, + } + } +} + +fn source_bytes(source: &InlineImageSource) -> usize { + match source { + InlineImageSource::Worktree(path) => path.len(), + InlineImageSource::Revision { revision, path } => revision.len() + path.len(), + InlineImageSource::Missing => 0, + } +} + +fn inline_placeholder(frame: &mut Frame, area: Rect, message: &str, theme: &Theme) { + let style = Style::default().fg(theme.text_dimmed); + frame.render_widget( + Paragraph::new("╱".repeat(area.width as usize)).style(style), + area, + ); + if area.height > 0 { + let label = Rect::new(area.x, area.y + area.height / 2, area.width, 1); + frame.render_widget( + Paragraph::new(message) + .alignment(ratatui::layout::Alignment::Center) + .style(style), + label, + ); + } +} + +#[cfg(test)] +mod tests { + use super::source::*; + use super::*; + use image::DynamicImage; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use std::path::PathBuf; + + fn halfblocks_picker() -> Picker { + let mut p = Picker::from_fontsize((8, 16)); + p.set_protocol_type(ProtocolType::Halfblocks); + p + } + + fn graphics_picker() -> Picker { + let mut p = Picker::from_fontsize((8, 16)); + p.set_protocol_type(ProtocolType::Kitty); + p + } + + fn png_bytes(w: u32, h: u32) -> Vec { + let img = DynamicImage::ImageRgb8(image::ImageBuffer::from_pixel( + w, + h, + image::Rgb([200, 30, 30]), + )); + let mut buf = Vec::new(); + let mut cur = std::io::Cursor::new(&mut buf); + img.write_to(&mut cur, ImageFormat::Png).unwrap(); + buf + } + + fn jpeg_bytes(w: u32, h: u32) -> Vec { + let img = DynamicImage::ImageRgb8(image::ImageBuffer::from_pixel( + w, + h, + image::Rgb([30, 200, 30]), + )); + let mut buf = Vec::new(); + let mut cur = std::io::Cursor::new(&mut buf); + img.write_to(&mut cur, ImageFormat::Jpeg).unwrap(); + buf + } + + fn gif_bytes() -> Vec { + vec![ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, + 0x3b, + ] + } + + #[test] + fn decode_png_detects_type_and_dims() { + let b = png_bytes(4, 3); + let d = decode_bytes(&b).expect("png decodes"); + assert_eq!((d.width(), d.height()), (4, 3)); + assert_eq!(d.format, Some(ImageFormat::Png)); + assert_eq!(d.format_label(), "PNG"); + } + + #[test] + fn decode_jpeg_detects_type() { + let b = jpeg_bytes(6, 5); + let d = decode_bytes(&b).expect("jpeg decodes"); + assert_eq!(d.format, Some(ImageFormat::Jpeg)); + assert_eq!(d.format_label(), "JPEG"); + } + + #[test] + fn decode_gif_detects_type() { + let d = decode_bytes(&gif_bytes()).expect("gif decodes"); + assert_eq!(d.format, Some(ImageFormat::Gif)); + assert_eq!(d.format_label(), "GIF"); + } + + #[test] + fn decode_garbage_returns_none() { + assert!(decode_bytes(b"not an image at all").is_none()); + assert!(decode_bytes(&[]).is_none()); + assert!(decode_bytes(&[0u8; 16]).is_none()); + } + + #[test] + fn decode_rejects_oversize_compressed() { + let big = vec![0u8; MAX_COMPRESSED_BYTES + 1]; + assert!(decode_bytes(&big).is_none()); + } + + #[test] + fn downsize_preserves_aspect_within_box() { + let b = png_bytes(3000, 2000); + let d = decode_bytes(&b).expect("large png decodes"); + assert_eq!((d.orig_width, d.orig_height), (3000, 2000)); + assert!(d.width() <= PREVIEW_MAX_WIDTH && d.height() <= PREVIEW_MAX_HEIGHT); + let r = d.width() as f64 / d.height() as f64; + assert!((r - 1.5).abs() < 0.05, "aspect kept, got {r}"); + } + + #[test] + fn small_images_are_not_upscaled() { + let b = png_bytes(8, 6); + let d = decode_bytes(&b).unwrap(); + assert_eq!((d.width(), d.height()), (8, 6)); + } + + #[test] + fn format_labels_cover_contract() { + assert_eq!(format_label_for(Some(ImageFormat::Png)), "PNG"); + assert_eq!(format_label_for(Some(ImageFormat::Jpeg)), "JPEG"); + assert_eq!(format_label_for(Some(ImageFormat::Gif)), "GIF"); + assert_eq!(format_label_for(Some(ImageFormat::WebP)), "WEBP"); + assert_eq!(format_label_for(Some(ImageFormat::Bmp)), "BMP"); + assert_eq!(format_label_for(Some(ImageFormat::Ico)), "ICO"); + assert_eq!(format_label_for(Some(ImageFormat::Tiff)), "TIFF"); + assert_eq!(format_label_for(None), "IMG"); + } + + #[test] + fn split_layout_honors_side_view() { + let area = Rect::new(0, 0, 100, 20); + let (o, n) = split_preview_area(area, DiffSideView::Both); + let (o, n) = (o.unwrap(), n.unwrap()); + assert_eq!(o.width + n.width, 100); + assert_eq!((o.x, o.y, o.height), (0, 0, 20)); + assert_eq!((n.x, n.y, n.height), (50, 0, 20)); + + let (o, n) = split_preview_area(area, DiffSideView::OldOnly); + assert!(o.is_some() && n.is_none()); + let (o, n) = split_preview_area(area, DiffSideView::NewOnly); + assert!(o.is_none() && n.is_some()); + + let empty = Rect::new(0, 0, 0, 0); + assert_eq!(split_preview_area(empty, DiffSideView::Both), (None, None)); + } + + #[test] + fn centered_rect_is_centered_and_clamped() { + let outer = Rect::new(10, 5, 20, 10); + let c = centered_rect(outer, 8, 4); + assert_eq!((c.x, c.y, c.width, c.height), (16, 8, 8, 4)); + let big = centered_rect(outer, 100, 100); + assert_eq!((big.x, big.y, big.width, big.height), (10, 5, 20, 10)); + } + + #[test] + fn fit_cells_preserves_aspect_and_clamps() { + let (w, h) = fit_cells(160, 120, (8, 16), 20, 10); + assert!(w <= 20 && h <= 10 && w >= 1 && h >= 1); + let (w2, h2) = fit_cells(8, 8, (8, 16), 40, 20); + assert_eq!((w2, h2), (1, 1)); + assert_eq!(fit_cells(0, 0, (8, 16), 10, 10), (1, 1)); + } + + #[test] + fn from_decoded_needs_picker_and_a_side() { + let proto = graphics_picker(); + let d = decode_bytes(&png_bytes(4, 4)).unwrap(); + assert!(ImagePreview::from_decoded(None, None, &proto).is_none()); + assert!(ImagePreview::from_decoded(Some(d.clone()), None, &proto).is_some()); + assert!(ImagePreview::from_decoded(None, Some(d), &proto).is_some()); + let half = halfblocks_picker(); + let d2 = decode_bytes(&png_bytes(4, 4)).unwrap(); + assert!(ImagePreview::from_decoded(Some(d2), None, &half).is_none()); + } + + #[test] + fn estimated_bytes_counts_decoded_plus_encoded() { + let proto = graphics_picker(); + let d = decode_bytes(&png_bytes(8, 8)).unwrap(); + let p = ImagePreview::from_decoded(Some(d), None, &proto).unwrap(); + let dec = 8usize * 8 * 4; + let est = p.estimated_bytes(); + assert!(est >= dec + dec * 4 / 3, "est {est} covers decoded+encoded"); + assert!(est < dec * 10 + 8192, "est {est} stays plausible"); + let d2 = decode_bytes(&png_bytes(8, 8)).unwrap(); + let d3 = decode_bytes(&jpeg_bytes(8, 8)).unwrap(); + let p2 = ImagePreview::from_decoded(Some(d2), Some(d3), &proto).unwrap(); + assert!(p2.estimated_bytes() > est); + } + + #[test] + fn kitty_id_stored_for_inflight_cleanup() { + let picker = graphics_picker(); + let d = decode_bytes(&png_bytes(8, 8)).unwrap(); + let p = ImagePreview::from_decoded(Some(d), None, &picker).unwrap(); + // Stored id is available even before any encode completes. + let id = p.old.as_ref().unwrap().kitty_id.expect("kitty id stored"); + let mut buf = Vec::new(); + p.cleanup(&mut buf); + let out = String::from_utf8(buf).expect("cleanup writes ascii"); + assert!(out.contains("\x1b_Ga=d,d=I"), "scoped delete, got {out:?}"); + assert!(out.contains(&format!("i={id}")), "own id only, got {out:?}"); + } + + #[test] + fn image_source_variants_exist() { + let a = ImageSource::Worktree("a.png"); + let b = ImageSource::Revision { + revision: "HEAD", + path: "a.png", + }; + let c = ImageSource::Missing; + assert_ne!(a, b); + assert_ne!(b, c); + } + + #[test] + fn render_halfblocks_body_does_not_panic() { + let picker = halfblocks_picker(); + let img = decode_bytes(&png_bytes(16, 16)).unwrap().image; + let mut proto = picker.new_resize_protocol(img); + let backend = TestBackend::new(40, 12); + let mut term = Terminal::new(backend).expect("test terminal"); + term.draw(|f| { + let area = f.area(); + f.render_stateful_widget( + StatefulImage::new().resize(Resize::Fit(None)), + area, + &mut proto, + ); + }) + .unwrap(); + } + + #[test] + fn render_preview_body_splits_and_labels() { + let picker = graphics_picker(); + let old = decode_bytes(&png_bytes(16, 16)).unwrap(); + let new = decode_bytes(&jpeg_bytes(16, 16)).unwrap(); + let mut preview = ImagePreview::from_decoded(Some(old), Some(new), &picker).unwrap(); + let theme = Theme::default(); + let backend = TestBackend::new(80, 24); + let mut term = Terminal::new(backend).expect("test terminal"); + term.draw(|f| { + render(f, f.area(), &mut preview, DiffSideView::Both, &theme); + }) + .unwrap(); + let buf = term.backend().buffer().clone(); + let text: String = buf + .content() + .iter() + .map(|c| c.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(text.contains("Before"), "has Before label"); + assert!(text.contains("After"), "has After label"); + assert!(text.contains("PNG") || text.contains("JPEG")); + + term.draw(|f| { + render(f, f.area(), &mut preview, DiffSideView::OldOnly, &theme); + }) + .unwrap(); + let buf = term.backend().buffer().clone(); + let text: String = buf + .content() + .iter() + .map(|c| c.symbol().chars().next().unwrap_or(' ')) + .collect(); + assert!(text.contains("Before")); + assert!(!text.contains("After")); + } + + #[test] + fn added_and_deleted_images_use_a_single_full_width_pane() { + for added in [true, false] { + let picker = graphics_picker(); + let image = decode_bytes(&png_bytes(8, 8)).unwrap(); + let (old, new) = if added { + (None, Some(image)) + } else { + (Some(image), None) + }; + let mut preview = ImagePreview::from_decoded(old, new, &picker).unwrap(); + let theme = Theme::default(); + let mut term = Terminal::new(TestBackend::new(80, 12)).unwrap(); + term.draw(|f| render(f, f.area(), &mut preview, DiffSideView::Both, &theme)) + .unwrap(); + let text = buffer_text(term.backend().buffer()); + assert!(text.starts_with(if added { "Added " } else { "Deleted " })); + assert!(!text.contains("Missing")); + assert!(!text.contains("Before")); + assert!(!text.contains("After")); + + // An explicit request for the absent side is still respected. + let absent = if added { + DiffSideView::OldOnly + } else { + DiffSideView::NewOnly + }; + term.draw(|f| render(f, f.area(), &mut preview, absent, &theme)) + .unwrap(); + assert!(buffer_text(term.backend().buffer()).contains("Missing")); + } + } + + #[test] + fn tiny_area_never_panics() { + let picker = graphics_picker(); + let d = decode_bytes(&png_bytes(8, 8)).unwrap(); + let mut preview = ImagePreview::from_decoded(Some(d.clone()), Some(d), &picker).unwrap(); + let theme = Theme::default(); + for (w, h) in [(0, 0), (1, 1), (2, 1), (1, 2), (3, 3)] { + let backend = TestBackend::new(w, h); + let mut term = Terminal::new(backend).expect("test terminal"); + term.draw(|f| { + render(f, f.area(), &mut preview, DiffSideView::Both, &theme); + }) + .unwrap(); + } + } + + #[test] + fn preview_is_send() { + fn assert_send() {} + assert_send::(); + } + + // ------------------------------------------------------------------ + // Regression helpers: isolated temp git fixtures, no global mutation. + // ------------------------------------------------------------------ + + fn iterm2_picker() -> Picker { + let mut p = Picker::from_fontsize((8, 16)); + p.set_protocol_type(ProtocolType::Iterm2); + p + } + + fn colored_png(rgb: [u8; 3], w: u32, h: u32) -> Vec { + let img = DynamicImage::ImageRgb8(image::ImageBuffer::from_pixel(w, h, image::Rgb(rgb))); + let mut buf = Vec::new(); + let mut cur = std::io::Cursor::new(&mut buf); + img.write_to(&mut cur, ImageFormat::Png).unwrap(); + buf + } + + fn buffer_text(buf: &ratatui::buffer::Buffer) -> String { + buf.content() + .iter() + .map(|c| c.symbol().chars().next().unwrap_or(' ')) + .collect() + } + + fn git_cmd(repo: &Path, args: &[&str]) { + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(repo).args(args); + // Isolated, non-interactive reads/writes; per-command env only, + // never mutates the test process environment. + cmd.env("GIT_OPTIONAL_LOCKS", "0"); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + cmd.env("SSH_ASKPASS_REQUIRE", "never"); + if std::env::var_os("GIT_SSH_COMMAND").is_none() { + cmd.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes"); + } + cmd.env_remove("GIT_DIR"); + cmd.env_remove("GIT_WORK_TREE"); + cmd.env_remove("GIT_INDEX_FILE"); + cmd.env_remove("GIT_PREFIX"); + let status = cmd.status().expect("run git"); + assert!( + status.success(), + "git {args:?} failed in {}", + repo.display() + ); + } + + fn git_output(repo: &Path, args: &[&str]) -> String { + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(repo).args(args); + cmd.env("GIT_OPTIONAL_LOCKS", "0"); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + cmd.env("SSH_ASKPASS_REQUIRE", "never"); + if std::env::var_os("GIT_SSH_COMMAND").is_none() { + cmd.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes"); + } + cmd.env_remove("GIT_DIR"); + cmd.env_remove("GIT_WORK_TREE"); + cmd.env_remove("GIT_INDEX_FILE"); + cmd.env_remove("GIT_PREFIX"); + let out = cmd.output().expect("git output"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } + + struct TempRepo { + path: PathBuf, + } + + impl TempRepo { + fn new(prefix: &str) -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "lazygitrs-img-{prefix}-{}-{nanos}-{n}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create temp dir"); + git_cmd(&path, &["init", "-q"]); + Self { path } + } + + fn git(&self, args: &[&str]) { + git_cmd(&self.path, args); + } + + fn commit(&self, msg: &str) { + self.git(&[ + "-c", + "user.email=test@example.com", + "-c", + "user.name=Test", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + msg, + ]); + } + + fn write(&self, rel: &str, bytes: &[u8]) { + let full = self.path.join(rel); + if let Some(parent) = full.parent() { + std::fs::create_dir_all(parent).expect("mkdir parent"); + } + std::fs::write(&full, bytes).expect("write fixture"); + } + + fn git_commands(&self) -> crate::git::GitCommands { + crate::git::GitCommands::new(&self.path).expect("GitCommands") + } + + fn repo_path(&self) -> PathBuf { + self.git_commands().repo_path().to_path_buf() + } + } + + impl Drop for TempRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + #[test] + fn head_index_worktree_preserve_raw_bytes() { + let repo = TempRepo::new("raw-bytes"); + let red = colored_png([220, 30, 30], 16, 16); + let green = colored_png([30, 220, 30], 16, 16); + let blue = colored_png([30, 30, 220], 16, 16); + assert_ne!(red, green); + assert_ne!(green, blue); + + // HEAD = red. + repo.write("img.png", &red); + repo.git(&["add", "img.png"]); + repo.commit("head red"); + + // Index = green (staged), worktree = blue (unstaged). + repo.write("img.png", &green); + repo.git(&["add", "img.png"]); + repo.write("img.png", &blue); + + let repo_path = repo.repo_path(); + // Raw bytes preserved per location via private helpers. + match read_revision_blob(&repo_path, "HEAD", "img.png") { + RevisionRead::Present(b) => assert_eq!(b, red, "HEAD bytes preserved"), + other => panic!( + "HEAD should be present, got {}", + matches!(other, RevisionRead::Absent) as u8 + ), + } + match read_revision_blob(&repo_path, "", "img.png") { + RevisionRead::Present(b) => assert_eq!(b, green, "index (:path) bytes preserved"), + _ => panic!("empty revision should read index"), + } + match read_worktree_bytes(&repo_path, "img.png") { + Ok(Some(b)) => assert_eq!(b, blue, "worktree bytes preserved"), + other => panic!("worktree should be present, got {other:?}"), + } + + // Decoded sides keep distinct colors (no downscale at 16x16). + let old = load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + ) + .expect("HEAD loads") + .expect("HEAD decodes"); + let staged = load_one_side( + &repo_path, + ImageSource::Revision { + revision: "", + path: "img.png", + }, + ) + .expect("index loads") + .expect("index decodes"); + let work = load_one_side(&repo_path, ImageSource::Worktree("img.png")) + .expect("worktree loads") + .expect("worktree decodes"); + assert_eq!((old.width(), old.height()), (16, 16)); + assert_eq!( + old.image.to_rgb8().get_pixel(0, 0).0, + [220, 30, 30], + "HEAD is red" + ); + assert_eq!( + staged.image.to_rgb8().get_pixel(0, 0).0, + [30, 220, 30], + "index is green" + ); + assert_eq!( + work.image.to_rgb8().get_pixel(0, 0).0, + [30, 30, 220], + "worktree is blue" + ); + + // Full preview loads both sides with an injected picker. + let git = repo.git_commands(); + let picker = graphics_picker(); + let preview = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + ImageSource::Worktree("img.png"), + &picker, + ) + .expect("preview loads HEAD vs worktree"); + assert!(preview.old.is_some() && preview.new.is_some()); + assert!(preview.estimated_bytes() > 0); + } + + #[test] + fn empty_revision_means_index_not_head() { + let repo = TempRepo::new("empty-rev"); + let red = colored_png([200, 30, 30], 12, 12); + let green = colored_png([30, 200, 30], 12, 12); + repo.write("a.png", &red); + repo.git(&["add", "a.png"]); + repo.commit("init"); + + // No staged change: index == HEAD. + let repo_path = repo.repo_path(); + let head = match read_revision_blob(&repo_path, "HEAD", "a.png") { + RevisionRead::Present(b) => b, + _ => panic!("HEAD present"), + }; + let index = match read_revision_blob(&repo_path, "", "a.png") { + RevisionRead::Present(b) => b, + _ => panic!("index present"), + }; + assert_eq!(head, index, "clean index matches HEAD"); + assert_eq!(head, red); + + // Stage a different image: empty revision follows the index. + repo.write("a.png", &green); + repo.git(&["add", "a.png"]); + let head2 = match read_revision_blob(&repo_path, "HEAD", "a.png") { + RevisionRead::Present(b) => b, + _ => panic!("HEAD present"), + }; + let index2 = match read_revision_blob(&repo_path, "", "a.png") { + RevisionRead::Present(b) => b, + _ => panic!("index present"), + }; + assert_eq!(head2, red, "HEAD unchanged"); + assert_eq!(index2, green, "empty revision reads staged bytes"); + + let via_helper = load_one_side( + &repo_path, + ImageSource::Revision { + revision: "", + path: "a.png", + }, + ) + .expect("index loads") + .expect("index decodes"); + assert_eq!(via_helper.image.to_rgb8().get_pixel(0, 0).0, [30, 200, 30]); + + // Injected Iterm2 picker behaves like Kitty here. + let git = repo.git_commands(); + for picker in [graphics_picker(), iterm2_picker()] { + let preview = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "", + path: "a.png", + }, + ImageSource::Revision { + revision: "HEAD", + path: "a.png", + }, + &picker, + ) + .expect("index vs HEAD preview loads"); + assert!(preview.old.is_some() && preview.new.is_some()); + let _ = picker.protocol_type(); + } + } + + #[test] + fn root_commit_missing_parent_is_absent() { + let repo = TempRepo::new("root-parent"); + let red = colored_png([200, 30, 30], 10, 10); + repo.write("img.png", &red); + repo.git(&["add", "img.png"]); + repo.commit("root"); + + let repo_path = repo.repo_path(); + let hash = git_output(&repo.path, &["rev-parse", "HEAD"]); + assert!(!hash.is_empty()); + let parent_spec = format!("{hash}^1"); + + // Missing parent blob is legitimately absent, not a failure. + match read_revision_blob(&repo_path, &parent_spec, "img.png") { + RevisionRead::Absent => {} + RevisionRead::Present(_) => panic!("parent should be absent"), + RevisionRead::Failed => panic!("parent should be absent, not failed"), + } + assert!(matches!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: &parent_spec, + path: "img.png", + } + ), + Some(None) + )); + // HEAD^1 shorthand behaves the same on a root commit. + assert!(matches!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD^1", + path: "img.png", + } + ), + Some(None) + )); + + // Preview treats absent old as added (new present). + let git = repo.git_commands(); + let picker = graphics_picker(); + let preview = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: &parent_spec, + path: "img.png", + }, + ImageSource::Revision { + revision: &hash, + path: "img.png", + }, + &picker, + ) + .expect("root preview loads"); + assert!(preview.old.is_none(), "old absent on root"); + assert!(preview.new.is_some(), "new present on root"); + } + + #[test] + fn added_and_deleted_sides_via_missing() { + // Pure helper contract without git. + let picker = graphics_picker(); + let decoded = decode_bytes(&colored_png([10, 200, 10], 8, 8)).unwrap(); + assert!(ImagePreview::from_decoded(None, Some(decoded.clone()), &picker).is_some()); + assert!(ImagePreview::from_decoded(Some(decoded), None, &picker).is_some()); + assert!(ImagePreview::from_decoded(None, None, &picker).is_none()); + + // Missing via private helper is Some(None), never a failure. + let repo = TempRepo::new("added-deleted"); + let bytes = colored_png([200, 30, 30], 8, 8); + repo.write("img.png", &bytes); + repo.git(&["add", "img.png"]); + repo.commit("init"); + let repo_path = repo.repo_path(); + assert!(matches!( + load_one_side(&repo_path, ImageSource::Missing), + Some(None) + )); + // Worktree file removed => legitimately missing. + std::fs::remove_file(repo.path.join("img.png")).expect("remove worktree file"); + assert!(matches!( + read_worktree_bytes(&repo_path, "img.png"), + Ok(None) + )); + assert!(matches!( + load_one_side(&repo_path, ImageSource::Worktree("img.png")), + Some(None) + )); + + // Added (old missing) and deleted (new missing) previews load. + let git = repo.git_commands(); + let added = ImagePreview::load_with_picker( + &git, + ImageSource::Missing, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + &picker, + ) + .expect("added loads"); + assert!(added.old.is_none() && added.new.is_some()); + // Worktree file is gone, so deleted shows old only. + let deleted = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + ImageSource::Missing, + &picker, + ) + .expect("deleted loads"); + assert!(deleted.old.is_some() && deleted.new.is_none()); + // Both missing => no preview at all. + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Missing, + ImageSource::Missing, + &picker + ) + .is_none() + ); + } + + #[test] + fn rename_uses_before_path_after_path() { + let repo = TempRepo::new("rename-paths"); + let bytes = colored_png([30, 30, 220], 14, 14); + repo.write("before.png", &bytes); + repo.git(&["add", "before.png"]); + repo.commit("add before"); + repo.git(&["mv", "before.png", "after.png"]); + repo.commit("rename"); + + let repo_path = repo.repo_path(); + // Old path only exists at the parent, new path only at HEAD. + let old_bytes = match read_revision_blob(&repo_path, "HEAD^", "before.png") { + RevisionRead::Present(b) => b, + _ => panic!("before.png at HEAD^"), + }; + let new_bytes = match read_revision_blob(&repo_path, "HEAD", "after.png") { + RevisionRead::Present(b) => b, + _ => panic!("after.png at HEAD"), + }; + assert_eq!(old_bytes, bytes); + assert_eq!(new_bytes, bytes); + assert!(matches!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD", + path: "before.png", + } + ), + Some(None) + )); + assert!(matches!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD^", + path: "after.png", + } + ), + Some(None) + )); + + let git = repo.git_commands(); + let picker = graphics_picker(); + let preview = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD^", + path: "before.png", + }, + ImageSource::Revision { + revision: "HEAD", + path: "after.png", + }, + &picker, + ) + .expect("rename preview loads before/after paths"); + assert!(preview.old.is_some() && preview.new.is_some()); + } + + #[test] + fn corrupt_side_makes_entire_preview_none() { + let repo = TempRepo::new("corrupt-side"); + let valid = colored_png([200, 30, 30], 12, 12); + repo.write("img.png", &valid); + repo.git(&["add", "img.png"]); + repo.commit("valid"); + + // Corrupt worktree: entire load fails, not a partial preview. + repo.write("img.png", b"definitely not an image"); + let repo_path = repo.repo_path(); + assert!(decode_bytes(b"definitely not an image").is_none()); + assert!(load_one_side(&repo_path, ImageSource::Worktree("img.png")).is_none()); + + let git = repo.git_commands(); + let picker = graphics_picker(); + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + ImageSource::Worktree("img.png"), + &picker, + ) + .is_none(), + "corrupt new side must fail the whole preview" + ); + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Worktree("img.png"), + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + &picker, + ) + .is_none(), + "corrupt old side must fail the whole preview" + ); + + // Corrupt revision side behaves the same. + repo.write("img.png", b"still not an image"); + repo.git(&["add", "img.png"]); + repo.commit("corrupt revision"); + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD^", + path: "img.png", + }, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + &picker, + ) + .is_none(), + "corrupt revision must fail the whole preview" + ); + let _ = picker.protocol_type(); + } + + #[test] + fn decode_rejects_over_dimension_limit() { + // Width far over MAX_IMAGE_DIMENSION with tiny height keeps the + // allocation small while still exercising the dimension guard. + let wide = colored_png([10, 10, 10], MAX_IMAGE_DIMENSION + 1000, 16); + assert!( + decode_bytes(&wide).is_none(), + "width over limit must not decode" + ); + let tall = colored_png([10, 10, 10], 16, MAX_IMAGE_DIMENSION + 1000); + assert!( + decode_bytes(&tall).is_none(), + "height over limit must not decode" + ); + + // Same failure surfaces through the worktree helper without a repo. + let repo = TempRepo::new("over-dim"); + repo.write("wide.png", &wide); + let repo_path = repo.repo_path(); + assert!(load_one_side(&repo_path, ImageSource::Worktree("wide.png")).is_none()); + let git = repo.git_commands(); + let picker = graphics_picker(); + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Worktree("wide.png"), + ImageSource::Missing, + &picker, + ) + .is_none() + ); + } + + #[test] + fn decode_rejects_over_alloc_limit() { + // 5000x4500 RGB is ~67MiB decoded (>64MiB cap) while staying inside + // the 16384 dimension cap. Uniform color keeps the PNG itself small. + let big = colored_png([40, 40, 200], 5000, 4500); + assert!( + decode_bytes(&big).is_none(), + "decoded alloc over limit must not decode" + ); + } + + #[test] + fn worktree_oversize_is_bounded_without_writing_20mib() { + let repo = TempRepo::new("oversize-worktree"); + let small = colored_png([200, 30, 30], 8, 8); + repo.write("big.png", &small); + // Sparse extension: no 20MiB write, just a size bump past the cap. + let full = repo.path.join("big.png"); + let file = std::fs::OpenOptions::new() + .write(true) + .open(&full) + .expect("open"); + file.set_len((MAX_COMPRESSED_BYTES as u64) + 1) + .expect("sparse oversize"); + drop(file); + assert_eq!( + std::fs::metadata(&full).expect("meta").len(), + (MAX_COMPRESSED_BYTES as u64) + 1 + ); + + let repo_path = repo.repo_path(); + assert!(matches!( + read_worktree_bytes(&repo_path, "big.png"), + Err(()) + )); + assert!(load_one_side(&repo_path, ImageSource::Worktree("big.png")).is_none()); + let git = repo.git_commands(); + let picker = graphics_picker(); + assert!( + ImagePreview::load_with_picker( + &git, + ImageSource::Worktree("big.png"), + ImageSource::Missing, + &picker, + ) + .is_none() + ); + } + + #[test] + fn headers_show_original_dimensions_not_downscaled() { + // 3000x2000 downsizes to ~1600x1066 for the protocol, but headers + // and cache accounting must preserve the original size. + let bytes = colored_png([200, 30, 30], 3000, 2000); + let decoded = decode_bytes(&bytes).expect("large png decodes"); + assert_eq!((decoded.orig_width, decoded.orig_height), (3000, 2000)); + assert!( + decoded.width() <= PREVIEW_MAX_WIDTH && decoded.height() <= PREVIEW_MAX_HEIGHT, + "protocol image is downsized" + ); + assert!( + decoded.width() != decoded.orig_width, + "fixture must actually downscale" + ); + + let picker = graphics_picker(); + let preview = ImagePreview::from_decoded(Some(decoded), None, &picker).unwrap(); + let side = preview.old.as_ref().unwrap(); + assert_eq!((side.orig_width, side.orig_height), (3000, 2000)); + // Only the thumbnail is retained; original dimensions are metadata. + let dec_orig = side.width as usize * side.height as usize * 4; + assert!( + preview.estimated_bytes() >= dec_orig + dec_orig * 4 / 3, + "estimate covers original, got {}", + preview.estimated_bytes() + ); + + let mut live = preview; + let theme = Theme::default(); + let backend = TestBackend::new(80, 24); + let mut term = Terminal::new(backend).expect("test terminal"); + term.draw(|f| { + render(f, f.area(), &mut live, DiffSideView::Both, &theme); + }) + .unwrap(); + let text = buffer_text(term.backend().buffer()); + assert!( + text.contains("3000x2000"), + "header shows original dims, got {text:?}" + ); + assert!( + !text.contains(&format!( + "{}x{}", + live.old.as_ref().unwrap().width, + live.old.as_ref().unwrap().height + )) || text.contains("3000x2000"), + "must not show only downsized dims" + ); + } + + #[test] + fn async_render_eventually_encodes_with_test_backend() { + let picker = graphics_picker(); + let decoded = decode_bytes(&colored_png([30, 200, 30], 32, 32)).unwrap(); + let mut preview = ImagePreview::from_decoded(Some(decoded), None, &picker).unwrap(); + let theme = Theme::default(); + let backend = TestBackend::new(80, 24); + let mut term = Terminal::new(backend).expect("test terminal"); + + // First frame kicks the background resize+encode. + term.draw(|f| { + render(f, f.area(), &mut preview, DiffSideView::Both, &theme); + }) + .unwrap(); + let first = buffer_text(term.backend().buffer()); + assert!(first.contains("Deleted"), "header visible while loading"); + + // Poll via repeated renders until the worker returns the protocol. + let mut encoded = false; + for _ in 0..250 { + std::thread::sleep(std::time::Duration::from_millis(20)); + term.draw(|f| { + render(f, f.area(), &mut preview, DiffSideView::Both, &theme); + }) + .unwrap(); + if preview + .old + .as_ref() + .expect("old side") + .thread + .protocol_type() + .is_some() + { + encoded = true; + break; + } + } + assert!(encoded, "background encode should complete"); + let text = buffer_text(term.backend().buffer()); + assert!(text.contains("Deleted")); + assert!(text.contains("32x32")); + } + + #[test] + fn iterm2_picker_loads_images_like_kitty() { + let repo = TempRepo::new("iterm2-picker"); + let bytes = colored_png([30, 30, 220], 20, 20); + repo.write("img.png", &bytes); + repo.git(&["add", "img.png"]); + repo.commit("init"); + let git = repo.git_commands(); + + for picker in [graphics_picker(), iterm2_picker()] { + assert_ne!(picker.protocol_type(), ProtocolType::Halfblocks); + let preview = ImagePreview::load_with_picker( + &git, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + ImageSource::Revision { + revision: "HEAD", + path: "img.png", + }, + &picker, + ) + .expect("iterm2/kitty loads"); + // No global picker touched; injected picker drives both sides. + assert!(preview.old.is_some() && preview.new.is_some()); + assert_eq!( + preview.old.as_ref().unwrap().orig_width, + preview.new.as_ref().unwrap().orig_width + ); + } + } + + #[test] + fn diff_view_state_clears_preview_to_text_fallback() { + use crate::pager::side_by_side::DiffViewState; + + let picker = graphics_picker(); + let decoded = decode_bytes(&colored_png([200, 30, 30], 8, 8)).unwrap(); + let preview = ImagePreview::from_decoded(Some(decoded), None, &picker).unwrap(); + + // Setting a preview then loading text clears back to the fallback. + let mut state = DiffViewState::new(); + state.image_preview = Some(preview); + assert!(state.image_preview.is_some()); + state.load("file.txt", "old\n", "new\n"); + assert!( + state.image_preview.is_none(), + "load() must clear the image preview" + ); + assert!(!state.lines.is_empty(), "text fallback has lines"); + + let decoded2 = decode_bytes(&colored_png([30, 200, 30], 8, 8)).unwrap(); + state.image_preview = + Some(ImagePreview::from_decoded(Some(decoded2), None, &picker).unwrap()); + state.load_from_diff_output( + "file.txt", + "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n", + ); + assert!( + state.image_preview.is_none(), + "load_from_diff_output must clear the preview" + ); + assert!( + state.lines.iter().any( + |l| l.old_line.as_ref().is_some_and(|(_, t)| t.contains("old")) + || l.new_line.as_ref().is_some_and(|(_, t)| t.contains("new")) + ), + "fallback text diff is viewable" + ); + + // Parsed text without a preview stays a plain text diff. + let parsed = DiffViewState::parse_diff_output( + "file.txt", + "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n", + 4, + true, + ); + assert!(parsed.image_preview.is_none()); + let mut applied = DiffViewState::new(); + applied.apply_parsed(parsed); + assert!(applied.image_preview.is_none()); + assert!(!applied.lines.is_empty()); + } + + #[test] + fn load_one_side_edge_cases_are_safe() { + let repo = TempRepo::new("edge-cases"); + let bytes = colored_png([200, 30, 30], 8, 8); + repo.write("img.png", &bytes); + repo.git(&["add", "img.png"]); + repo.commit("init"); + std::fs::create_dir_all(repo.path.join("subdir")).expect("mkdir"); + let repo_path = repo.repo_path(); + + // Empty and NUL paths never touch git/disk beyond a failure. + assert!(load_one_side(&repo_path, ImageSource::Worktree("")).is_none()); + assert!(load_one_side(&repo_path, ImageSource::Worktree("a\0b")).is_none()); + assert!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD", + path: "", + } + ) + .is_none() + ); + assert!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD", + path: "a\0b", + } + ) + .is_none() + ); + assert!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD\0", + path: "img.png", + } + ) + .is_none() + ); + + // Missing worktree file and missing blob are absent, not failed. + assert!(matches!( + read_worktree_bytes(&repo_path, "missing.png"), + Ok(None) + )); + assert!(matches!( + load_one_side(&repo_path, ImageSource::Worktree("missing.png")), + Some(None) + )); + assert!(matches!( + read_revision_blob(&repo_path, "HEAD", "missing.png"), + RevisionRead::Absent + )); + assert!(matches!( + load_one_side( + &repo_path, + ImageSource::Revision { + revision: "HEAD", + path: "missing.png", + } + ), + Some(None) + )); + + // Directories read as absent. + assert!(matches!( + read_worktree_bytes(&repo_path, "subdir"), + Ok(None) + )); + assert!(matches!( + load_one_side(&repo_path, ImageSource::Worktree("subdir")), + Some(None) + )); + + // Direct helper coverage for bounded reads. + assert!(matches!( + read_worktree_bytes(&repo_path, "img.png"), + Ok(Some(_)) + )); + assert!(matches!( + read_revision_blob(&repo_path, "HEAD", "img.png"), + RevisionRead::Present(_) + )); + } + #[test] + fn image_diff_renders_labels_and_hides_for_overlay() { + use crate::pager::side_by_side::{DiffViewState, render_diff}; + let picker = graphics_picker(); + let mut parsed = DiffViewState::parse_diff_output( + "image.png", + "Binary files a/image.png and b/image.png differ\n", + 4, + true, + ); + parsed.image_preview = ImagePreview::from_decoded( + Some(decode_bytes(&png_bytes(32, 32)).unwrap()), + Some(decode_bytes(&png_bytes(64, 64)).unwrap()), + &picker, + ); + assert!(parsed.image_preview.as_ref().unwrap().uses_placeholders()); + let mut view = DiffViewState::new(); + view.apply_parsed(parsed); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + let theme = Theme::default(); + terminal + .draw(|f| render_diff(f, f.area(), &mut view, &theme, true, false, true)) + .unwrap(); + let text = buffer_text(terminal.backend().buffer()); + assert!(text.contains("Before") && text.contains("After")); + assert!(!text.contains("Binary cannot be previewed")); + view.image_preview_hidden = true; + terminal + .draw(|f| render_diff(f, f.area(), &mut view, &theme, true, false, true)) + .unwrap(); + let text = buffer_text(terminal.backend().buffer()); + assert!(text.contains("Binary cannot be previewed")); + assert!(!text.contains("Before")); + } + + // ------------------------------------------------------------------ + // Inline mixed-diff helpers (isolated, no global env/detection). + // ------------------------------------------------------------------ + + fn mixed_three_file_diff() -> String { + let long_a_old = "a".repeat(200); + let long_a_new = "b".repeat(200); + let long_c_old = "c".repeat(200); + let long_c_new = "d".repeat(200); + format!( + "diff --git a/first.txt b/first.txt\n--- a/first.txt\n+++ b/first.txt\n@@ -1,3 +1,3 @@\n-{long_a_old}\n+{long_a_new}\n context line one\n@@ -10,3 +10,3 @@\n context line two\n-{long_c_old}\n+{long_c_new}\n context line three\ndiff --git a/img.png b/img.png\nBinary files a/img.png and b/img.png differ\ndiff --git a/last.txt b/last.txt\n--- a/last.txt\n+++ b/last.txt\n@@ -1,2 +1,2 @@\n-tail old one\n+tail new one\n tail context\n" + ) + } + + fn inline_ready_both_sides() -> InlineImagePreview { + let picker = graphics_picker(); + let old = decode_bytes(&png_bytes(32, 32)).unwrap(); + let new = decode_bytes(&png_bytes(32, 32)).unwrap(); + let preview = ImagePreview::from_decoded(Some(old), Some(new), &picker).unwrap(); + InlineImagePreview::from_preview_for_test(preview) + } + + fn inline_ready_single_sided(added: bool) -> InlineImagePreview { + let picker = graphics_picker(); + let img = decode_bytes(&png_bytes(16, 16)).unwrap(); + let (old, new) = if added { + (None, Some(img)) + } else { + (Some(img), None) + }; + let preview = ImagePreview::from_decoded(old, new, &picker).unwrap(); + InlineImagePreview::from_preview_for_test(preview) + } + + fn render_state_buffer( + state: &mut crate::pager::side_by_side::DiffViewState, + w: u16, + h: u16, + ) -> ratatui::buffer::Buffer { + use crate::pager::side_by_side::render_diff; + let mut term = Terminal::new(TestBackend::new(w, h)).unwrap(); + term.draw(|f| { + render_diff( + f, + f.area(), + state, + &crate::config::Theme::default(), + true, + false, + true, + ) + }) + .unwrap(); + term.backend().buffer().clone() + } + + fn test_row_string(buf: &ratatui::buffer::Buffer, y: u16) -> String { + (0..buf.area.width) + .map(|x| buf[(x, y)].symbol().to_string()) + .collect() + } + + fn test_header_idx(state: &crate::pager::side_by_side::DiffViewState, name: &str) -> usize { + state + .lines + .iter() + .position(|l| l.file_header.as_ref().is_some_and(|h| h.contains(name))) + .expect("header present") + } + + fn middle_image_section(state: &crate::pager::side_by_side::ParsedDiff) -> usize { + state + .lines + .iter() + .find_map(|l| { + if l.preview_placeholder.is_some() { + Some(l.section_index) + } else { + None + } + }) + .expect("binary section present") + } + + #[test] + fn inline_with_picker_and_ready_fixture_uses_injected_picker() { + let picker = graphics_picker(); + let idle = InlineImagePreview::with_picker( + std::path::Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker, + ); + assert!(matches!(idle.state, InlineLoadState::Idle)); + let bytes = png_bytes(4, 4); + assert!(!bytes.is_empty()); + let decoded = decode_bytes(&bytes).expect("png decodes"); + assert_eq!((decoded.width(), decoded.height()), (4, 4)); + let picker2 = graphics_picker(); + let d = decode_bytes(&png_bytes(8, 8)).unwrap(); + let preview = ImagePreview::from_decoded(Some(d), None, &picker2).unwrap(); + let ready = InlineImagePreview::from_preview_for_test(preview); + assert!(matches!(ready.state, InlineLoadState::Ready(_))); + assert!(ready.estimated_bytes() > idle.estimated_bytes()); + use crate::pager::side_by_side::DiffViewState; + let diff = mixed_three_file_diff(); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + let middle = middle_image_section(&parsed); + let mut map = std::collections::HashMap::new(); + map.insert(middle, inline_ready_both_sides()); + let before = parsed.lines.len(); + parsed.attach_inline_images(map); + assert_eq!(parsed.inline_images.len(), 1); + assert!(parsed.lines.len() > before); + assert_eq!( + parsed + .lines + .iter() + .filter(|l| l.preview_placeholder.is_some() && l.section_index == middle) + .count(), + INLINE_IMAGE_ROWS + ); + } + + #[test] + fn inline_lazy_idle_has_no_pixels() { + let picker = graphics_picker(); + let inline = InlineImagePreview::with_picker( + std::path::Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker, + ); + assert!(matches!(inline.state, InlineLoadState::Idle)); + let est = inline.estimated_bytes(); + assert!(est < 4096, "idle estimate small, got {est}"); + let ready = inline_ready_both_sides(); + assert!(ready.estimated_bytes() > est + 1000); + } + + #[test] + fn inline_initial_render_triggers_background_load() { + use crate::pager::side_by_side::DiffSideView; + let repo = TempRepo::new("inline-initial-load"); + let bytes = png_bytes(16, 16); + repo.write("img.png", &bytes); + let repo_path = repo.repo_path(); + let picker = graphics_picker(); + let mut inline = InlineImagePreview::with_picker( + &repo_path, + InlineImageSource::Missing, + InlineImageSource::Worktree("img.png".to_string()), + picker, + ); + assert!(matches!(inline.state, InlineLoadState::Idle)); + let theme = crate::config::Theme::default(); + let mut term = Terminal::new(TestBackend::new(60, 14)).unwrap(); + let start = std::time::Instant::now(); + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + assert!( + start.elapsed() < std::time::Duration::from_secs(2), + "initial render must not block" + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut settled = false; + while std::time::Instant::now() < deadline { + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + match &inline.state { + InlineLoadState::Ready(_) => { + settled = true; + break; + } + InlineLoadState::Unavailable => panic!("valid png must not become unavailable"), + _ => {} + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(settled, "background load should settle to Ready within 2s"); + assert!(inline.estimated_bytes() > 4096); + } + + #[test] + fn inline_corrupt_source_shows_fallback_without_blocking() { + use crate::pager::side_by_side::DiffSideView; + let repo = TempRepo::new("inline-corrupt-fallback"); + repo.write("img.png", b"definitely not an image"); + let repo_path = repo.repo_path(); + let picker = graphics_picker(); + let mut inline = InlineImagePreview::with_picker( + &repo_path, + InlineImageSource::Missing, + InlineImageSource::Worktree("img.png".to_string()), + picker, + ); + let theme = crate::config::Theme::default(); + let mut term = Terminal::new(TestBackend::new(60, 14)).unwrap(); + let start = std::time::Instant::now(); + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + assert!( + start.elapsed() < std::time::Duration::from_secs(2), + "corrupt render must not block" + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut unavailable = false; + while std::time::Instant::now() < deadline { + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + if matches!(inline.state, InlineLoadState::Unavailable) { + unavailable = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(unavailable, "corrupt must settle to Unavailable"); + let text = buffer_text(term.backend().buffer()); + assert!( + text.contains("Binary cannot be previewed") || text.contains("╱"), + "fallback stripes, got {text:?}" + ); + assert!(inline.estimated_bytes() < 4096); + } + + #[test] + fn inline_pending_unload_retains_loading_receiver() { + let picker = graphics_picker(); + let mut inline = InlineImagePreview::with_picker( + std::path::Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker, + ); + let (_tx, rx) = std::sync::mpsc::channel::>(); + inline.state = InlineLoadState::Loading(rx); + inline.unload(); + assert!( + matches!(inline.state, InlineLoadState::Loading(_)), + "pending load must be retained" + ); + let picker2 = graphics_picker(); + let mut inline2 = InlineImagePreview::with_picker( + std::path::Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker2, + ); + let (tx2, rx2) = std::sync::mpsc::channel::>(); + tx2.send(None).unwrap(); + inline2.state = InlineLoadState::Loading(rx2); + inline2.unload(); + assert!( + matches!(inline2.state, InlineLoadState::Unavailable), + "completed None must become Unavailable" + ); + } + + #[test] + fn inline_ready_unload_and_cache_unload_free_pixels() { + let mut ready = inline_ready_both_sides(); + assert!(matches!(ready.state, InlineLoadState::Ready(_))); + let before = ready.estimated_bytes(); + assert!(before > 4096, "ready carries pixels, got {before}"); + ready.unload(); + assert!(matches!(ready.state, InlineLoadState::Idle)); + assert!(ready.estimated_bytes() < before); + assert!(ready.estimated_bytes() < 4096); + let mut cached = inline_ready_both_sides(); + cached.unload_for_cache(); + assert!(matches!(cached.state, InlineLoadState::Idle)); + assert!(cached.estimated_bytes() < 4096); + let picker = graphics_picker(); + let mut unavailable = InlineImagePreview::with_picker( + std::path::Path::new("."), + InlineImageSource::Missing, + InlineImageSource::Missing, + picker, + ); + unavailable.state = InlineLoadState::Unavailable; + unavailable.unload(); + assert!(matches!(unavailable.state, InlineLoadState::Unavailable)); + unavailable.unload_for_cache(); + assert!(matches!(unavailable.state, InlineLoadState::Unavailable)); + } + + #[test] + fn inline_offscreen_stays_idle_until_viewport() { + use crate::pager::side_by_side::{DiffViewLayout, DiffViewState, render_diff}; + let repo = TempRepo::new("inline-offscreen-idle"); + let bytes = png_bytes(16, 16); + repo.write("img.png", &bytes); + let repo_path = repo.repo_path(); + let diff = mixed_three_file_diff(); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + let middle = middle_image_section(&parsed); + let picker = graphics_picker(); + let inline = InlineImagePreview::with_picker( + &repo_path, + InlineImageSource::Missing, + InlineImageSource::Worktree("img.png".to_string()), + picker, + ); + assert!(matches!(inline.state, InlineLoadState::Idle)); + let mut map = std::collections::HashMap::new(); + map.insert(middle, inline); + parsed.attach_inline_images(map); + let mut state = DiffViewState::new(); + state.apply_parsed(parsed); + state.view_layout = DiffViewLayout::SideBySide; + state.wrap = false; + state.scroll_offset = 0; + let img_hdr = test_header_idx(&state, "img.png"); + assert!(img_hdr >= 6, "image below small viewport, got {img_hdr}"); + let theme = crate::config::Theme::default(); + let mut term = Terminal::new(TestBackend::new(60, 8)).unwrap(); + term.draw(|f| { + render_diff(f, f.area(), &mut state, &theme, true, false, true); + }) + .unwrap(); + assert!( + matches!( + state.inline_images.get(&middle).unwrap().state, + InlineLoadState::Idle + ), + "offscreen must stay Idle" + ); + state.scroll_offset = img_hdr; + let mut term2 = Terminal::new(TestBackend::new(60, 20)).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut started = false; + while std::time::Instant::now() < deadline { + term2 + .draw(|f| { + render_diff(f, f.area(), &mut state, &theme, true, false, true); + }) + .unwrap(); + if !matches!( + state.inline_images.get(&middle).unwrap().state, + InlineLoadState::Idle + ) { + started = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(started, "viewport render must start load"); + } + + #[test] + fn inline_mixed_three_file_sticky_headers_and_bounded_image() { + use crate::pager::side_by_side::{DiffViewLayout, DiffViewState}; + for wrap in [false, true] { + for layout in [DiffViewLayout::SideBySide, DiffViewLayout::Unified] { + let diff = mixed_three_file_diff(); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + let middle = middle_image_section(&parsed); + let mut map = std::collections::HashMap::new(); + map.insert(middle, inline_ready_both_sides()); + parsed.attach_inline_images(map); + assert_eq!( + parsed + .lines + .iter() + .filter(|l| l.preview_placeholder.is_some() && l.section_index == middle) + .count(), + INLINE_IMAGE_ROWS, + "layout {layout:?} wrap {wrap}" + ); + let mut state = DiffViewState::new(); + state.apply_parsed(parsed); + state.wrap = wrap; + let img_hdr = test_header_idx(&state, "img.png"); + let tail_hdr = test_header_idx(&state, "last.txt"); + assert!(tail_hdr > img_hdr, "tail after image"); + if layout == DiffViewLayout::Unified { + state.view_layout = DiffViewLayout::Unified; + state.scroll_offset = 0; + let _ = render_state_buffer(&mut state, 60, 30); + state.view_layout = DiffViewLayout::SideBySide; + state.scroll_offset = img_hdr + 1; + let _ = render_state_buffer(&mut state, 60, 30); + state.toggle_view_layout(); + assert_eq!(state.view_layout, DiffViewLayout::Unified); + } else { + state.view_layout = DiffViewLayout::SideBySide; + let _ = render_state_buffer(&mut state, 60, 30); + state.scroll_offset = img_hdr + 1; + } + assert!( + state + .sticky_file_header() + .is_some_and(|h| h.contains("img.png")), + "layout {layout:?} wrap {wrap} sticky" + ); + assert_eq!(state.sticky_header_line_idx(), Some(img_hdr)); + let buf = render_state_buffer(&mut state, 60, 30); + let text = buffer_text(&buf); + assert!( + text.contains("Before") && text.contains("After"), + "layout {layout:?} wrap {wrap} labels, got {text:?}" + ); + assert!(text.contains("last.txt"), "tail header visible"); + assert!(text.contains("tail"), "tail content visible"); + let mut before_y = None; + let mut tail_y = None; + for y in 0..buf.area.height { + let row = test_row_string(&buf, y); + if row.contains("Before") && before_y.is_none() { + before_y = Some(y); + } + if row.contains("last.txt") && tail_y.is_none() { + tail_y = Some(y); + } + } + let (b, t) = (before_y.expect("Before row"), tail_y.expect("tail row")); + assert!(b < t, "image above tail layout {layout:?} wrap {wrap}"); + assert!( + (t as usize).saturating_sub(b as usize) <= INLINE_IMAGE_ROWS + 4, + "tail just after image b={b} t={t}" + ); + for y in (t + 1)..buf.area.height { + let row = test_row_string(&buf, y); + assert!( + !row.contains("Before") && !row.contains("After"), + "bounded y={y} {row:?}" + ); + } + if layout == DiffViewLayout::Unified { + state.toggle_view_layout(); + assert_eq!(state.view_layout, DiffViewLayout::SideBySide); + assert!( + (state.scroll_offset as isize - (img_hdr as isize + 1)).abs() <= 2, + "toggle back near image, got {}", + state.scroll_offset + ); + } + } + } + } + + #[test] + fn inline_mixed_added_deleted_full_width_and_scroll_unloads() { + use crate::pager::side_by_side::{DiffSideView, DiffViewLayout, DiffViewState}; + for added in [true, false] { + let diff = mixed_three_file_diff(); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + let middle = middle_image_section(&parsed); + let mut map = std::collections::HashMap::new(); + map.insert(middle, inline_ready_single_sided(added)); + parsed.attach_inline_images(map); + let mut state = DiffViewState::new(); + state.apply_parsed(parsed); + state.view_layout = DiffViewLayout::SideBySide; + state.side_view = DiffSideView::Both; + state.wrap = false; + let img_hdr = test_header_idx(&state, "img.png"); + state.scroll_offset = img_hdr + 1; + let buf = render_state_buffer(&mut state, 60, 30); + let text = buffer_text(&buf); + let label = if added { "Added" } else { "Deleted" }; + assert!(text.contains(label), "added={added} label, got {text:?}"); + assert!( + !text.contains("Before") && !text.contains("After"), + "single-sided no Before/After" + ); + let tail_hdr = test_header_idx(&state, "last.txt"); + state.scroll_offset = tail_hdr; + let buf2 = render_state_buffer(&mut state, 60, 30); + let text2 = buffer_text(&buf2); + assert!( + !text2.contains("Before") && !text2.contains("After"), + "no labels past image" + ); + assert!( + !text2.contains(label), + "label {label} gone after scroll, got {text2:?}" + ); + let preview = state.inline_images.get(&middle).unwrap(); + assert!( + matches!(preview.state, InlineLoadState::Idle), + "scrolled past must unload to Idle" + ); + assert!(preview.estimated_bytes() < 4096); + } + } + + #[test] + fn inline_concurrent_loads_settle_without_global_counter() { + use crate::pager::side_by_side::DiffSideView; + let repo = TempRepo::new("inline-concurrent-loads"); + for (name, color) in [ + ("a.png", [200, 30, 30]), + ("b.png", [30, 200, 30]), + ("c.png", [30, 30, 200]), + ] { + repo.write(name, &colored_png(color, 12, 12)); + } + let repo_path = repo.repo_path(); + let picker = graphics_picker(); + let mut inlines: Vec = ["a.png", "b.png", "c.png"] + .into_iter() + .map(|n| { + InlineImagePreview::with_picker( + &repo_path, + InlineImageSource::Missing, + InlineImageSource::Worktree(n.to_string()), + picker.clone(), + ) + }) + .collect(); + let theme = crate::config::Theme::default(); + let mut term = Terminal::new(TestBackend::new(60, 14)).unwrap(); + let start = std::time::Instant::now(); + for inline in inlines.iter_mut() { + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + } + assert!( + start.elapsed() < std::time::Duration::from_secs(2), + "kickoff must not block" + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let mut all_ready = true; + for inline in inlines.iter_mut() { + term.draw(|f| { + inline.render(f, f.area(), DiffSideView::Both, &theme); + }) + .unwrap(); + match &inline.state { + InlineLoadState::Ready(_) => {} + InlineLoadState::Unavailable => panic!("valid png unavailable"), + _ => { + all_ready = false; + } + } + } + if all_ready { + break; + } + if std::time::Instant::now() >= deadline { + panic!("concurrent loads should settle within 2s"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } +} diff --git a/src/pager/image_preview/capabilities.rs b/src/pager/image_preview/capabilities.rs new file mode 100644 index 0000000..9c0e339 --- /dev/null +++ b/src/pager/image_preview/capabilities.rs @@ -0,0 +1,488 @@ +//! Terminal capability detection and the shared image protocol picker. + +use std::io::Write; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use ratatui_image::picker::cap_parser::{Parser, Response}; +use ratatui_image::picker::{Picker, ProtocolType}; + +const PROBE_TIMEOUT: Duration = Duration::from_millis(450); +// Only CSI replies: Kitty/Ghostty are identified by hints, not an APC query. +// Graphics replies can arrive after the status reply and leak into key input. +const CAPABILITY_QUERY: &str = "\x1b[c\x1b[16t\x1b[5n"; +const FALLBACK_KITTY_FONT: (u16, u16) = (8, 16); + +static GLOBAL_PICKER: OnceLock> = OnceLock::new(); + +pub(super) fn global_picker_ref() -> Option<&'static Picker> { + GLOBAL_PICKER.get().and_then(|o| o.as_ref()) +} + +/// Detect capabilities once, before the TUI input reader starts. Only the +/// first call probes; later calls are no-ops. Never touches stdin/stdout, +/// never mutates tmux settings. +pub fn initialize() { + if GLOBAL_PICKER.get().is_some() { + return; + } + let picker = detect_picker(); + let _ = GLOBAL_PICKER.set(picker); +} + +fn preview_disabled_by_env() -> bool { + match std::env::var("LAZYGITRS_IMAGE_PREVIEW") { + Ok(v) => { + matches!( + v.trim().to_ascii_lowercase().as_str(), + "off" | "0" | "false" | "no" | "disable" | "disabled" | "none" + ) + } + Err(_) => false, + } +} + +fn in_unsupported_multiplexer() -> bool { + if std::env::var_os("TMUX").is_some() { + return true; + } + if std::env::var_os("ZELLIJ").is_some() { + return true; + } + if std::env::var_os("ZELLIJ_SESSION_NAME").is_some() { + return true; + } + if std::env::var("TERM").is_ok_and(|t| t.starts_with("tmux") || t.starts_with("screen")) { + return true; + } + if std::env::var("TERM_PROGRAM").is_ok_and(|p| p == "tmux") { + return true; + } + false +} + +fn is_konsole() -> bool { + if std::env::var_os("KONSOLE_VERSION").is_some() { + return true; + } + if std::env::var_os("KONSOLE_DBUS_SESSION").is_some() { + return true; + } + if std::env::var("TERM").is_ok_and(|t| t.to_ascii_lowercase().contains("konsole")) { + return true; + } + false +} + +/// iTerm2/WezTerm hints. `rio` is known-broken and `Hyper` is unverified, so +/// neither enables graphics here. +fn iterm2_hint_from_env() -> bool { + if let Ok(tp) = std::env::var("TERM_PROGRAM") { + if tp.contains("iTerm") + || tp.contains("WezTerm") + || tp.contains("mintty") + || tp.contains("vscode") + || tp.contains("Tabby") + { + return true; + } + } + if std::env::var("LC_TERMINAL").is_ok_and(|v| v.contains("iTerm")) { + return true; + } + if std::env::var("ITERM_SESSION_ID").is_ok_and(|s| !s.is_empty()) { + return true; + } + if std::env::var("WEZTERM_EXECUTABLE").is_ok_and(|s| !s.is_empty()) { + return true; + } + false +} + +fn strong_kitty_ghostty_hint() -> bool { + if std::env::var("KITTY_WINDOW_ID").is_ok_and(|s| !s.is_empty()) { + return true; + } + if std::env::var("TERM").is_ok_and(|t| t == "xterm-kitty" || t.contains("kitty")) { + return true; + } + if std::env::var("TERM_PROGRAM") + .is_ok_and(|p| p.contains("kitty") || p.contains("ghostty") || p.contains("Ghostty")) + { + return true; + } + if std::env::var_os("GHOSTTY_RESOURCES_DIR").is_some() { + return true; + } + if std::env::var("GHOSTTY_BIN_DIR").is_ok_and(|s| !s.is_empty()) { + return true; + } + false +} + +#[cfg(unix)] +fn font_size_from_fd(fd: std::os::fd::RawFd) -> Option<(u16, u16)> { + // SAFETY: ioctl with TIOCGWINSZ on a borrowed fd; `ws` is a plain POD out-param. + let mut ws: libc::winsize = unsafe { std::mem::zeroed() }; + let ret = unsafe { libc::ioctl(fd, libc::TIOCGWINSZ, &mut ws) }; + if ret != 0 { + return None; + } + if ws.ws_col == 0 || ws.ws_row == 0 || ws.ws_xpixel == 0 || ws.ws_ypixel == 0 { + return None; + } + let w = ws.ws_xpixel / ws.ws_col; + let h = ws.ws_ypixel / ws.ws_row; + if w == 0 || h == 0 { + return None; + } + if !(4..=100).contains(&w) || !(6..=200).contains(&h) { + return None; + } + Some((w, h)) +} + +#[cfg(unix)] +fn font_size_from_winsize() -> Option<(u16, u16)> { + use std::os::fd::AsRawFd; + if let Ok(f) = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + { + if let Some(fs) = font_size_from_fd(f.as_raw_fd()) { + return Some(fs); + } + } + font_size_from_fd(libc::STDOUT_FILENO) +} + +#[cfg(not(unix))] +fn font_size_from_winsize() -> Option<(u16, u16)> { + None +} + +/// Bounded synchronous capability probe on `/dev/tty`. No threads, no +/// detached readers; termios is restored via a guard. +#[cfg(unix)] +fn probe_tty_responses(timeout: Duration) -> Option> { + use std::os::fd::AsRawFd; + + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .ok()?; + let fd = file.as_raw_fd(); + + // SAFETY: tcgetattr on our own open tty fd with a valid out-pointer. + let mut orig: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(fd, &mut orig) } != 0 { + return None; + } + struct Guard { + fd: i32, + orig: libc::termios, + } + impl Drop for Guard { + fn drop(&mut self) { + unsafe { + libc::tcsetattr(self.fd, libc::TCSANOW, &self.orig); + } + } + } + let _guard = Guard { fd, orig }; + + // SAFETY: tcsetattr on our own tty fd. + let mut raw = _guard.orig; + raw.c_lflag &= !(libc::ICANON | libc::ECHO); + raw.c_cc[libc::VMIN as usize] = 0; + raw.c_cc[libc::VTIME as usize] = 0; + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { + return None; + } + + { + let mut w = &file; + if w.write_all(CAPABILITY_QUERY.as_bytes()).is_err() { + return None; + } + if w.flush().is_err() { + return None; + } + } + + let mut parser = Parser::new(); + let mut out: Vec = Vec::new(); + let deadline = Instant::now() + timeout; + let mut buf = [0u8; 512]; + + loop { + let remain = deadline.saturating_duration_since(Instant::now()); + if remain.is_zero() { + break; + } + let ms = remain.as_millis().min(100) as libc::c_int; + let mut pfd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: poll on our own tty fd with a valid pollfd pointer. + let pr = unsafe { libc::poll(&mut pfd, 1, ms) }; + if pr < 0 { + continue; + } + if pr == 0 { + continue; + } + if pfd.revents & libc::POLLIN == 0 { + if pfd.revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0 { + break; + } + continue; + } + // SAFETY: read into a valid stack buffer. + let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; + if n <= 0 { + continue; + } + for b in buf.iter().take(n as usize) { + for r in parser.push(char::from(*b)) { + if r == Response::Status { + return Some(out); + } + out.push(r); + } + } + if out.len() > 32 { + break; + } + } + if out.is_empty() { None } else { Some(out) } +} + +#[cfg(not(unix))] +fn probe_tty_responses(_timeout: Duration) -> Option> { + None +} + +fn interpret_probed( + responses: Option>, +) -> (Option, Option<(u16, u16)>) { + let Some(responses) = responses else { + return (None, None); + }; + let mut proto: Option = None; + let mut font: Option<(u16, u16)> = None; + for r in &responses { + match r { + Response::Kitty => { + proto = Some(ProtocolType::Kitty); + } + Response::Sixel => { + if proto.is_none() { + proto = Some(ProtocolType::Sixel); + } + } + Response::CellSize(Some((w, h))) => { + if *w != 0 && *h != 0 { + font = Some((*w, *h)); + } + } + Response::CellSize(None) => {} + Response::RectangularOps | Response::CursorPositionReport(..) | Response::Status => {} + } + } + (proto, font) +} + +fn detect_picker() -> Option { + if preview_disabled_by_env() { + return None; + } + if in_unsupported_multiplexer() { + return None; + } + if is_konsole() { + return None; + } + // Non-placeholder protocols cannot reliably clear graphics under popups + // with this widget release. Keep them opt-in until verified end-to-end. + let experimental = std::env::var("LAZYGITRS_IMAGE_PREVIEW").is_ok_and(|v| v == "experimental"); + if !strong_kitty_ghostty_hint() && !experimental { + return None; + } + + #[cfg(not(unix))] + { + return None; + } + + #[cfg(unix)] + { + let nested = crate::os::tty::nested_tty_launch(); + let winsize_font = font_size_from_winsize(); + + if nested { + // No terminal query under nested launches; hints + ioctl only. + if experimental && iterm2_hint_from_env() { + let fs = winsize_font?; + let mut p = Picker::from_fontsize(fs); + p.set_protocol_type(ProtocolType::Iterm2); + return Some(p); + } + if strong_kitty_ghostty_hint() { + let fs = winsize_font.unwrap_or(FALLBACK_KITTY_FONT); + if fs.0 == 0 || fs.1 == 0 { + return None; + } + let mut p = Picker::from_fontsize(fs); + p.set_protocol_type(ProtocolType::Kitty); + return Some(p); + } + return None; + } + + // Known placeholder terminals need no protocol query. In particular, + // don't create terminal replies when ioctl already supplies cell size. + let probed = if strong_kitty_ghostty_hint() && winsize_font.is_some() { + None + } else { + probe_tty_responses(PROBE_TIMEOUT) + }; + let (probed_proto, probed_font) = interpret_probed(probed); + let hint_iterm2 = experimental && iterm2_hint_from_env(); + let hint_kitty = strong_kitty_ghostty_hint(); + + // iTerm2 hints win, then queried Sixel, then Kitty only with a strong + // Kitty/Ghostty hint. A bare Kitty query response is not trusted: our + // Kitty path needs unicode placeholders, which the query alone does + // not guarantee. + let proto: Option = if hint_iterm2 { + Some(ProtocolType::Iterm2) + } else if let Some(p) = probed_proto { + match p { + ProtocolType::Sixel if experimental => Some(ProtocolType::Sixel), + ProtocolType::Kitty if hint_kitty => Some(ProtocolType::Kitty), + _ => { + if hint_kitty { + Some(ProtocolType::Kitty) + } else { + None + } + } + } + } else if hint_kitty { + Some(ProtocolType::Kitty) + } else { + None + }; + + let proto = proto?; + if proto == ProtocolType::Halfblocks { + return None; + } + + let font = winsize_font.or(probed_font).or_else(|| { + if proto == ProtocolType::Kitty && hint_kitty { + Some(FALLBACK_KITTY_FONT) + } else { + None + } + })?; + if font.0 == 0 || font.1 == 0 { + return None; + } + + let mut picker = Picker::from_fontsize(font); + picker.set_protocol_type(proto); + if picker.protocol_type() == ProtocolType::Halfblocks { + return None; + } + Some(picker) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn startup_query_never_requests_a_graphics_reply() { + assert_eq!(CAPABILITY_QUERY, "\x1b[c\x1b[16t\x1b[5n"); + assert!(!CAPABILITY_QUERY.contains("\x1b_G")); + assert!(!CAPABILITY_QUERY.contains("i=31")); + } + + #[test] + fn interpret_probed_without_responses_has_no_protocol_or_font() { + assert_eq!(interpret_probed(None), (None, None)); + assert_eq!(interpret_probed(Some(Vec::new())), (None, None)); + } + + #[test] + fn interpret_probed_recognizes_sixel() { + assert_eq!( + interpret_probed(Some(vec![Response::Sixel])), + (Some(ProtocolType::Sixel), None) + ); + } + + #[test] + fn interpret_probed_prefers_kitty_regardless_of_response_order() { + for responses in [ + vec![Response::Kitty, Response::Sixel], + vec![Response::Sixel, Response::Kitty], + ] { + assert_eq!( + interpret_probed(Some(responses)), + (Some(ProtocolType::Kitty), None) + ); + } + } + + #[test] + fn interpret_probed_recognizes_cell_size_without_a_protocol() { + assert_eq!( + interpret_probed(Some(vec![Response::CellSize(Some((8, 16)))])), + (None, Some((8, 16))) + ); + } + + #[test] + fn interpret_probed_ignores_missing_and_zero_cell_sizes() { + assert_eq!( + interpret_probed(Some(vec![ + Response::CellSize(None), + Response::CellSize(Some((0, 16))), + Response::CellSize(Some((8, 0))), + Response::CellSize(Some((0, 0))), + ])), + (None, None) + ); + assert_eq!( + interpret_probed(Some(vec![ + Response::CellSize(Some((8, 16))), + Response::CellSize(None), + Response::CellSize(Some((0, 16))), + Response::CellSize(Some((8, 0))), + ])), + (None, Some((8, 16))) + ); + } + + #[test] + fn interpret_probed_keeps_last_valid_font_and_ignores_other_responses() { + assert_eq!( + interpret_probed(Some(vec![ + Response::Sixel, + Response::CellSize(Some((8, 16))), + Response::RectangularOps, + Response::CellSize(Some((10, 20))), + Response::Status, + ])), + (Some(ProtocolType::Sixel), Some((10, 20))) + ); + } +} diff --git a/src/pager/image_preview/inline_diff.rs b/src/pager/image_preview/inline_diff.rs new file mode 100644 index 0000000..d47abe4 --- /dev/null +++ b/src/pager/image_preview/inline_diff.rs @@ -0,0 +1,174 @@ +//! Select inline raster sections and their source identities without loading pixels. + +use std::collections::HashSet; +use std::path::Path; + +use super::{InlineImagePreview, InlineImageSource}; +use crate::pager::side_by_side::ParsedDiff; + +/// Attach source metadata, not pixels. Inline sections decode on viewport entry. +pub(crate) fn attach_inline_image_previews( + parsed: &mut ParsedDiff, + repo: &Path, + diff: &str, + old_revision: &str, + new_revision: Option<&str>, + excluded_paths: &HashSet, +) { + let images = sources_for_diff(diff, old_revision, new_revision, excluded_paths) + .into_iter() + .filter_map(|(section, old, new)| { + InlineImagePreview::new(repo, old, new).map(|image| (section, image)) + }) + .collect(); + parsed.attach_inline_images(images); +} + +fn sources_for_diff( + diff: &str, + old_revision: &str, + new_revision: Option<&str>, + excluded_paths: &HashSet, +) -> Vec<(usize, InlineImageSource, InlineImageSource)> { + let mut sources = Vec::new(); + for (section, (_, file_diff)) in crate::pager::side_by_side::parse_multi_file_diff(diff) + .iter() + .enumerate() + { + let Some(paths) = crate::git::diff_paths::paths_from_diff(file_diff) else { + continue; + }; + if paths + .old + .iter() + .chain(&paths.new) + .any(|p| excluded_paths.contains(p)) + { + continue; + } + if !paths.old.iter().chain(&paths.new).any(|p| is_image_path(p)) { + continue; + } + // No textual hunk data may be replaced by graphics (attributes can + // force a PNG-looking file to be diffed as ordinary source text). + if file_diff.lines().any(|line| line.starts_with("@@")) { + continue; + } + let old = paths.old.map_or(InlineImageSource::Missing, |path| { + InlineImageSource::Revision { + revision: old_revision.to_string(), + path, + } + }); + let new = paths + .new + .map_or(InlineImageSource::Missing, |path| match new_revision { + Some(revision) => InlineImageSource::Revision { + revision: revision.to_string(), + path, + }, + None => InlineImageSource::Worktree(path), + }); + sources.push((section, old, new)); + } + sources +} + +pub(crate) fn is_image_path(path: &str) -> bool { + std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| { + matches!( + e.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tif" | "tiff" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn revision(revision: &str, path: &str) -> InlineImageSource { + InlineImageSource::Revision { + revision: revision.into(), + path: path.into(), + } + } + + #[test] + fn raster_extensions_are_case_insensitive_and_exclude_svg_and_video() { + for extension in [ + "png", "JPG", "jpeg", "gif", "webp", "bmp", "ico", "tif", "TIFF", + ] { + assert!(is_image_path(&format!("folder/image.{extension}"))); + } + for path in ["image.svg", "movie.mp4", "image.png.txt", "png", ""] { + assert!(!is_image_path(path)); + } + } + + #[test] + fn renamed_images_use_distinct_paths_and_revision_sources() { + let diff = "diff --git a/old.png b/new.png\nsimilarity index 100%\nrename from old.png\nrename to new.png\n"; + assert_eq!( + sources_for_diff(diff, "base", Some("tip"), &HashSet::new()), + vec![(0, revision("base", "old.png"), revision("tip", "new.png"))] + ); + assert_eq!( + sources_for_diff(diff, "HEAD", None, &HashSet::new()), + vec![( + 0, + revision("HEAD", "old.png"), + InlineImageSource::Worktree("new.png".into()) + )] + ); + } + + #[test] + fn added_and_deleted_images_keep_missing_sides_and_section_indices() { + let diff = concat!( + "diff --git a/readme.txt b/readme.txt\nBinary files a/readme.txt and b/readme.txt differ\n", + "diff --git a/added.png b/added.png\nnew file mode 100644\nBinary files /dev/null and b/added.png differ\n", + "diff --git a/deleted.jpg b/deleted.jpg\ndeleted file mode 100644\nBinary files a/deleted.jpg and /dev/null differ\n", + ); + assert_eq!( + sources_for_diff(diff, "HEAD", Some(""), &HashSet::new()), + vec![ + (1, InlineImageSource::Missing, revision("", "added.png")), + ( + 2, + revision("HEAD", "deleted.jpg"), + InlineImageSource::Missing + ), + ] + ); + } + + #[test] + fn excludes_either_side_of_a_rename() { + let diff = "diff --git a/old.png b/new.png\nrename from old.png\nrename to new.png\n"; + for excluded in ["old.png", "new.png"] { + let excluded_paths = HashSet::from([excluded.to_string()]); + assert!(sources_for_diff(diff, "HEAD", None, &excluded_paths).is_empty()); + } + } + + #[test] + fn textual_image_hunks_are_never_replaced_and_later_indices_stay_aligned() { + let diff = concat!( + "diff --git a/text.png b/text.png\n--- a/text.png\n+++ b/text.png\n@@ -1 +1 @@\n-old\n+new\n", + "diff --git a/real.png b/real.png\nBinary files a/real.png and b/real.png differ\n", + ); + assert_eq!( + sources_for_diff(diff, "HEAD", None, &HashSet::new()), + vec![( + 1, + revision("HEAD", "real.png"), + InlineImageSource::Worktree("real.png".into()) + )] + ); + assert!(sources_for_diff("", "HEAD", None, &HashSet::new()).is_empty()); + } +} diff --git a/src/pager/image_preview/source.rs b/src/pager/image_preview/source.rs new file mode 100644 index 0000000..1f60142 --- /dev/null +++ b/src/pager/image_preview/source.rs @@ -0,0 +1,234 @@ +//! Bounded worktree/revision reads and decoding for image previews. + +use std::io::Read; +use std::path::Path; + +use image::{DynamicImage, ImageFormat}; + +pub(super) const MAX_COMPRESSED_BYTES: usize = 20 * 1024 * 1024; +pub(super) const MAX_IMAGE_DIMENSION: u32 = 16384; +const MAX_ALLOC_BYTES: u64 = 64 * 1024 * 1024; +pub(super) const PREVIEW_MAX_WIDTH: u32 = 1600; +pub(super) const PREVIEW_MAX_HEIGHT: u32 = 1200; + +/// Where one side of the before/after preview comes from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageSource<'a> { + /// Worktree file at this repo-relative path. + Worktree(&'a str), + /// `revision:path` via `git cat-file`. + Revision { revision: &'a str, path: &'a str }, + /// Legitimately absent (added/deleted). + Missing, +} + +/// Decoded + downsized image with its detected format. +#[derive(Debug, Clone)] +pub(crate) struct DecodedImage { + /// Downsized to `PREVIEW_MAX_*` (aspect preserved). + pub(crate) image: DynamicImage, + pub(crate) format: Option, + pub(crate) orig_width: u32, + pub(crate) orig_height: u32, +} + +impl DecodedImage { + pub(super) fn width(&self) -> u32 { + self.image.width() + } + pub(super) fn height(&self) -> u32 { + self.image.height() + } + pub(super) fn format_label(&self) -> &'static str { + format_label_for(self.format) + } +} + +pub(super) fn format_label_for(format: Option) -> &'static str { + match format { + Some(ImageFormat::Png) => "PNG", + Some(ImageFormat::Jpeg) => "JPEG", + Some(ImageFormat::Gif) => "GIF", + Some(ImageFormat::WebP) => "WEBP", + Some(ImageFormat::Bmp) => "BMP", + Some(ImageFormat::Ico) => "ICO", + Some(ImageFormat::Tiff) => "TIFF", + Some(_) => "IMG", + None => "IMG", + } +} + +/// Decode bounded bytes, then downsize for preview. Returns `None` for empty, +/// oversize, unsupported, or over-limit inputs. +pub(super) fn decode_bytes(bytes: &[u8]) -> Option { + if bytes.is_empty() || bytes.len() > MAX_COMPRESSED_BYTES { + return None; + } + let cursor = std::io::Cursor::new(bytes); + let mut reader = image::ImageReader::new(cursor).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_IMAGE_DIMENSION); + limits.max_image_height = Some(MAX_IMAGE_DIMENSION); + limits.max_alloc = Some(MAX_ALLOC_BYTES); + reader.limits(limits); + let format = reader.format(); + let decoded = reader.decode().ok()?; + let (ow, oh) = (decoded.width(), decoded.height()); + if ow == 0 || oh == 0 || ow > MAX_IMAGE_DIMENSION || oh > MAX_IMAGE_DIMENSION { + return None; + } + let image = if ow > PREVIEW_MAX_WIDTH || oh > PREVIEW_MAX_HEIGHT { + decoded.thumbnail(PREVIEW_MAX_WIDTH, PREVIEW_MAX_HEIGHT) + } else { + decoded + }; + Some(DecodedImage { + image, + format, + orig_width: ow, + orig_height: oh, + }) +} + +pub(super) enum RevisionRead { + Absent, + Present(Vec), + Failed, +} + +fn git_env_no_interactive(cmd: &mut std::process::Command) { + cmd.env("GIT_OPTIONAL_LOCKS", "0"); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + cmd.env("SSH_ASKPASS_REQUIRE", "never"); + if std::env::var_os("GIT_SSH_COMMAND").is_none() { + cmd.env("GIT_SSH_COMMAND", "ssh -oBatchMode=yes"); + } +} + +fn git_blob_exists(cwd: &Path, spec: &str) -> Option { + use std::process::Stdio; + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(cwd) + .args(["cat-file", "-e", spec]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + git_env_no_interactive(&mut cmd); + match cmd.status() { + Ok(s) => Some(s.success()), + Err(_) => None, + } +} + +/// Bounded `git cat-file -p` read. Kills the child when the cap is exceeded +/// so a large blob cannot deadlock on a full pipe. +pub(super) fn read_revision_blob(cwd: &Path, revision: &str, path: &str) -> RevisionRead { + use std::process::Stdio; + if path.is_empty() { + return RevisionRead::Failed; + } + if revision.contains('\0') || path.contains('\0') { + return RevisionRead::Failed; + } + let spec = format!("{revision}:{path}"); + match git_blob_exists(cwd, &spec) { + Some(false) => return RevisionRead::Absent, + None => return RevisionRead::Failed, + Some(true) => {} + } + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(cwd) + .args(["cat-file", "-p", &spec]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .stdout(Stdio::piped()); + git_env_no_interactive(&mut cmd); + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(_) => return RevisionRead::Failed, + }; + let stdout = match child.stdout.take() { + Some(s) => s, + None => { + let _ = child.kill(); + let _ = child.wait(); + return RevisionRead::Failed; + } + }; + let mut limited = stdout.take((MAX_COMPRESSED_BYTES as u64) + 1); + let mut buf = Vec::with_capacity(8192.min(MAX_COMPRESSED_BYTES)); + if limited.read_to_end(&mut buf).is_err() { + let _ = child.kill(); + let _ = child.wait(); + return RevisionRead::Failed; + } + if buf.len() > MAX_COMPRESSED_BYTES { + let _ = child.kill(); + let _ = child.wait(); + return RevisionRead::Failed; + } + match child.wait() { + Ok(s) if s.success() => RevisionRead::Present(buf), + _ => { + if buf.is_empty() { + RevisionRead::Absent + } else { + RevisionRead::Failed + } + } + } +} + +/// Bounded worktree read. `Ok(None)` = legitimately missing; `Err(())` = +/// present but unreadable/oversize. +pub(super) fn read_worktree_bytes(repo_path: &Path, rel: &str) -> Result>, ()> { + if rel.is_empty() || rel.contains('\0') { + return Err(()); + } + let full = repo_path.join(rel); + let file = match std::fs::File::open(&full) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(()), + }; + if file.metadata().is_ok_and(|m| m.is_dir()) { + return Ok(None); + } + let mut limited = file.take((MAX_COMPRESSED_BYTES as u64) + 1); + let mut buf = Vec::new(); + if limited.read_to_end(&mut buf).is_err() { + return Err(()); + } + if buf.len() > MAX_COMPRESSED_BYTES { + return Err(()); + } + Ok(Some(buf)) +} + +pub(super) fn load_one_side(repo: &Path, src: ImageSource<'_>) -> Option> { + match src { + ImageSource::Missing => Some(None), + ImageSource::Worktree(rel) => match read_worktree_bytes(repo, rel) { + Ok(None) => Some(None), + Ok(Some(bytes)) => { + if bytes.is_empty() { + return None; + } + Some(Some(decode_bytes(&bytes)?)) + } + Err(()) => None, + }, + ImageSource::Revision { revision, path } => { + match read_revision_blob(repo, revision, path) { + RevisionRead::Absent => Some(None), + RevisionRead::Present(bytes) => { + if bytes.is_empty() { + return None; + } + Some(Some(decode_bytes(&bytes)?)) + } + RevisionRead::Failed => None, + } + } + } +} diff --git a/src/pager/mod.rs b/src/pager/mod.rs index b7fb6be..fe1f978 100644 --- a/src/pager/mod.rs +++ b/src/pager/mod.rs @@ -1,5 +1,6 @@ pub mod diff_algo; pub mod highlight; +pub mod image_preview; pub mod side_by_side; pub mod word_diff; diff --git a/src/pager/side_by_side.rs b/src/pager/side_by_side.rs index f285724..982e716 100644 --- a/src/pager/side_by_side.rs +++ b/src/pager/side_by_side.rs @@ -4,6 +4,7 @@ use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; +use std::collections::HashMap; use crate::config::Theme; @@ -19,6 +20,8 @@ pub struct FileSection { /// Pre-parsed diff data that can be sent across threads. /// Contains all the expensive-to-compute results (diff algorithm, tree-sitter highlighting). pub struct ParsedDiff { + pub image_preview: Option, + pub inline_images: HashMap, pub filename: String, pub old_content: String, pub new_content: String, @@ -39,6 +42,86 @@ pub enum DiffPanel { New, // Right panel (added / new code) } +/// Inline-image row expansion for multi-file diffs. +/// +/// Each key in `images` is a file-section index whose content is replaced by +/// `INLINE_IMAGE_ROWS` placeholder rows. Headers are preserved; text hunks in +/// later sections are remapped via an old-index -> new-index table. +impl ParsedDiff { + pub fn attach_inline_images( + &mut self, + images: HashMap, + ) { + if images.is_empty() { + return; + } + let sections: std::collections::HashSet = images.keys().copied().collect(); + self.expand_lines_for_image_sections(§ions); + self.inline_images = images; + } + + /// Expand placeholder rows for the given file-section indices. + /// + /// Testable without constructing image values: callers pass section ids, + /// this replaces each section's non-header content with + /// [`inline_image_placeholder_rows`] and remaps text hunks. + fn expand_lines_for_image_sections(&mut self, sections: &std::collections::HashSet) { + if sections.is_empty() { + return; + } + let old_lines = std::mem::take(&mut self.lines); + let mut old_to_new = vec![None; old_lines.len()]; + let mut expanded = std::collections::HashSet::new(); + for (old_index, line) in old_lines.iter().enumerate() { + if line.file_header.is_some() { + old_to_new[old_index] = Some(self.lines.len()); + self.lines.push(line.clone()); + // Headers exist even for rename-only sections with no body. + if sections.contains(&line.section_index) && expanded.insert(line.section_index) { + self.lines + .extend(inline_image_placeholder_rows(line.section_index)); + } + } else if sections.contains(&line.section_index) { + if expanded.insert(line.section_index) { + self.lines + .extend(inline_image_placeholder_rows(line.section_index)); + } + } else { + old_to_new[old_index] = Some(self.lines.len()); + self.lines.push(line.clone()); + } + } + // A single-file pure rename may have no source rows or header at all. + if old_lines.is_empty() && sections.contains(&0) { + self.lines.extend(inline_image_placeholder_rows(0)); + } + let had_staged = !self.hunk_staged.is_empty(); + let mut staged = Vec::new(); + self.hunk_starts = self + .hunk_starts + .iter() + .enumerate() + .filter_map(|(index, old_start)| { + let new_start = old_to_new.get(*old_start).copied().flatten()?; + if had_staged { + staged.push(self.hunk_staged.get(index).copied().unwrap_or(false)); + } + Some(new_start) + }) + .collect(); + if had_staged { + self.hunk_staged = staged; + } + self.hunk_line_offsets = self + .hunk_line_offsets + .iter() + .filter_map(|(index, old, new)| { + Some((old_to_new.get(*index).copied().flatten()?, *old, *new)) + }) + .collect(); + } +} + /// Mouse text selection state in the diff view. #[derive(Clone, Debug)] pub struct TextSelection { @@ -297,6 +380,10 @@ pub const REVERT_UNDO_STACK_CAP: usize = 20; /// State for the diff view panel. pub struct DiffViewState { + pub image_preview: Option, + pub inline_images: HashMap, + /// Graphics are suppressed while an overlay covers the diff pane. + pub image_preview_hidden: bool, pub scroll_offset: usize, pub horizontal_scroll: usize, pub lines: Vec, @@ -356,6 +443,9 @@ pub struct DiffViewState { impl Default for DiffViewState { fn default() -> Self { Self { + image_preview: None, + inline_images: HashMap::new(), + image_preview_hidden: false, scroll_offset: 0, horizontal_scroll: 0, lines: Vec::new(), @@ -698,6 +788,8 @@ impl DiffViewState { new_highlighter: FileHighlighter::new(new, filename), }]; ParsedDiff { + image_preview: None, + inline_images: HashMap::new(), filename: filename.to_string(), old_content: old.to_string(), new_content: new.to_string(), @@ -858,6 +950,8 @@ impl DiffViewState { new_highlighter: FileHighlighter::new(&new, actual_name), }]; ParsedDiff { + image_preview: None, + inline_images: HashMap::new(), filename: actual_name.to_string(), old_content: old, new_content: new, @@ -910,6 +1004,8 @@ impl DiffViewState { let hunk_starts = super::diff_algo::find_hunk_starts(&lines); ParsedDiff { + image_preview: None, + inline_images: HashMap::new(), filename: new_filename, old_content: String::new(), new_content: String::new(), @@ -925,6 +1021,8 @@ impl DiffViewState { /// Apply a pre-parsed diff result, preserving scroll position for same-file reloads. pub fn apply_parsed(&mut self, parsed: ParsedDiff) { + self.image_preview = parsed.image_preview; + self.inline_images = parsed.inline_images; 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; @@ -960,6 +1058,8 @@ impl DiffViewState { /// Load a diff from old/new content (single file). pub fn load(&mut self, filename: &str, old: &str, new: &str) { + self.image_preview = None; + self.inline_images.clear(); // 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; @@ -1002,6 +1102,8 @@ impl DiffViewState { /// Load from raw diff output (git diff). /// Automatically detects multi-file diffs and splits into per-file sections. pub fn load_from_diff_output(&mut self, filename: &str, diff_output: &str) { + self.image_preview = None; + self.inline_images.clear(); let file_diffs = parse_multi_file_diff(diff_output); if file_diffs.len() <= 1 { @@ -1809,12 +1911,21 @@ pub fn render_diff( let inner = block.inner(area); frame.render_widget(block, area); + if !state.image_preview_hidden { + if let Some(preview) = &mut state.image_preview { + super::image_preview::render(frame, inner, preview, state.side_view, theme); + return; + } + } + // Only a lone unavailable file fills the viewport. In multi-file buffers, // the five placeholder rows below remain ordinary scrollable rows. - if state - .lines - .iter() - .all(|line| line.preview_placeholder.is_some()) + // Inline images use compact rows even when lone; never take the fallback. + if state.inline_images.is_empty() + && state + .lines + .iter() + .all(|line| line.preview_placeholder.is_some()) { let message = state .lines @@ -1846,19 +1957,22 @@ pub fn render_diff( }; let visible_height = inner.height as usize; - let buf = frame.buffer_mut(); - if state.view_layout == DiffViewLayout::Unified { - render_unified_diff_body( - buf, - inner, - state, - theme, - visible_height, - show_revert_markers, - ); + { + let buf = frame.buffer_mut(); + render_unified_diff_body( + buf, + inner, + state, + theme, + visible_height, + show_revert_markers, + ); + } + render_inline_image_regions(frame, area, inner, state, theme); return; } + let buf = frame.buffer_mut(); if single_side.is_some() || is_new_file { // Single-panel mode: new file, old-only, or new-only @@ -1889,12 +2003,17 @@ pub fn render_diff( } if let Some(message) = diff_line.preview_placeholder { - render_preview_placeholder( - buf, - Rect::new(inner.x, inner.y + row as u16, inner.width, 1), - message, - theme, - ); + // Inline images overlay these rows; skip stripes when visible. + let has_inline = !state.image_preview_hidden + && state.inline_images.contains_key(&diff_line.section_index); + if !has_inline { + render_preview_placeholder( + buf, + Rect::new(inner.x, inner.y + row as u16, inner.width, 1), + message, + theme, + ); + } row += 1; continue; } @@ -2077,12 +2196,17 @@ pub fn render_diff( .unwrap_or((&default_hl, &default_hl)); if let Some(message) = diff_line.preview_placeholder { - render_preview_placeholder( - buf, - Rect::new(inner.x, inner.y + row as u16, inner.width, 1), - message, - theme, - ); + // Inline images overlay these rows; skip stripes when visible. + let has_inline = !state.image_preview_hidden + && state.inline_images.contains_key(&diff_line.section_index); + if !has_inline { + render_preview_placeholder( + buf, + Rect::new(inner.x, inner.y + row as u16, inner.width, 1), + message, + theme, + ); + } row += 1; continue; } @@ -2405,6 +2529,75 @@ pub fn render_diff( ); } } + render_inline_image_regions(frame, area, inner, state, theme); +} + +/// Overlay inline images onto their placeholder rows (viewport only). +/// +/// Gathers contiguous visible placeholder rows per file section via +/// `line_chunk_at_row` (which already accounts for wrap, sticky headers and +/// layout), then draws each section's image into its visible region. Text and +/// sticky header rows never join a region; partially scrolled sections use +/// only the visible sub-region so following content is never overwritten. +fn render_inline_image_regions( + frame: &mut Frame, + area: Rect, + inner: Rect, + state: &mut DiffViewState, + theme: &Theme, +) { + if state.image_preview_hidden || state.inline_images.is_empty() { + return; + } + if inner.width == 0 || inner.height == 0 { + return; + } + let layout = DiffPanelLayout::compute(area, state); + // Group adjacent visible image rows; headers and wrapped text stay outside + // these rectangles, so graphics cannot overwrite their source content. + let mut runs: Vec<(usize, u16, u16)> = Vec::new(); + for y in inner.y..inner.bottom() { + if state.is_sticky_row(y, &layout) { + continue; + } + let Some((line_index, _)) = state.line_chunk_at_row(y, &layout) else { + continue; + }; + let Some(line) = state.lines.get(line_index) else { + continue; + }; + if line.file_header.is_some() + || line.preview_placeholder.is_none() + || !state.inline_images.contains_key(&line.section_index) + { + continue; + } + if let Some((section, _, end)) = runs.last_mut() { + if *section == line.section_index && *end == y { + *end = y + 1; + continue; + } + } + runs.push((line.section_index, y, y + 1)); + } + let visible: std::collections::HashSet<_> = + runs.iter().map(|(section, _, _)| *section).collect(); + for (section, preview) in &mut state.inline_images { + if !visible.contains(section) { + preview.unload(); + } + } + let side = state.side_view; + for (sec, start_y, end_y) in runs { + let h = end_y.saturating_sub(start_y); + if h == 0 { + continue; + } + let rect = Rect::new(inner.x, start_y, inner.width, h); + if let Some(preview) = state.inline_images.get_mut(&sec) { + preview.render(frame, rect, side, theme); + } + } } fn render_unified_diff_body( @@ -2462,12 +2655,17 @@ fn render_unified_diff_body( if skip > 0 { skip -= 1; } else { - render_preview_placeholder( - buf, - Rect::new(inner.x, inner.y + row as u16, inner.width, 1), - message, - theme, - ); + // Inline images overlay these rows; skip stripes when visible. + let has_inline = !state.image_preview_hidden + && state.inline_images.contains_key(&diff_line.section_index); + if !has_inline { + render_preview_placeholder( + buf, + Rect::new(inner.x, inner.y + row as u16, inner.width, 1), + message, + theme, + ); + } row += 1; } line_idx += 1; @@ -3514,7 +3712,7 @@ pub fn render_diff_search_bar(frame: &mut Frame, area: Rect, state: &DiffViewSta /// Parse a multi-file unified diff into per-file sections. /// Returns Vec of (filename, raw_diff_slice) without re-joining bodies. -fn parse_multi_file_diff(diff: &str) -> Vec<(String, &str)> { +pub(crate) fn parse_multi_file_diff(diff: &str) -> Vec<(String, &str)> { let bytes = diff.as_bytes(); let mut sections: Vec<(String, &str)> = Vec::new(); let mut current_filename = String::new(); @@ -3538,12 +3736,9 @@ fn parse_multi_file_diff(diff: &str) -> Vec<(String, &str)> { )); } current_filename = extract_filename_from_diff_header(line); - let after = if line_end < bytes.len() { - line_end + 1 - } else { - line_end - }; - section_start = Some(after); + // Keep the header: binary sections have no ---/+++ paths, and + // delayed image loads need the exact old/new source identities. + section_start = Some(line_start); } if line_end >= bytes.len() { @@ -3620,15 +3815,8 @@ fn build_file_sections_parallel(section_meta: &[(&str, &str)]) -> Vec String { - // Format: "diff --git a/some/path b/some/path" - // We want "some/path" (the b/ side, which is the new name) - if let Some(b_part) = line.split(" b/").last() { - b_part.to_string() - } else { - // Fallback: strip "diff --git " prefix - line.trim_start_matches("diff --git ").to_string() - } +fn extract_filename_from_diff_header(header: &str) -> String { + crate::git::diff_paths::new_path_from_header(header).unwrap_or_default() } /// Parse a unified diff into old/new content for side-by-side display. @@ -3721,6 +3909,37 @@ fn binary_file_placeholder_lines() -> Vec { .collect() } +/// Placeholder rows backing one inline-image section. +/// +/// Uses the existing `DiffLine` placeholder model (no new field): all rows are +/// hatch stripes, the middle row carries the fallback label so hidden images +/// still read usefully. Length is `INLINE_IMAGE_ROWS`. +fn inline_image_placeholder_rows(section_index: usize) -> Vec { + let total_rows = super::image_preview::INLINE_IMAGE_ROWS; + let mid = total_rows / 2; + (0..total_rows) + .map(|r| { + let message: &'static str = if r == mid { + "Binary cannot be previewed" + } else if r + 1 == mid || r == mid + 1 { + " " + } else { + "" + }; + DiffLine { + old_line: None, + new_line: None, + change_type: ChangeType::Equal, + old_segments: None, + new_segments: None, + file_header: None, + preview_placeholder: Some(message), + section_index, + } + }) + .collect() +} + /// True when git emitted a rename/copy with no content hunks (pure move). pub fn is_rename_only_diff(diff: &str) -> bool { rename_only_paths(diff).is_some() @@ -4511,4 +4730,210 @@ mod tests { ); assert!(!parsed.hunk_starts.is_empty()); } + + #[test] + fn inline_rows_match_const_and_middle_label() { + assert_eq!(super::super::image_preview::INLINE_IMAGE_ROWS, 12); + let rows = super::inline_image_placeholder_rows(3); + assert_eq!(rows.len(), 12); + assert!(rows.iter().all(|l| l.file_header.is_none())); + assert!(rows.iter().all(|l| l.section_index == 3)); + assert!( + rows.iter() + .all(|l| l.old_line.is_none() && l.new_line.is_none()) + ); + let mid = 12 / 2; + assert_eq!( + rows[mid].preview_placeholder, + Some("Binary cannot be previewed") + ); + // Neighbours are blank padding, rest are empty hatch rows. + assert_eq!( + rows[mid - 1].preview_placeholder, + Some(" ") + ); + assert_eq!( + rows[mid + 1].preview_placeholder, + Some(" ") + ); + assert_eq!(rows[0].preview_placeholder, Some("")); + } + + #[test] + fn attach_empty_is_noop() { + let mut parsed = DiffViewState::parse_diff_output("foo.png", BINARY_DIFF, 4, true); + let before = parsed.lines.len(); + parsed.attach_inline_images(std::collections::HashMap::< + usize, + super::super::image_preview::InlineImagePreview, + >::new()); + assert_eq!(parsed.lines.len(), before); + assert!(parsed.inline_images.is_empty()); + } + + #[test] + fn image_expansion_replaces_binary_and_remaps_text_hunks() { + let diff = format!("{BINARY_DIFF}{TEXT_DIFF}"); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + // Preconditions: 2 file headers + 5 binary rows + text hunk. + let headers: Vec = parsed + .lines + .iter() + .enumerate() + .filter_map(|(i, l)| l.file_header.as_ref().map(|_| i)) + .collect(); + assert_eq!(headers.len(), 2); + let binary_section = parsed.lines[headers[0] + 1].section_index; + let text_section = parsed.lines[headers[1] + 1].section_index; + assert_ne!(binary_section, text_section); + let binary_rows = parsed + .lines + .iter() + .filter(|l| l.preview_placeholder.is_some()) + .count(); + assert_eq!(binary_rows, 5); + let old_hunk = parsed.hunk_starts[0]; + let old_offsets = parsed.hunk_line_offsets.clone(); + let text_header = parsed.lines[headers[1]] + .file_header + .clone() + .expect("text file header"); + + let mut sections = std::collections::HashSet::new(); + sections.insert(binary_section); + parsed.expand_lines_for_image_sections(§ions); + + // Binary block grew 5 -> 12 (+7); header preserved. + assert!(parsed.lines[headers[0]].file_header.is_some()); + let new_binary_rows: Vec<_> = parsed + .lines + .iter() + .filter(|l| l.preview_placeholder.is_some() && l.section_index == binary_section) + .collect(); + assert_eq!(new_binary_rows.len(), 12); + assert_eq!( + new_binary_rows[6].preview_placeholder, + Some("Binary cannot be previewed") + ); + // Text hunks shifted by +7, offsets remapped, staged untouched. + assert_eq!(parsed.hunk_starts.len(), 1); + assert_eq!(parsed.hunk_starts[0], old_hunk + 7); + assert_eq!(parsed.hunk_line_offsets.len(), old_offsets.len()); + for ((new_idx, _, _), (old_idx, _, _)) in + parsed.hunk_line_offsets.iter().zip(old_offsets.iter()) + { + assert_eq!(*new_idx, *old_idx + 7); + } + assert!(parsed.hunk_staged.is_empty()); + // Text content still resolves to the same file. + let mut state = DiffViewState::new(); + state.apply_parsed(parsed); + assert_eq!( + state.file_line_number(state.hunk_starts[0], DiffPanel::New), + Some(1) + ); + assert_eq!(state.file_at_line(state.hunk_starts[0]), text_header); + } + + #[test] + fn rename_only_section_expands_to_image_rows() { + let diff = "diff --git a/old.png b/new.png\nsimilarity index 100%\nrename from old.png\nrename to new.png\n"; + let mut parsed = DiffViewState::parse_diff_output("old.png -> new.png", diff, 4, true); + assert_eq!(parsed.lines.len(), 1); + let mut sections = std::collections::HashSet::new(); + sections.insert(0); + parsed.expand_lines_for_image_sections(§ions); + assert_eq!(parsed.lines.len(), 12); + assert!(parsed.lines.iter().all(|l| l.preview_placeholder.is_some())); + assert_eq!( + parsed.lines[6].preview_placeholder, + Some("Binary cannot be previewed") + ); + assert!(parsed.hunk_starts.is_empty()); + assert!(parsed.hunk_line_offsets.is_empty()); + } + + #[test] + fn image_rows_map_via_line_chunk_and_sticky() { + let diff = format!("{BINARY_DIFF}{TEXT_DIFF}"); + let mut parsed = DiffViewState::parse_diff_output("mixed", &diff, 4, true); + let binary_section = parsed.lines[1].section_index; + let mut sections = std::collections::HashSet::new(); + sections.insert(binary_section); + parsed.expand_lines_for_image_sections(§ions); + let mut state = DiffViewState::new(); + state.apply_parsed(parsed); + for layout in [DiffViewLayout::SideBySide, DiffViewLayout::Unified] { + for wrap in [false, true] { + state.view_layout = layout; + state.wrap = wrap; + state.scroll_offset = 0; + let area = Rect::new(0, 0, 80, 18); + let panel = DiffPanelLayout::compute(area, &state); + // First content row after the header is an image placeholder. + // Header at row 1, image rows follow; sticky only when scrolled. + assert_eq!(state.line_chunk_at_row(1, &panel), Some((0, 0))); + // Image block rows map to placeholder lines in the image section. + let mapped = state.line_chunk_at_row(2, &panel).expect("image row maps"); + let line = &state.lines[mapped.0]; + assert!(line.preview_placeholder.is_some()); + assert_eq!(line.section_index, binary_section); + // Scroll into the middle of the image block: sticky pins header, + // next file stays aligned and text hunks still resolve. + state.scroll_offset = if layout == DiffViewLayout::Unified { + 3 + } else { + 3 + }; + let buf = render_preview(&mut state, 80, 10); + assert!(buffer_row(&buf, 1).contains("foo.png")); + // No text row is mistaken for an image row. + let panel2 = DiffPanelLayout::compute(buf.area, &state); + for y in panel2.inner_y..panel2.inner_end_y { + if state.is_sticky_row(y, &panel2) { + continue; + } + if let Some((idx, _)) = state.line_chunk_at_row(y, &panel2) { + let l = &state.lines[idx]; + if l.file_header.is_some() { + continue; + } + if l.preview_placeholder.is_none() { + assert_ne!(l.section_index, binary_section); + } + } + } + state.scroll_offset = 0; + } + } + } + + #[test] + fn inline_regions_noop_when_hidden_or_empty() { + let mut state = DiffViewState::new(); + state.load_from_diff_output("foo.png", BINARY_DIFF); + // Empty inline map: normal stripes still render, overlay is a noop. + let buf = render_preview(&mut state, 60, 12); + assert!(buffer_row(&buf, 6).contains("Binary cannot be previewed")); + // Hidden flag with empty map also renders stripes without panic. + state.image_preview_hidden = true; + let buf2 = render_preview(&mut state, 60, 12); + assert!(buffer_row(&buf2, 6).contains("Binary cannot be previewed")); + } + #[test] + fn split_binary_sections_keep_paths_for_lazy_image_loading() { + let diff = "diff --git a/assets/old name.png b/assets/new name.png\n\ + similarity index 90%\nrename from assets/old name.png\n\ + rename to assets/new name.png\n\ + Binary files a/assets/old name.png and b/assets/new name.png differ\n\ + diff --git a/text.txt b/text.txt\n--- a/text.txt\n+++ b/text.txt\n\ + @@ -1 +1 @@\n-old\n+new\n"; + let sections = parse_multi_file_diff(diff); + assert_eq!(sections.len(), 2); + assert!(sections[0].1.starts_with("diff --git ")); + let paths = crate::git::diff_paths::paths_from_diff(sections[0].1).unwrap(); + assert_eq!(paths.old.as_deref(), Some("assets/old name.png")); + assert_eq!(paths.new.as_deref(), Some("assets/new name.png")); + assert_eq!(sections[1].0, "text.txt"); + } } From b0ae7776a185e491d2558f513d00ea0fa3fd2021 Mon Sep 17 00:00:00 2001 From: Felipe cm Date: Tue, 6 Oct 2026 11:44:35 -0300 Subject: [PATCH 08/11] feat: add Esc filter reset, Ctrl+L shortcut, and branch checklist clear (#36) --- src/gui/controller/commits.rs | 113 +++++++++++++++++++----- src/gui/mod.rs | 9 ++ src/gui/views.rs | 157 +++++++++++++++++++++++++++++++++- 3 files changed, 257 insertions(+), 22 deletions(-) diff --git a/src/gui/controller/commits.rs b/src/gui/controller/commits.rs index 7f4b93e..b4f8f41 100644 --- a/src/gui/controller/commits.rs +++ b/src/gui/controller/commits.rs @@ -16,13 +16,24 @@ use crate::model::Branch; use crate::os::platform::Platform; pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) -> Result<()> { - // Esc: cancel range select first, then clear clipboard + // Esc: cancel range select first, then clear active commit filter, then clear clipboard if key.code == crossterm::event::KeyCode::Esc { if gui.range_select_anchor.is_some() { gui.range_select_anchor = None; return Ok(()); } + if gui.commit_path_filter.is_some() + || !gui.commit_author_filter.is_empty() + || !gui.commit_branch_filter.is_empty() + { + gui.commit_path_filter = None; + gui.commit_author_filter.clear(); + gui.commit_branch_filter.clear(); + apply_commit_filters_and_focus(gui)?; + return Ok(()); + } + if !gui.cherry_pick_clipboard.is_empty() { gui.cherry_pick_clipboard.clear(); return Ok(()); @@ -133,7 +144,11 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) } // Open commit filtering menu - if matches_key(key, &keybindings.commits.open_log_menu) { + if matches_key(key, &keybindings.commits.open_log_menu) + || (key.modifiers == crossterm::event::KeyModifiers::CONTROL + && (key.code == crossterm::event::KeyCode::Char('l') + || key.code == crossterm::event::KeyCode::Char('L'))) + { return show_filtering_menu(gui); } @@ -1324,26 +1339,40 @@ fn apply_commit_filters(gui: &mut Gui) -> Result<()> { Ok(()) } -fn show_branch_filter_menu(gui: &mut Gui) -> Result<()> { - use crate::gui::popup::ChecklistItem; +fn build_branch_checklist_items( + branches: &[Branch], + current_filter: &[String], +) -> Vec { + let mut items = vec![ChecklistItem { + label: "".to_string(), + checked: false, + is_free_entry: false, + }]; + + for b in branches { + let checked = current_filter.contains(&b.name); + items.push(ChecklistItem { + label: b.name.clone(), + checked, + is_free_entry: false, + }); + } - let model = gui.model.lock().unwrap(); - let branches: Vec = model.branches.iter().map(|b| b.name.clone()).collect(); - drop(model); + items +} - let current_filter = &gui.commit_branch_filter; +fn resolve_branch_filter_selection(checked: Vec) -> Vec { + if checked.iter().any(|val| val == "") { + Vec::new() + } else { + checked + } +} - let items: Vec = branches - .into_iter() - .map(|name| { - let checked = current_filter.contains(&name); - ChecklistItem { - label: name, - checked, - is_free_entry: false, - } - }) - .collect(); +fn show_branch_filter_menu(gui: &mut Gui) -> Result<()> { + let model = gui.model.lock().unwrap(); + let items = build_branch_checklist_items(&model.branches, &gui.commit_branch_filter); + drop(model); gui.popup = PopupState::Checklist { title: "Filter commits by branch".to_string(), @@ -1351,7 +1380,7 @@ fn show_branch_filter_menu(gui: &mut Gui) -> Result<()> { selected: 0, search_textarea: crate::gui::popup::make_checklist_search_textarea(), on_confirm: Box::new(|gui: &mut Gui, checked: Vec| { - gui.commit_branch_filter = checked; + gui.commit_branch_filter = resolve_branch_filter_selection(checked); apply_commit_filters_and_focus(gui) }), free_entry_category: None, @@ -1551,4 +1580,48 @@ mod tests { fn treats_blank_filter_values_as_unset() { assert_eq!(nonempty(" "), None); } + + #[test] + fn build_branch_checklist_items_starts_with_unchecked_clear_filter() { + use super::build_branch_checklist_items; + + let branches = vec![branch("main", "1111111"), branch("feature", "2222222")]; + let current_filter = vec!["main".to_string()]; + let items = build_branch_checklist_items(&branches, ¤t_filter); + + assert_eq!(items.len(), 3); + assert_eq!(items[0].label, ""); + assert!(!items[0].checked); + + assert_eq!(items[1].label, "main"); + assert!(items[1].checked); + + assert_eq!(items[2].label, "feature"); + assert!(!items[2].checked); + } + + #[test] + fn resolve_branch_filter_selection_clears_when_clear_filter_selected() { + use super::resolve_branch_filter_selection; + + let checked = vec!["".to_string(), "main".to_string()]; + assert!(resolve_branch_filter_selection(checked).is_empty()); + + let checked = vec!["".to_string()]; + assert!(resolve_branch_filter_selection(checked).is_empty()); + } + + #[test] + fn resolve_branch_filter_selection_preserves_selected_branches() { + use super::resolve_branch_filter_selection; + + let checked = vec!["main".to_string(), "feature".to_string()]; + assert_eq!( + resolve_branch_filter_selection(checked), + vec!["main".to_string(), "feature".to_string()] + ); + + let checked: Vec = Vec::new(); + assert!(resolve_branch_filter_selection(checked).is_empty()); + } } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 361b778..235229b 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -6010,6 +6010,15 @@ impl Gui { ), ); } + if self.commit_path_filter.is_some() + || !self.commit_author_filter.is_empty() + || !self.commit_branch_filter.is_empty() + { + entries.insert( + 0, + CommandEntry::keybinding("".into(), "Reset commit filter".into()), + ); + } CommandSection { title: "Commits".into(), entries, diff --git a/src/gui/views.rs b/src/gui/views.rs index 2ad4155..dc846f9 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use crate::config::{AppConfig, Theme}; +use crate::config::{AppConfig, KeybindingConfig, Theme}; use crate::model::Model; use crate::model::commit::{Commit, CommitStat}; use crate::model::file_tree::{CommitFileTreeNode, FileTreeNode}; @@ -417,6 +417,8 @@ pub fn render( model, diff_focused, !cherry_pick_clipboard.is_empty(), + &config.user_config.keybinding, + !active_commit_filters.is_empty(), ); // Render text selection highlight overlay and tooltip (must be before popup) render_selection_overlay(frame, diff_view, fl.main_panel, theme); @@ -1076,6 +1078,8 @@ pub fn render( model, diff_focused, !cherry_pick_clipboard.is_empty(), + &config.user_config.keybinding, + !active_commit_filters.is_empty(), ); // Render text selection highlight overlay and tooltip @@ -1636,6 +1640,101 @@ mod tests { assert_eq!(checklist_item_at(&popup, area, x, 12), None); // search row assert_eq!(checklist_item_at(&popup, area, x, 13), None); // separator } + + #[test] + fn format_key_hint_normalizes_standard_and_custom_bindings() { + use super::format_key_hint; + + assert_eq!(format_key_hint(""), "ctrl+s"); + assert_eq!(format_key_hint(""), "ctrl+l"); + assert_eq!(format_key_hint(""), "ctrl+l"); + assert_eq!(format_key_hint("ctrl+l"), "ctrl+l"); + assert_eq!(format_key_hint(""), "space"); + assert_eq!(format_key_hint(""), "esc"); + assert_eq!(format_key_hint(""), "enter"); + assert_eq!(format_key_hint("c-s"), "ctrl+s"); + assert_eq!(format_key_hint("alt-x"), "alt+x"); + assert_eq!(format_key_hint("shift-tab"), "shift+tab"); + } + + #[test] + fn render_status_bar_displays_filter_branch_and_esc_reset() { + use super::{DiffViewState, render_status_bar}; + use crate::config::KeybindingConfig; + use crate::gui::context::{ContextId, ContextManager}; + use crate::model::Model; + + let backend = TestBackend::new(120, 2); + let mut terminal = Terminal::new(backend).expect("test terminal"); + let model = Model::default(); + let keybindings = KeybindingConfig::default(); + let mut ctx_mgr = ContextManager::new(); + ctx_mgr.set_active(ContextId::Commits); + let diff_view = DiffViewState::new(); + let theme = Theme::default(); + + // With active filters + terminal + .draw(|frame| { + render_status_bar( + frame, + Rect::new(0, 0, 120, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &keybindings, + true, + ); + }) + .expect("should render status bar with active filters"); + + let buffer = terminal.backend().buffer(); + let content: String = buffer.content().iter().map(|c| c.symbol()).collect(); + assert!( + content.contains("reset filter"), + "expected 'reset filter' in status bar: {}", + content + ); + assert!( + content.contains("filter branch"), + "expected 'filter branch' in status bar: {}", + content + ); + + // Without active filters + terminal + .draw(|frame| { + render_status_bar( + frame, + Rect::new(0, 0, 120, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &keybindings, + false, + ); + }) + .expect("should render status bar without active filters"); + + let buffer = terminal.backend().buffer(); + let content: String = buffer.content().iter().map(|c| c.symbol()).collect(); + assert!( + !content.contains("reset filter"), + "did not expect 'reset filter' when no filters active: {}", + content + ); + assert!( + content.contains("filter branch"), + "expected 'filter branch' in status bar: {}", + content + ); + } } /// Build a window title like " 4 Commit Files (abc1234 feat: some change) ". @@ -2390,6 +2489,48 @@ fn get_info_content<'a>(model: &Model, ctx_mgr: &ContextManager) -> Vec } } +fn format_key_hint(key: &str) -> String { + let key = key.trim(); + let inner = if let Some(stripped) = key.strip_prefix('<').and_then(|k| k.strip_suffix('>')) { + stripped.trim() + } else { + key + }; + let inner_lower = inner.to_ascii_lowercase(); + if let Some(rest) = inner_lower + .strip_prefix("c-") + .or_else(|| inner_lower.strip_prefix("c+")) + .or_else(|| inner_lower.strip_prefix("ctrl-")) + .or_else(|| inner_lower.strip_prefix("ctrl+")) + { + format!("ctrl+{}", rest) + } else if let Some(rest) = inner_lower + .strip_prefix("a-") + .or_else(|| inner_lower.strip_prefix("a+")) + .or_else(|| inner_lower.strip_prefix("alt-")) + .or_else(|| inner_lower.strip_prefix("alt+")) + { + format!("alt+{}", rest) + } else if let Some(rest) = inner_lower + .strip_prefix("s-") + .or_else(|| inner_lower.strip_prefix("s+")) + .or_else(|| inner_lower.strip_prefix("shift-")) + .or_else(|| inner_lower.strip_prefix("shift+")) + { + format!("shift+{}", rest) + } else if inner_lower == "enter" || inner_lower == "return" { + "enter".to_string() + } else if inner_lower == "escape" || inner_lower == "esc" { + "esc".to_string() + } else if inner_lower == "space" { + "space".to_string() + } else if inner_lower == "backtab" || inner_lower == "shift-tab" || inner_lower == "shift+tab" { + "shift+tab".to_string() + } else { + key.to_string() + } +} + fn render_search_bar_or_status_bar( frame: &mut Frame, status_bar: Rect, @@ -2401,6 +2542,8 @@ fn render_search_bar_or_status_bar( model: &Model, diff_focused: bool, has_copied_commits: bool, + keybindings: &KeybindingConfig, + has_active_filters: bool, ) { if let Some((query, match_count, current_match)) = search_state { let match_info = if match_count > 0 { @@ -2458,6 +2601,8 @@ fn render_search_bar_or_status_bar( model, diff_focused, has_copied_commits, + keybindings, + has_active_filters, ); } } @@ -2471,9 +2616,12 @@ fn render_status_bar( model: &Model, diff_focused: bool, has_copied_commits: bool, + keybindings: &KeybindingConfig, + has_active_filters: bool, ) { let mut hints: Vec<(&str, &str)> = Vec::new(); let mut emphasized: Vec<&str> = Vec::new(); + let open_log_menu_key = format_key_hint(&keybindings.commits.open_log_menu); // When in a special state (rebasing/merging/cherry-picking), show those options prominently if model.is_rebasing { @@ -2561,6 +2709,9 @@ fn render_status_bar( ]); } ContextId::Commits => { + if has_active_filters { + hints.push(("esc", "reset filter")); + } if has_copied_commits { hints.push(("V", "paste (cherry-pick)")); } @@ -2570,8 +2721,10 @@ fn render_status_bar( ("g", "reset"), ("t", "revert"), ("\\", view_layout_hint), - ("ctrl+l", "filter branch"), ]); + if !open_log_menu_key.is_empty() { + hints.push((open_log_menu_key.as_str(), "filter branch")); + } } ContextId::Stash => { hints.extend([ From 4ca533ac36ad0137aed48a10bfed847d31a124bb Mon Sep 17 00:00:00 2001 From: Felipe cm Date: Tue, 6 Oct 2026 12:12:43 -0300 Subject: [PATCH 09/11] feat: add --commits cli flag and ctrl+g dual-diff toggle between files and head (#34) Co-authored-by: Blankeos --- README.md | 6 + src/app.rs | 15 +- src/config/keybindings.rs | 3 + src/config/mod.rs | 14 ++ src/gui/controller/diff_mode.rs | 2 +- src/gui/mod.rs | 423 +++++++++++++++++++++++++++++++- src/gui/views.rs | 253 ++++++++++++++++++- src/main.rs | 20 +- 8 files changed, 727 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index adfe4b1..3ffbcd0 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ Then run: lazygitrs ``` +Use `lazygitrs --commits` to start at the checked-out HEAD commit. Press +`Ctrl+G` to toggle between working-tree changes and HEAD (including from a +focused diff). If filters or pagination hide HEAD, it opens a separate HEAD +history view without clearing your filters. Remap or disable the shortcut with +`keybinding.universal.toggleWorkingTreeAndHead` (`""` by default; `""` disables it). + ### Upgrade Detects how you installed (brew / npm / bun / cargo / install.sh) and upgrades in place: diff --git a/src/app.rs b/src/app.rs index be1b613..e695639 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,10 +10,16 @@ pub struct App { pub config: AppConfig, pub repo_path: PathBuf, pub filter_path: Option, + pub start_in_commits: bool, } impl App { - pub fn new(repo_path: PathBuf, debug: bool, filter_path: Option) -> Result { + pub fn new( + repo_path: PathBuf, + debug: bool, + filter_path: Option, + start_in_commits: bool, + ) -> Result { let config = AppConfig::load(debug)?; // Validate git repo @@ -25,6 +31,7 @@ impl App { config, repo_path, filter_path, + start_in_commits, }) } @@ -36,9 +43,9 @@ impl App { self.config.app_state.add_recent_repo(&repo_str); let _ = self.config.save_state(); - // Pass `-f` into Gui::new so the initial model stream loads filtered - // commits immediately (no wait for full refresh + second git log). - let mut gui = Gui::new(self.config, git, self.filter_path)?; + // Pass `-f` and start_in_commits into Gui::new so the initial model stream loads + // filtered commits or commits tab immediately. + let mut gui = Gui::new(self.config, git, self.filter_path, self.start_in_commits)?; gui.run()?; Ok(()) diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 0b992f6..c619c94 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -126,6 +126,8 @@ pub struct UniversalKeybinding { pub reset_side_panel: String, #[serde(rename = "toggleDiffViewLayout")] pub toggle_diff_view_layout: String, + #[serde(rename = "toggleWorkingTreeAndHead")] + pub toggle_working_tree_and_head: String, } impl Default for UniversalKeybinding { @@ -181,6 +183,7 @@ impl Default for UniversalKeybinding { main_panel_full: "".into(), reset_side_panel: "".into(), toggle_diff_view_layout: "\\".into(), + toggle_working_tree_and_head: "".into(), } } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 8898b66..abfdd5b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -31,6 +31,20 @@ pub struct AppConfig { pub state_path: PathBuf, } +impl Default for AppConfig { + fn default() -> Self { + Self { + debug: false, + version: env!("CARGO_PKG_VERSION").to_string(), + user_config: UserConfig::default(), + app_state: AppState::default(), + config_dir: PathBuf::from("."), + state_dir: PathBuf::from("."), + state_path: PathBuf::from("state.yml"), + } + } +} + impl AppConfig { pub fn load(debug: bool) -> Result { let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index abe46ed..06f5c40 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -1205,7 +1205,7 @@ mod tests { state_dir: repo.0.clone(), state_path: repo.0.join("state.yml"), }; - let mut gui = Gui::new(config, GitCommands::new(&repo.0).unwrap(), None).unwrap(); + let mut gui = Gui::new(config, GitCommands::new(&repo.0).unwrap(), None, false).unwrap(); gui.diff_mode.enter(false); gui.diff_mode.ref_a = "main".into(); gui.diff_mode.ref_b = "feature".into(); diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 235229b..2cd5806 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -722,6 +722,8 @@ pub struct Gui { pub show_commit_file_tree: bool, /// Name of the branch/tag whose commits are being viewed in BranchCommits context. pub branch_commits_name: String, + /// Select HEAD once startup history arrives, unless the user navigates first. + pending_startup_head: bool, /// Name of the remote whose branches are being viewed in RemoteBranches context. pub remote_branches_name: String, /// Parent context to return to when pressing Esc from BranchCommits. @@ -958,7 +960,12 @@ impl Gui { }; } - pub fn new(config: AppConfig, git: GitCommands, filter_path: Option) -> Result { + pub fn new( + config: AppConfig, + git: GitCommands, + filter_path: Option, + start_in_commits: bool, + ) -> Result { let (diff_tx, diff_rx) = mpsc::channel(); let (diff_scheduler_tx, diff_scheduler_rx) = mpsc::channel(); let (diff_prefetch_tx, diff_prefetch_rx) = mpsc::channel(); @@ -1050,8 +1057,9 @@ impl Gui { .unwrap_or(0); let mut context_mgr = ContextManager::new(); - if startup_path_filter.is_some() { + if startup_path_filter.is_some() || start_in_commits { context_mgr.set_active(ContextId::Commits); + context_mgr.set_selection_for(ContextId::Commits, 0); } Ok(Self { @@ -1137,6 +1145,7 @@ impl Gui { commit_files_collapsed_dirs: HashSet::new(), show_commit_file_tree: show_file_tree, branch_commits_name: String::new(), + pending_startup_head: start_in_commits, remote_branches_name: String::new(), sub_commits_parent_context: context::ContextId::Branches, commit_files_parent_context: None, @@ -1296,6 +1305,7 @@ impl Gui { // Drain any model parts that have arrived from the background load. if let Some(rx) = &self.initial_load_rx { let mut got_files = false; + let mut got_commits = false; let mut got_rebase_in_progress = false; let received_before = self.initial_load_received; while let Ok(part) = rx.try_recv() { @@ -1307,6 +1317,7 @@ impl Gui { } ModelPart::Branches(v) => model.branches = v, ModelPart::Commits(v) => { + got_commits = true; // Stream already applies the active filter when one is set // (`load_model_streaming(commit_filter)`), so always take it. self.commit_history_complete = v.len() < DEFAULT_COMMIT_LIMIT; @@ -1347,6 +1358,11 @@ impl Gui { } self.initial_load_received += 1; } + if got_commits { + if let Err(err) = self.select_startup_head() { + self.show_error("Could not select HEAD", err); + } + } // Enter the InProgress rebase view as soon as we know a rebase // is on disk — don't wait for a future `refresh()` tick (focus // event / auto-refresh interval), which is what made the view @@ -3523,6 +3539,7 @@ impl Gui { } fn handle_key(&mut self, key: KeyEvent) -> Result<()> { + self.pending_startup_head = false; if self.handle_ai_commit_cancel_key(key) { return Ok(()); } @@ -3588,6 +3605,11 @@ impl Gui { return self.handle_diff_focused_key(key); } + // Toggle between working-tree changes and the actual checked-out HEAD. + if matches_key(key, &keybindings.universal.toggle_working_tree_and_head) { + return self.toggle_working_tree_and_head(); + } + // Global keybindings if matches_key(key, &keybindings.universal.quit) || matches_key(key, &keybindings.universal.quit_alt1) @@ -4108,6 +4130,11 @@ impl Gui { return Ok(()); } + // Respect the configured binding in the diff pane too. + if matches_key(key, &keybindings.universal.toggle_working_tree_and_head) { + return self.toggle_working_tree_and_head(); + } + // Toggle command log (;) if key.code == KeyCode::Char(';') { self.show_command_log = !self.show_command_log; @@ -5774,6 +5801,10 @@ impl Gui { kb.universal.create_patch_options_menu.clone(), "Patch options".into(), ), + CommandEntry::keybinding( + kb.universal.toggle_working_tree_and_head.clone(), + "Toggle files / commit HEAD".into(), + ), CommandEntry::keybinding("{/}".into(), "Previous/next hunk".into()), CommandEntry::keybinding(";".into(), "Toggle command log".into()), CommandEntry::keybinding("W".into(), "Compare / Diff mode".into()), @@ -6191,6 +6222,15 @@ impl Gui { .clone(), "Toggle unified / side-by-side view".into(), ), + CommandEntry::keybinding( + self.config + .user_config + .keybinding + .universal + .toggle_working_tree_and_head + .clone(), + "Toggle files / commit HEAD".into(), + ), CommandEntry::keybinding("z".into(), "Toggle line wrap".into()), CommandEntry::keybinding("g/G".into(), "Go to top / bottom".into()), CommandEntry::keybinding("PgUp/PgDn".into(), "Page up / down".into()), @@ -8720,6 +8760,62 @@ impl Gui { } } + fn select_startup_head(&mut self) -> Result<()> { + if std::mem::take(&mut self.pending_startup_head) + && self.context_mgr.active() == ContextId::Commits + { + self.focus_head_commit()?; + } + Ok(()) + } + + fn focus_head_commit(&mut self) -> Result<()> { + // HEAD may not be the first all-ref commit, and may be absent from a + // filtered or paginated list. Never change filters or inject a commit + // that doesn't match them: reuse the separate revision-history view. + let hash = self.git.head_hash().unwrap_or_default(); + let selected = self + .model + .lock() + .unwrap() + .commits + .iter() + .position(|commit| commit.hash == hash); + let (context, index) = if let Some(index) = selected { + (ContextId::Commits, index) + } else if hash.is_empty() { + // Unborn repository: an empty commit panel is still a valid target. + (ContextId::Commits, 0) + } else { + let commits = self.git.load_commits_for_branch(&hash, 300)?; + self.model.lock().unwrap().set_sub_commits(commits); + self.branch_commits_name = "HEAD".to_string(); + self.sub_commits_parent_context = ContextId::Commits; + (ContextId::BranchCommits, 0) + }; + self.context_mgr.set_active(context); + self.context_mgr.set_selection(index); + self.context_mgr.set_scroll_offset(context, index); + self.needs_diff_refresh = true; + Ok(()) + } + + pub fn toggle_working_tree_and_head(&mut self) -> Result<()> { + self.range_select_anchor = None; + match self.context_mgr.active() { + ContextId::Commits + | ContextId::CommitFiles + | ContextId::Reflog + | ContextId::BranchCommits + | ContextId::BranchCommitFiles => { + self.context_mgr.set_active(ContextId::Files); + self.needs_diff_refresh = true; + } + _ => self.focus_head_commit()?, + } + Ok(()) + } + fn next_screen_mode(&mut self) { self.screen_mode = match self.screen_mode { ScreenMode::Normal => ScreenMode::Half, @@ -9222,6 +9318,329 @@ mod terminal_mouse_tests { } assert_eq!(keys.len(), 8); } + + struct TempRepo { + path: PathBuf, + } + + impl TempRepo { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "lazygitrs-gui-test-{}-{}-{}", + name, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create temp dir"); + let _ = std::process::Command::new("git") + .args(["init", "-b", "main"]) + .current_dir(&path) + .output(); + Self { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } + + fn git(&self, args: &[&str]) -> String { + let output = std::process::Command::new("git") + .args(["-c", "user.name=Test", "-c", "user.email=test@example.com"]) + .args(args) + .current_dir(&self.path) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() + } + + fn commit(&self, path: &str, contents: &str) -> String { + std::fs::write(self.path.join(path), contents).unwrap(); + self.git(&["add", path]); + self.git(&["commit", "-m", contents]); + self.git(&["rev-parse", "HEAD"]) + } + } + + impl Drop for TempRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + #[test] + fn toggle_working_tree_and_head_toggles_between_files_and_head() { + let repo = TempRepo::new("toggle-files-head"); + let config = crate::config::AppConfig::default(); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(config, git, None, false).unwrap(); + + // Starts in Files by default + assert_eq!(gui.context_mgr.active(), ContextId::Files); + + // Toggle to Commits (HEAD at index 0) + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + assert_eq!(gui.context_mgr.selected(ContextId::Commits), 0); + assert_eq!(gui.context_mgr.scroll_offset(ContextId::Commits), 0); + assert!(gui.needs_diff_refresh); + + // Move commit selection, then toggle back to Files + gui.context_mgr.set_selection_for(ContextId::Commits, 3); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + assert!(gui.needs_diff_refresh); + + // Toggle from CommitFiles subcontext back to Files + gui.context_mgr.set_active(ContextId::CommitFiles); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + + // Toggle from outside context (e.g. Branches) jumps to Commits at HEAD + gui.context_mgr.set_active(ContextId::Branches); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + assert_eq!(gui.context_mgr.selected(ContextId::Commits), 0); + } + + #[test] + fn start_in_commits_initializes_context_to_commits_head() { + let repo = TempRepo::new("start-in-commits"); + let config = crate::config::AppConfig::default(); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let gui = Gui::new(config, git, None, true).unwrap(); + + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + assert_eq!(gui.context_mgr.selected(ContextId::Commits), 0); + } + + #[test] + fn ctrl_g_key_event_triggers_toggle() { + let repo = TempRepo::new("ctrl-g-toggle"); + let config = crate::config::AppConfig::default(); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(config, git, None, false).unwrap(); + + assert_eq!(gui.context_mgr.active(), ContextId::Files); + + // Send Ctrl+g + let ctrl_g = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL); + gui.handle_key(ctrl_g).unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + + // Send Ctrl+g again to toggle back + gui.handle_key(ctrl_g).unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + + // Send Ctrl+g while diff is focused + gui.diff_focused = true; + gui.handle_key(ctrl_g).unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + assert!(gui.diff_focused); + + // The configured shortcut also toggles back while diff-focused. + gui.handle_key(ctrl_g).unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + assert!(gui.diff_focused); + + // Test custom keybinding (e.g. ) + let mut custom_config = crate::config::AppConfig::default(); + custom_config + .user_config + .keybinding + .universal + .toggle_working_tree_and_head = "".into(); + let git2 = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui2 = Gui::new(custom_config, git2, None, false).unwrap(); + let ctrl_t = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL); + gui2.handle_key(ctrl_t).unwrap(); + assert_eq!(gui2.context_mgr.active(), ContextId::Commits); + } + + #[test] + fn head_toggle_selects_checked_out_hash_not_another_branch_tip() { + let repo = TempRepo::new("head-not-first"); + let head = repo.commit("file", "main commit"); + repo.git(&["checkout", "-b", "ahead"]); + let other = repo.commit("file", "other branch commit"); + repo.git(&["checkout", "main"]); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let commits = git.load_commits(300).unwrap(); + assert_eq!(commits[0].hash, other); + let mut gui = Gui::new(crate::config::AppConfig::default(), git, None, false).unwrap(); + gui.model.lock().unwrap().set_commits(commits); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Commits); + let index = gui.context_mgr.selected(ContextId::Commits); + assert_eq!(gui.model.lock().unwrap().commits[index].hash, head); + assert_eq!(gui.context_mgr.scroll_offset(ContextId::Commits), index); + } + + #[test] + fn head_toggle_preserves_filters_and_uses_separate_head_history() { + let repo = TempRepo::new("filtered-head"); + let older = repo.commit("filtered", "older"); + let head = repo.commit("other", "head"); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(crate::config::AppConfig::default(), git, None, false).unwrap(); + + for filter in [ + crate::git::commit::CommitFilter { + path: Some("filtered".into()), + ..Default::default() + }, + crate::git::commit::CommitFilter { + authors: vec!["nobody@example.com".into()], + ..Default::default() + }, + crate::git::commit::CommitFilter { + branches: vec![older.clone()], + ..Default::default() + }, + ] { + let commits = gui.git.load_filtered_commits_page(&filter, 300, 0).unwrap(); + let hashes: Vec<_> = commits.iter().map(|c| c.hash.clone()).collect(); + assert!(!hashes.contains(&head)); + gui.commit_path_filter = filter.path.clone(); + gui.commit_author_filter = filter.authors.clone(); + gui.commit_branch_filter = filter.branches.clone(); + gui.model.lock().unwrap().set_commits(commits); + gui.context_mgr.set_active(ContextId::Files); + gui.context_mgr.set_selection(2); + gui.diff_focused = true; + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::BranchCommits); + assert_eq!(gui.model.lock().unwrap().sub_commits[0].hash, head); + assert_eq!(gui.commit_path_filter, filter.path); + assert_eq!(gui.commit_author_filter, filter.authors); + assert_eq!(gui.commit_branch_filter, filter.branches); + assert_eq!( + gui.model + .lock() + .unwrap() + .commits + .iter() + .map(|c| c.hash.clone()) + .collect::>(), + hashes + ); + + // Streaming model refreshes do not retain sub-history themselves. + gui.model.lock().unwrap().clear_sub_commits(); + gui.after_model_refresh().unwrap(); + assert_eq!(gui.model.lock().unwrap().sub_commits[0].hash, head); + gui.context_mgr.set_active(ContextId::BranchCommitFiles); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + assert_eq!(gui.context_mgr.selected(ContextId::Files), 2); + assert!(gui.diff_focused); + } + } + + #[test] + fn head_toggle_handles_head_outside_the_loaded_page_and_detached_head() { + let repo = TempRepo::new("paged-head"); + let older = repo.commit("file", "older"); + let head = repo.commit("file", "head"); + repo.git(&["checkout", "--detach", &head]); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let commits = git.load_commits_for_branch(&older, 1).unwrap(); + let mut gui = Gui::new(crate::config::AppConfig::default(), git, None, false).unwrap(); + gui.model.lock().unwrap().set_commits(commits); + gui.toggle_working_tree_and_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::BranchCommits); + assert_eq!(gui.model.lock().unwrap().sub_commits[0].hash, head); + assert_eq!(gui.model.lock().unwrap().commits[0].hash, older); + } + + #[test] + fn head_toggle_does_not_consume_old_binding_when_remapped_or_disabled() { + let repo = TempRepo::new("remapped-head"); + repo.commit("file", "head"); + for binding in ["", ""] { + for diff_focused in [false, true] { + let mut config = crate::config::AppConfig::default(); + config + .user_config + .keybinding + .universal + .toggle_working_tree_and_head = binding.into(); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(config, git, None, false).unwrap(); + gui.diff_focused = diff_focused; + gui.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + gui.popup = PopupState::None; + gui.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!( + gui.context_mgr.active(), + if binding.is_empty() { + ContextId::Files + } else { + ContextId::BranchCommits + } + ); + } + } + } + #[test] + fn commits_startup_selects_actual_head_and_preserves_path_filter() { + let repo = TempRepo::new("startup-head"); + repo.commit("filtered", "older"); + let head = repo.commit("other", "head"); + repo.git(&["checkout", "-b", "ahead"]); + repo.commit("other", "ahead"); + repo.git(&["checkout", "main"]); + for path in [None, Some(PathBuf::from("filtered"))] { + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = + Gui::new(crate::config::AppConfig::default(), git, path.clone(), true).unwrap(); + let commits = gui + .git + .load_filtered_commits_page( + &gui.commit_filter_for_load().unwrap_or_default(), + 300, + 0, + ) + .unwrap(); + gui.model.lock().unwrap().set_commits(commits); + gui.select_startup_head().unwrap(); + assert!(!gui.pending_startup_head); + let model = gui.model.lock().unwrap(); + let selected = gui.context_mgr.selected_active(); + let commit = if path.is_some() { + &model.sub_commits[selected] + } else { + &model.commits[selected] + }; + assert_eq!(commit.hash, head); + assert_eq!( + gui.commit_path_filter, + path.map(|p| p.to_string_lossy().to_string()) + ); + } + } + + #[test] + fn startup_head_does_not_override_navigation() { + let repo = TempRepo::new("startup-navigation"); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(crate::config::AppConfig::default(), git, None, true).unwrap(); + gui.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + gui.select_startup_head().unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + } } fn setup_terminal() -> Result<(Term, bool)> { diff --git a/src/gui/views.rs b/src/gui/views.rs index dc846f9..c7cee0a 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -1343,7 +1343,9 @@ pub fn checklist_item_at(popup: &PopupState, area: Rect, col: u16, row: u16) -> #[cfg(test)] mod tests { - use super::{checklist_item_at, command_log_geometry, menu_item_at, render_popup}; + use super::{ + checklist_item_at, command_log_geometry, format_key_hint, menu_item_at, render_popup, + }; use crate::config::Theme; use crate::gui::popup::{ChecklistItem, MenuItem, MessageKind, PopupState}; use ratatui::Terminal; @@ -1515,6 +1517,224 @@ mod tests { } } + #[test] + fn format_key_hint_formats_modifiers_and_preserves_plain() { + assert_eq!(format_key_hint(""), "ctrl+s"); + assert_eq!(format_key_hint(""), "ctrl+s"); + assert_eq!(format_key_hint(""), "ctrl+s"); + assert_eq!(format_key_hint(""), "ctrl+s"); + assert_eq!(format_key_hint("Ctrl+S"), "ctrl+s"); + assert_eq!(format_key_hint("ctrl-s"), "ctrl+s"); + assert_eq!(format_key_hint(""), "ctrl+g"); + assert_eq!(format_key_hint(""), "ctrl+l"); + assert_eq!(format_key_hint(""), "ctrl+f"); + assert_eq!(format_key_hint(""), "alt+h"); + assert_eq!(format_key_hint(""), "alt+h"); + assert_eq!(format_key_hint(""), "shift+f"); + assert_eq!(format_key_hint(""), "shift+f"); + assert_eq!(format_key_hint(""), "shift+tab"); + assert_eq!(format_key_hint(""), "space"); + assert_eq!(format_key_hint(""), "space"); + assert_eq!(format_key_hint(""), "enter"); + assert_eq!(format_key_hint(""), "esc"); + assert_eq!(format_key_hint("f"), "f"); + assert_eq!(format_key_hint("F"), "F"); + assert_eq!(format_key_hint("ctrl+g"), "ctrl+g"); + assert_eq!(format_key_hint(""), ""); + } + + #[test] + fn status_bar_shows_dual_diff_toggle_hint() { + use crate::config::KeybindingConfig; + use crate::gui::context::{ContextId, ContextManager}; + use crate::model::Model; + use crate::pager::side_by_side::DiffViewState; + + let backend = TestBackend::new(160, 1); + let mut terminal = Terminal::new(backend).expect("test terminal"); + let theme = Theme::default(); + let mut ctx_mgr = ContextManager::new(); + let diff_view = DiffViewState::new(); + let model = Model::default(); + let keybindings = KeybindingConfig::default(); + + // 1. Files context -> shows "ctrl+g head" + ctx_mgr.set_active(ContextId::Files); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &keybindings, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+g head"), + "Expected 'ctrl+g head' in status bar for Files, got: {}", + content + ); + + // 2. Commits context -> shows "ctrl+g files" + ctx_mgr.set_active(ContextId::Commits); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &keybindings, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+g files"), + "Expected 'ctrl+g files' in status bar for Commits, got: {}", + content + ); + + // 3. CommitFiles context -> shows "ctrl+g files" + ctx_mgr.set_active(ContextId::CommitFiles); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &keybindings, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+g files"), + "Expected 'ctrl+g files' in status bar for CommitFiles, got: {}", + content + ); + + // 4. Diff-focused mode: Files context -> shows "ctrl+g head" + ctx_mgr.set_active(ContextId::Files); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + true, + false, + &keybindings, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+g head"), + "Expected 'ctrl+g head' in diff-focused status bar for Files, got: {}", + content + ); + + // 5. Diff-focused mode: Commits context -> shows "ctrl+g files" + ctx_mgr.set_active(ContextId::Commits); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + true, + false, + &keybindings, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+g files"), + "Expected 'ctrl+g files' in diff-focused status bar for Commits, got: {}", + content + ); + + // 6. Custom keybinding (e.g. ) -> shows "ctrl+t head" and "ctrl+t files" + let mut custom_kb = KeybindingConfig::default(); + custom_kb.universal.toggle_working_tree_and_head = "".into(); + ctx_mgr.set_active(ContextId::Files); + terminal + .draw(|f| { + super::render_status_bar( + f, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &theme, + &model, + false, + false, + &custom_kb, + false, + ); + }) + .unwrap(); + + let buf = terminal.backend().buffer(); + let content: String = (0..160) + .map(|x| buf.cell((x, 0)).unwrap().symbol()) + .collect(); + assert!( + content.contains("ctrl+t head"), + "Expected 'ctrl+t head' with custom keybinding, got: {}", + content + ); + } + #[test] fn command_log_is_hidden_when_main_panel_is_absent() { assert_eq!(command_log_geometry(Rect::default(), 1), None); @@ -1704,6 +1924,8 @@ mod tests { content ); + assert!(content.contains("ctrl+g files"), "{content}"); + // Without active filters terminal .draw(|frame| { @@ -2622,6 +2844,7 @@ fn render_status_bar( let mut hints: Vec<(&str, &str)> = Vec::new(); let mut emphasized: Vec<&str> = Vec::new(); let open_log_menu_key = format_key_hint(&keybindings.commits.open_log_menu); + let toggle_head_key = format_key_hint(&keybindings.universal.toggle_working_tree_and_head); // When in a special state (rebasing/merging/cherry-picking), show those options prominently if model.is_rebasing { @@ -2641,6 +2864,10 @@ fn render_status_bar( let has_selection = diff_view.selected_revert_hunk.is_some(); let has_undo = !diff_view.revert_undo_stack.is_empty(); let mut idx = 0; + if !toggle_head_key.is_empty() { + hints.insert(idx, (toggle_head_key.as_str(), "head")); + idx += 1; + } if has_selection { hints.insert(idx, ("enter", "hunk menu")); emphasized.push("enter"); @@ -2652,6 +2879,15 @@ fn render_status_bar( hints.insert(idx, ("u", "undo revert")); } } else { + if (ctx_mgr.active() == ContextId::Commits + || ctx_mgr.active() == ContextId::CommitFiles + || ctx_mgr.active() == ContextId::BranchCommits + || ctx_mgr.active() == ContextId::BranchCommitFiles + || ctx_mgr.active() == ContextId::Reflog) + && !toggle_head_key.is_empty() + { + hints.push((toggle_head_key.as_str(), "files")); + } hints.push(("{/}", "prev/next hunk")); } hints.push(("[/]", "side view")); @@ -2668,6 +2904,9 @@ fn render_status_bar( }; match ctx_mgr.active() { ContextId::Files => { + if !toggle_head_key.is_empty() { + hints.push((toggle_head_key.as_str(), "head")); + } hints.extend([ ("c", "commit"), ("a", "stage all"), @@ -2679,6 +2918,9 @@ fn render_status_bar( ]); } ContextId::CommitFiles | ContextId::StashFiles | ContextId::BranchCommitFiles => { + if ctx_mgr.active() != ContextId::StashFiles && !toggle_head_key.is_empty() { + hints.push((toggle_head_key.as_str(), "files")); + } hints.extend([ ("enter", "focus diff"), ("\\", view_layout_hint), @@ -2686,6 +2928,9 @@ fn render_status_bar( ]); } ContextId::BranchCommits => { + if !toggle_head_key.is_empty() { + hints.push((toggle_head_key.as_str(), "files")); + } hints.extend([ ("enter", "commit files"), ("\\", view_layout_hint), @@ -2693,6 +2938,9 @@ fn render_status_bar( ]); } ContextId::Reflog => { + if !toggle_head_key.is_empty() { + hints.push((toggle_head_key.as_str(), "files")); + } hints.extend([ ("enter", "commit files"), ("\\", view_layout_hint), @@ -2709,6 +2957,9 @@ fn render_status_bar( ]); } ContextId::Commits => { + if !toggle_head_key.is_empty() { + hints.push((toggle_head_key.as_str(), "files")); + } if has_active_filters { hints.push(("esc", "reset filter")); } diff --git a/src/main.rs b/src/main.rs index 7e9b13f..3415ca0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,10 @@ struct Cli { /// Filter commits by path (file or directory), like lazygit -f #[arg(short = 'f', long = "filter", value_name = "PATH")] filter_path: Option, + + /// Launch directly in the commits panel + #[arg(long)] + commits: bool, } #[derive(Subcommand)] @@ -115,7 +119,7 @@ fn main() { .or(cli.work_tree) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); - match app::App::new(repo_path, cli.debug, cli.filter_path) { + match app::App::new(repo_path, cli.debug, cli.filter_path, cli.commits) { Ok(app) => { if let Err(e) = app.run() { eprintln!("Error: {:#}", e); @@ -128,3 +132,17 @@ fn main() { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cli_commits_flag() { + let cli = Cli::parse_from(["lazygitrs", "--commits"]); + assert!(cli.commits); + + let cli_default = Cli::parse_from(["lazygitrs"]); + assert!(!cli_default.commits); + } +} From 4aa3af4d06f52dd852c8b83630ad9fd16baf3147 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 7 Oct 2026 01:12:16 +0800 Subject: [PATCH 10/11] Fix tree shortcut conflict and keep navigation help out of footer --- README.md | 10 +- src/config/keybindings.rs | 4 +- src/gui/controller/commit_files.rs | 28 +++--- src/gui/controller/diff_mode.rs | 30 +++--- src/gui/controller/files.rs | 20 ++-- src/gui/controller/tree.rs | 52 +++-------- src/gui/mod.rs | 142 ++++++++++++++++++++++++++++- src/gui/presentation/diff_mode.rs | 91 ++++++------------ src/gui/views.rs | 51 +---------- 9 files changed, 232 insertions(+), 196 deletions(-) diff --git a/README.md b/README.md index 1d7f2fb..d362144 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ toggle the tree view. With a tree active: - `-` folds/unfolds the selected directory (including the root). - `Enter` focuses the selected file or combined directory diff without changing your normal/half/full layout; `Esc` returns to the file list. -- `,` / `.` select the parent / first visible child. +- `,` / `'` (apostrophe) select the parent / first visible child. - `<` / `>` select the previous / next sibling, skipping nested descendants. Hierarchy navigation also works while the diff is focused. @@ -58,14 +58,16 @@ keybinding: universal: foldDirectory: "-" treeParent: "," - treeChild: "." + treeChild: "'" treePrevSibling: "<" treeNextSibling: ">" ``` Set a binding to `""` to disable it. Collapsed directories must be unfolded -before their children can be selected. In a commit-file tree, `.` navigates -rather than toggling commit details when assigned to a tree-navigation action. +before their children can be selected. Tree-navigation actions appear in `?` +only while a tree is active, not in the footer. `.` remains the commit-details +toggle with the default bindings; explicitly remapping a tree action to `.` +will override it while the tree is active. ### Upgrade diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 77fe68c..b4ee933 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -196,7 +196,7 @@ impl Default for UniversalKeybinding { toggle_working_tree_and_head: "".into(), fold_directory: "-".into(), tree_parent: ",".into(), - tree_child: ".".into(), + tree_child: "'".into(), tree_prev_sibling: "<".into(), tree_next_sibling: ">".into(), } @@ -476,7 +476,7 @@ mod tests { let default_config = KeybindingConfig::default(); assert_eq!(default_config.universal.fold_directory, "-"); assert_eq!(default_config.universal.tree_parent, ","); - assert_eq!(default_config.universal.tree_child, "."); + assert_eq!(default_config.universal.tree_child, "'"); assert_eq!(default_config.universal.tree_prev_sibling, "<"); assert_eq!(default_config.universal.tree_next_sibling, ">"); diff --git a/src/gui/controller/commit_files.rs b/src/gui/controller/commit_files.rs index 8f60f73..bb0d35c 100644 --- a/src/gui/controller/commit_files.rs +++ b/src/gui/controller/commit_files.rs @@ -48,21 +48,21 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) } // Folding is explicit; Enter focuses file or combined directory diffs. - if super::tree::matches_key(key, &keybindings.universal.fold_directory) { - if gui.show_commit_file_tree { - if let Some(node) = gui - .commit_file_tree_nodes - .get(gui.context_mgr.selected_active()) - { - if node.is_dir { - let path = node.path.clone(); - if !gui.commit_files_collapsed_dirs.remove(&path) { - gui.commit_files_collapsed_dirs.insert(path); - } - update_commit_file_tree_state(gui); - gui.context_mgr.viewport_manually_scrolled = false; - gui.needs_diff_refresh = true; + if gui.show_commit_file_tree + && super::tree::matches_key(key, &keybindings.universal.fold_directory) + { + if let Some(node) = gui + .commit_file_tree_nodes + .get(gui.context_mgr.selected_active()) + { + if node.is_dir { + let path = node.path.clone(); + if !gui.commit_files_collapsed_dirs.remove(&path) { + gui.commit_files_collapsed_dirs.insert(path); } + update_commit_file_tree_state(gui); + gui.context_mgr.viewport_manually_scrolled = false; + gui.needs_diff_refresh = true; } } return Ok(()); diff --git a/src/gui/controller/diff_mode.rs b/src/gui/controller/diff_mode.rs index 3092066..c943d9b 100644 --- a/src/gui/controller/diff_mode.rs +++ b/src/gui/controller/diff_mode.rs @@ -414,22 +414,22 @@ fn handle_commit_files_key(gui: &mut Gui, key: KeyEvent) -> Result<()> { return Ok(()); } - if super::tree::matches_key(key, &keybindings.universal.fold_directory) { - if gui.diff_mode.show_tree { - if let Some(node) = gui - .diff_mode - .tree_nodes - .get(gui.diff_mode.diff_files_selected) - { - if node.is_dir { - let path = node.path.clone(); - if !gui.diff_mode.collapsed_dirs.remove(&path) { - gui.diff_mode.collapsed_dirs.insert(path); - } - update_diff_mode_tree(gui); - gui.diff_mode.viewport_manually_scrolled = false; - gui.needs_diff_refresh = true; + if gui.diff_mode.show_tree + && super::tree::matches_key(key, &keybindings.universal.fold_directory) + { + if let Some(node) = gui + .diff_mode + .tree_nodes + .get(gui.diff_mode.diff_files_selected) + { + if node.is_dir { + let path = node.path.clone(); + if !gui.diff_mode.collapsed_dirs.remove(&path) { + gui.diff_mode.collapsed_dirs.insert(path); } + update_diff_mode_tree(gui); + gui.diff_mode.viewport_manually_scrolled = false; + gui.needs_diff_refresh = true; } } return Ok(()); diff --git a/src/gui/controller/files.rs b/src/gui/controller/files.rs index 3fc0dfa..e88ae89 100644 --- a/src/gui/controller/files.rs +++ b/src/gui/controller/files.rs @@ -20,18 +20,16 @@ pub fn handle_key(gui: &mut Gui, key: KeyEvent, keybindings: &KeybindingConfig) } // Folding is explicit; Enter focuses file or combined directory diffs. - if super::tree::matches_key(key, &keybindings.universal.fold_directory) { - if gui.show_file_tree { - if let Some(node) = gui.file_tree_nodes.get(gui.context_mgr.selected_active()) { - if node.is_dir { - let path = node.path.clone(); - if !gui.collapsed_dirs.remove(&path) { - gui.collapsed_dirs.insert(path); - } - gui.update_file_tree_state(); - gui.context_mgr.viewport_manually_scrolled = false; - gui.needs_diff_refresh = true; + if gui.show_file_tree && super::tree::matches_key(key, &keybindings.universal.fold_directory) { + if let Some(node) = gui.file_tree_nodes.get(gui.context_mgr.selected_active()) { + if node.is_dir { + let path = node.path.clone(); + if !gui.collapsed_dirs.remove(&path) { + gui.collapsed_dirs.insert(path); } + gui.update_file_tree_state(); + gui.context_mgr.viewport_manually_scrolled = false; + gui.needs_diff_refresh = true; } } return Ok(()); diff --git a/src/gui/controller/tree.rs b/src/gui/controller/tree.rs index ba81757..adeed34 100644 --- a/src/gui/controller/tree.rs +++ b/src/gui/controller/tree.rs @@ -40,29 +40,6 @@ pub fn destination( } } -pub fn hints(kb: &KeybindingConfig, include_fold: bool) -> Vec<(String, &'static str)> { - let u = &kb.universal; - let mut hints = Vec::new(); - if include_fold && !u.fold_directory.is_empty() { - hints.push((u.fold_directory.clone(), "fold")); - } - for (keys, label) in [ - ([&u.tree_parent, &u.tree_child], "nav"), - ([&u.tree_prev_sibling, &u.tree_next_sibling], "siblings"), - ] { - let keys = keys - .into_iter() - .filter(|k| !k.is_empty()) - .cloned() - .collect::>() - .join("/"); - if !keys.is_empty() { - hints.push((keys, label)); - } - } - hints -} - pub fn command_section( kb: &KeybindingConfig, include_fold: bool, @@ -97,30 +74,31 @@ mod tests { use super::*; #[test] - fn tree_hints_reflect_custom_and_disabled_bindings() { + fn tree_help_reflects_custom_and_disabled_bindings() { let mut kb = KeybindingConfig::default(); kb.universal.fold_directory = "".into(); kb.universal.tree_parent = "p".into(); kb.universal.tree_child = "c".into(); kb.universal.tree_prev_sibling.clear(); kb.universal.tree_next_sibling = "n".into(); - assert_eq!( - hints(&kb, true), - vec![ - ("".into(), "fold"), - ("p/c".into(), "nav"), - ("n".into(), "siblings") - ] - ); - assert_eq!( - hints(&kb, false), - vec![("p/c".into(), "nav"), ("n".into(), "siblings")] - ); + for include_fold in [false, true] { + let section = command_section(&kb, include_fold); + assert_eq!(section.title, "File Tree"); + let keys: Vec<&str> = section.entries.iter().map(|e| e.key.as_str()).collect(); + assert_eq!( + keys, + if include_fold { + vec!["", "p", "c", "n"] + } else { + vec!["p", "c", "n"] + } + ); + } kb.universal.fold_directory.clear(); kb.universal.tree_parent.clear(); kb.universal.tree_child.clear(); kb.universal.tree_next_sibling.clear(); - assert!(hints(&kb, true).is_empty()); + assert!(command_section(&kb, true).entries.is_empty()); } #[test] diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 28f3a30..c86338e 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1517,7 +1517,6 @@ impl Gui { &mut self.commit_list_cache, self.layout.side_panel_ratio, self.screen_mode, - &self.config.user_config.keybinding, ); // Render popup overlay on top of diff mode (for ? help, errors, etc.) if self.popup != PopupState::None { @@ -9453,7 +9452,7 @@ mod terminal_mouse_tests { gui.diff_mode.diff_files_selected = 0; gui.diff_mode.viewport_manually_scrolled = true; gui.needs_diff_refresh = false; - gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) .unwrap(); assert_eq!(gui.diff_mode.diff_files_selected, 1); assert!(!gui.diff_mode.viewport_manually_scrolled); @@ -9490,7 +9489,7 @@ mod terminal_mouse_tests { .keybinding .universal .tree_child = "".into(); - gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) .unwrap(); assert_eq!(gui.diff_mode.diff_files_selected, 0); gui.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) @@ -9504,7 +9503,7 @@ mod terminal_mouse_tests { let mut gui = tree_test_gui(&repo); gui.context_mgr.set_active(ContextId::CommitFiles); let details = gui.show_commit_details; - gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) .unwrap(); assert_eq!(gui.context_mgr.selected_active(), 1); assert_eq!(gui.show_commit_details, details); @@ -9527,6 +9526,139 @@ mod terminal_mouse_tests { assert_ne!(gui.show_commit_details, details); } + #[test] + fn tree_default_child_key_preserves_commit_details_toggle() { + let repo = TempRepo::new("tree-default-details"); + let mut gui = tree_test_gui(&repo); + for ctx in [ContextId::CommitFiles, ContextId::BranchCommitFiles] { + for tree in [false, true] { + gui.context_mgr.set_active(ctx); + gui.context_mgr.set_selection(0); + gui.show_commit_file_tree = tree; + let details = gui.show_commit_details; + gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + .unwrap(); + assert_ne!(gui.show_commit_details, details, "{ctx:?} {tree}"); + assert_eq!(gui.context_mgr.selected_active(), 0); + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + .unwrap(); + assert_eq!(gui.context_mgr.selected_active(), usize::from(tree)); + assert_ne!(gui.show_commit_details, details); + } + } + } + + #[test] + fn tree_help_is_only_available_in_active_tree_contexts() { + use crate::gui::modes::diff_mode::DiffModeFocus; + let repo = TempRepo::new("tree-contextual-help"); + let mut gui = tree_test_gui(&repo); + for ctx in [ + ContextId::Files, + ContextId::CommitFiles, + ContextId::BranchCommitFiles, + ContextId::StashFiles, + ContextId::Commits, + ] { + for tree in [false, true] { + for diff in [false, true] { + gui.popup = PopupState::None; + gui.context_mgr.set_active(ctx); + gui.show_file_tree = tree; + gui.show_commit_file_tree = tree; + gui.diff_focused = diff; + gui.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)) + .unwrap(); + let PopupState::CommandPalette { sections, .. } = &gui.popup else { + panic!("expected help palette"); + }; + let tree_section = sections.iter().find(|s| s.title == "File Tree"); + assert_eq!( + tree_section.is_some(), + tree && ctx != ContextId::Commits, + "{ctx:?} tree={tree} diff={diff}" + ); + if let Some(section) = tree_section { + assert!( + section + .entries + .iter() + .any(|e| e.key == "'" + && e.description == "Select first visible child") + ); + assert_eq!(section.entries.iter().any(|e| e.key == "-"), !diff); + } + } + } + } + gui.diff_mode.enter(true); + for tree in [false, true] { + for focus in [ + DiffModeFocus::CommitFiles, + DiffModeFocus::DiffExploration, + DiffModeFocus::Commits, + DiffModeFocus::SelectorA, + DiffModeFocus::SelectorB, + ] { + gui.popup = PopupState::None; + gui.diff_mode.show_tree = tree; + gui.diff_mode.set_focus(focus); + gui.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)) + .unwrap(); + let PopupState::CommandPalette { sections, .. } = &gui.popup else { + panic!("expected compare help palette"); + }; + let tree_section = sections.iter().find(|s| s.title == "File Tree"); + assert_eq!( + tree_section.is_some(), + tree && matches!( + focus, + DiffModeFocus::CommitFiles | DiffModeFocus::DiffExploration + ) + ); + if let Some(section) = tree_section { + assert!(section.entries.iter().any(|e| e.key == "'")); + assert_eq!( + section.entries.iter().any(|e| e.key == "-"), + focus == DiffModeFocus::CommitFiles + ); + } + } + } + } + + #[test] + fn tree_fold_binding_does_not_override_flat_list_actions() { + use crate::gui::modes::diff_mode::DiffModeFocus; + let repo = TempRepo::new("tree-flat-fold"); + let mut gui = tree_test_gui(&repo); + // A remapped fold key must not swallow a flat list's tree-toggle action. + Arc::get_mut(&mut gui.config) + .unwrap() + .user_config + .keybinding + .universal + .fold_directory = "`".into(); + for ctx in [ + ContextId::Files, + ContextId::CommitFiles, + ContextId::BranchCommitFiles, + ContextId::StashFiles, + ] { + gui.context_mgr.set_active(ctx); + gui.show_file_tree = false; + gui.show_commit_file_tree = false; + gui.handle_key(KeyEvent::new(KeyCode::Char('`'), KeyModifiers::NONE)) + .unwrap(); + assert!(gui.active_file_tree(), "{ctx:?}"); + } + gui.diff_mode.enter(false); + gui.diff_mode.set_focus(DiffModeFocus::CommitFiles); + gui.handle_key(KeyEvent::new(KeyCode::Char('`'), KeyModifiers::NONE)) + .unwrap(); + assert!(gui.diff_mode.show_tree); + } + fn tree_test_gui(repo: &TempRepo) -> Gui { std::fs::create_dir_all(repo.path.join("src/deep")).unwrap(); repo.commit("src/deep/a.txt", "base\n"); @@ -9612,7 +9744,7 @@ mod terminal_mouse_tests { gui.diff_focused = diff; gui.context_mgr.viewport_manually_scrolled = true; gui.needs_diff_refresh = false; - gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) .unwrap(); assert_eq!(gui.context_mgr.selected_active(), 1); assert!(!gui.context_mgr.viewport_manually_scrolled); diff --git a/src/gui/presentation/diff_mode.rs b/src/gui/presentation/diff_mode.rs index fb3160e..713f9c6 100644 --- a/src/gui/presentation/diff_mode.rs +++ b/src/gui/presentation/diff_mode.rs @@ -109,7 +109,6 @@ mod tests { &state, &DiffViewState::default(), &Theme::default(), - &crate::config::KeybindingConfig::default(), ) }) .unwrap(); @@ -131,52 +130,34 @@ mod tests { } #[test] - fn tree_compare_status_uses_configured_keys_and_hides_disabled_ones() { - let mut state = DiffModeState::new(); - state.show_tree = true; - let mut kb = crate::config::KeybindingConfig::default(); - kb.universal.fold_directory = "f".into(); - kb.universal.tree_parent = "p".into(); - kb.universal.tree_child = "c".into(); - kb.universal.tree_prev_sibling.clear(); - kb.universal.tree_next_sibling = "n".into(); - for focus in [ - DiffModeFocus::CommitFiles, - DiffModeFocus::DiffExploration, - DiffModeFocus::Commits, - ] { - state.set_focus(focus); - let mut terminal = Terminal::new(TestBackend::new(180, 1)).unwrap(); - terminal - .draw(|frame| { - render_status_bar( - frame, - frame.area(), - &state, - &DiffViewState::default(), - &Theme::default(), - &kb, - ) - }) - .unwrap(); - let text = buffer_text(&terminal); - assert_eq!( - text.contains("f fold"), - focus == DiffModeFocus::CommitFiles, - "{text}" - ); - assert_eq!( - text.contains("p/c nav"), - focus != DiffModeFocus::Commits, - "{text}" - ); - assert_eq!( - text.contains("n siblings"), - focus != DiffModeFocus::Commits, - "{text}" - ); - assert!(!text.contains(",/."), "{text}"); - assert!(!text.contains("- fold"), "{text}"); + fn tree_compare_navigation_is_absent_from_footer() { + for tree in [false, true] { + for focus in [ + DiffModeFocus::CommitFiles, + DiffModeFocus::DiffExploration, + DiffModeFocus::Commits, + ] { + let mut state = DiffModeState::new(); + state.show_tree = tree; + state.set_focus(focus); + let mut terminal = Terminal::new(TestBackend::new(180, 1)).unwrap(); + terminal + .draw(|frame| { + render_status_bar( + frame, + frame.area(), + &state, + &DiffViewState::default(), + &Theme::default(), + ) + }) + .unwrap(); + let text = buffer_text(&terminal); + assert!(!text.contains("fold"), "{text}"); + assert!(!text.contains("nav"), "{text}"); + assert!(!text.contains("siblings"), "{text}"); + assert!(text.contains("? help"), "{text}"); + } } } @@ -233,7 +214,6 @@ mod tests { &mut CommitListCache::default(), 1.0 / 3.0, mode, - &crate::config::KeybindingConfig::default(), ) }) .unwrap(); @@ -274,7 +254,6 @@ pub fn render( commit_cache: &mut CommitListCache, side_ratio: f64, screen_mode: crate::gui::ScreenMode, - keybindings: &crate::config::KeybindingConfig, ) { let layout = CompareLayout::new(frame.area(), side_ratio, screen_mode, state); let sidebar = layout.sidebar; @@ -307,7 +286,7 @@ pub fn render( } // Status bar - render_status_bar(frame, layout.status, state, diff_view, theme, keybindings); + render_status_bar(frame, layout.status, state, diff_view, theme); // Render combobox dropdown overlay on top of the sidebar if state.editing.is_some() && sidebar.iter().any(|r| !r.is_empty()) { @@ -691,10 +670,7 @@ fn render_status_bar( state: &DiffModeState, diff_view: &DiffViewState, theme: &Theme, - keybindings: &crate::config::KeybindingConfig, ) { - let tree_hints = - crate::gui::controller::tree::hints(keybindings, state.focus == DiffModeFocus::CommitFiles); // If search is active or has results, show search bar instead of hints if state.file_search_active { if let Some(ref ta) = state.file_search_textarea { @@ -754,7 +730,7 @@ fn render_status_bar( return; } - let mut hints = if state.editing.is_some() { + let hints = if state.editing.is_some() { vec![("Enter", "select"), ("Esc", "cancel"), ("↑↓", "navigate")] } else { let view_layout_hint = match diff_view.view_layout { @@ -775,18 +751,11 @@ fn render_status_bar( ("", "swap"), ("`", "tree"), ]); - if state.show_tree && state.focus == DiffModeFocus::CommitFiles { - hints.extend(tree_hints.iter().map(|(key, label)| (key.as_str(), *label))); - } hints.push(("\\", view_layout_hint)); hints.push(("?", "help")); hints }; - if state.show_tree && state.focus == DiffModeFocus::DiffExploration { - hints.extend(tree_hints.iter().map(|(key, label)| (key.as_str(), *label))); - } - let key_style = Style::default().fg(theme.text).add_modifier(Modifier::BOLD); let desc_style = Style::default().fg(theme.text_dimmed); let spans: Vec = hints diff --git a/src/gui/views.rs b/src/gui/views.rs index dd34d36..7253053 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -419,8 +419,6 @@ pub fn render( !cherry_pick_clipboard.is_empty(), &config.user_config.keybinding, !active_commit_filters.is_empty(), - show_file_tree, - show_commit_file_tree, ); // Render text selection highlight overlay and tooltip (must be before popup) render_selection_overlay(frame, diff_view, fl.main_panel, theme); @@ -1082,8 +1080,6 @@ pub fn render( !cherry_pick_clipboard.is_empty(), &config.user_config.keybinding, !active_commit_filters.is_empty(), - show_file_tree, - show_commit_file_tree, ); // Render text selection highlight overlay and tooltip @@ -1444,7 +1440,7 @@ mod tests { } #[test] - fn tree_status_hints_use_custom_keys_across_file_contexts() { + fn tree_navigation_is_absent_from_footer_across_file_contexts() { use super::{ContextId, ContextManager, DiffViewState, KeybindingConfig, Model}; let mut kb = KeybindingConfig::default(); kb.universal.fold_directory = "f".into(); @@ -1477,17 +1473,15 @@ mod tests { false, &kb, false, - true, - true, ) }) .unwrap(); let text: String = (0..240) .map(|x| terminal.backend().buffer().cell((x, 0)).unwrap().symbol()) .collect(); - assert!(text.contains("p/c nav"), "{ctx:?} {text}"); - assert!(text.contains("n siblings"), "{text}"); - assert_eq!(text.contains("f fold"), !focused, "{text}"); + assert!(!text.contains("p/c nav"), "{ctx:?} {text}"); + assert!(!text.contains("n siblings"), "{text}"); + assert!(!text.contains("f fold"), "{text}"); assert!(!text.contains(",/."), "{text}"); } } @@ -1627,8 +1621,6 @@ mod tests { false, &keybindings, false, - false, - false, ); }) .unwrap(); @@ -1658,8 +1650,6 @@ mod tests { false, &keybindings, false, - false, - false, ); }) .unwrap(); @@ -1689,8 +1679,6 @@ mod tests { false, &keybindings, false, - false, - false, ); }) .unwrap(); @@ -1720,8 +1708,6 @@ mod tests { false, &keybindings, false, - false, - false, ); }) .unwrap(); @@ -1751,8 +1737,6 @@ mod tests { false, &keybindings, false, - false, - false, ); }) .unwrap(); @@ -1784,8 +1768,6 @@ mod tests { false, &custom_kb, false, - false, - false, ); }) .unwrap(); @@ -1973,8 +1955,6 @@ mod tests { false, &keybindings, true, - false, - false, ); }) .expect("should render status bar with active filters"); @@ -2008,8 +1988,6 @@ mod tests { false, &keybindings, false, - false, - false, ); }) .expect("should render status bar without active filters"); @@ -2836,8 +2814,6 @@ fn render_search_bar_or_status_bar( has_copied_commits: bool, keybindings: &KeybindingConfig, has_active_filters: bool, - show_file_tree: bool, - show_commit_file_tree: bool, ) { if let Some((query, match_count, current_match)) = search_state { let match_info = if match_count > 0 { @@ -2897,8 +2873,6 @@ fn render_search_bar_or_status_bar( has_copied_commits, keybindings, has_active_filters, - show_file_tree, - show_commit_file_tree, ); } } @@ -2914,10 +2888,7 @@ fn render_status_bar( has_copied_commits: bool, keybindings: &KeybindingConfig, has_active_filters: bool, - show_file_tree: bool, - show_commit_file_tree: bool, ) { - let tree_hints = crate::gui::controller::tree::hints(keybindings, !diff_focused); let mut hints: Vec<(&str, &str)> = Vec::new(); let mut emphasized: Vec<&str> = Vec::new(); let open_log_menu_key = format_key_hint(&keybindings.commits.open_log_menu); @@ -2967,14 +2938,6 @@ fn render_status_bar( } hints.push(("{/}", "prev/next hunk")); } - if (ctx_mgr.active() == ContextId::Files && show_file_tree) - || (matches!( - ctx_mgr.active(), - ContextId::CommitFiles | ContextId::BranchCommitFiles | ContextId::StashFiles - ) && show_commit_file_tree) - { - hints.extend(tree_hints.iter().map(|(key, label)| (key.as_str(), *label))); - } hints.push(("[/]", "side view")); let view_layout_hint = match diff_view.view_layout { DiffViewLayout::SideBySide => "unified view", @@ -2998,9 +2961,6 @@ fn render_status_bar( ("space", "toggle"), ("`", "tree"), ]); - if show_file_tree { - hints.extend(tree_hints.iter().map(|(key, label)| (key.as_str(), *label))); - } hints.extend([ ("\\", view_layout_hint), ("d", "discard"), @@ -3013,9 +2973,6 @@ fn render_status_bar( hints.push((toggle_head_key.as_str(), "files")); } hints.extend([("enter", "focus diff"), ("`", "tree"), ("y", "copy")]); - if show_commit_file_tree { - hints.extend(tree_hints.iter().map(|(key, label)| (key.as_str(), *label))); - } hints.push(("\\", view_layout_hint)); } ContextId::BranchCommits => { From b16b159fa9058056a2811bf767a7497d1adcff49 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 7 Oct 2026 01:35:11 +0800 Subject: [PATCH 11/11] Fix tree/detail shortcuts and contextual fold hints Use '.' to select the first visible tree child and apostrophe to toggle Commit Details across commit contexts. Update contextual help and the panel's top-right toggle hint to match; explicit tree remappings retain precedence while a tree is active. Show only the configured fold/unfold shortcut in active file-tree list footers, including compare mode. Hide it in flat lists and diff focus, omit disabled bindings, and keep hierarchy navigation in contextual '?' help rather than the footer. Remove Commit Details footer hints. Document the final defaults and footer policy, and add regression coverage for keybinding defaults, remapping precedence, details contexts/help, both details-panel layouts, and contextual normal/compare footers. --- README.md | 12 +- src/config/keybindings.rs | 4 +- src/gui/mod.rs | 77 +++++++++---- src/gui/presentation/commit_details.rs | 44 +++++++- src/gui/presentation/diff_mode.rs | 67 ++++++++---- src/gui/views.rs | 146 ++++++++++++++++++------- 6 files changed, 261 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index d362144..f92b0e0 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ toggle the tree view. With a tree active: - `-` folds/unfolds the selected directory (including the root). - `Enter` focuses the selected file or combined directory diff without changing your normal/half/full layout; `Esc` returns to the file list. -- `,` / `'` (apostrophe) select the parent / first visible child. +- `,` / `.` select the parent / first visible child. - `<` / `>` select the previous / next sibling, skipping nested descendants. Hierarchy navigation also works while the diff is focused. @@ -58,16 +58,18 @@ keybinding: universal: foldDirectory: "-" treeParent: "," - treeChild: "'" + treeChild: "." treePrevSibling: "<" treeNextSibling: ">" ``` Set a binding to `""` to disable it. Collapsed directories must be unfolded before their children can be selected. Tree-navigation actions appear in `?` -only while a tree is active, not in the footer. `.` remains the commit-details -toggle with the default bindings; explicitly remapping a tree action to `.` -will override it while the tree is active. +only while a tree is active. The footer shows just the fold/unfold shortcut +(default `-`) in tree views, not hierarchy navigation or commit details. +`'` (apostrophe) toggles commit details, also shown on the panel’s top-right +border; explicitly remapping a tree action to `'` will override it while +the tree is active. ### Upgrade diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index b4ee933..77fe68c 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -196,7 +196,7 @@ impl Default for UniversalKeybinding { toggle_working_tree_and_head: "".into(), fold_directory: "-".into(), tree_parent: ",".into(), - tree_child: "'".into(), + tree_child: ".".into(), tree_prev_sibling: "<".into(), tree_next_sibling: ">".into(), } @@ -476,7 +476,7 @@ mod tests { let default_config = KeybindingConfig::default(); assert_eq!(default_config.universal.fold_directory, "-"); assert_eq!(default_config.universal.tree_parent, ","); - assert_eq!(default_config.universal.tree_child, "'"); + assert_eq!(default_config.universal.tree_child, "."); assert_eq!(default_config.universal.tree_prev_sibling, "<"); assert_eq!(default_config.universal.tree_next_sibling, ">"); diff --git a/src/gui/mod.rs b/src/gui/mod.rs index c86338e..b5d8061 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -1517,6 +1517,7 @@ impl Gui { &mut self.commit_list_cache, self.layout.side_panel_ratio, self.screen_mode, + &self.config.user_config.keybinding, ); // Render popup overlay on top of diff mode (for ? help, errors, etc.) if self.popup != PopupState::None { @@ -3891,10 +3892,10 @@ impl Gui { return Ok(()); } - // `.` toggles the commit-details box when in any commit-related + // `'` toggles the commit-details box when in any commit-related // context. Kept outside per-context controllers so the binding is // consistent across Commits / BranchCommits / Reflog / CommitFiles. - if key.code == KeyCode::Char('.') && self.context_has_commit_details() { + if key.code == KeyCode::Char('\'') && self.context_has_commit_details() { self.show_commit_details = !self.show_commit_details; self.persist_commit_details_visibility(); return Ok(()); @@ -5994,7 +5995,7 @@ impl Gui { kb.universal.toggle_diff_view_layout.clone(), "Toggle unified / side-by-side view".into(), ), - CommandEntry::keybinding(".".into(), "Toggle commit details panel".into()), + CommandEntry::keybinding("'".into(), "Toggle commit details panel".into()), ]; // Grep only applies to the files list, not the commits list. if active == ContextId::BranchCommitFiles { @@ -6078,7 +6079,7 @@ impl Gui { kb.commits.open_log_menu.clone(), "Filter commits".into(), ), - CommandEntry::keybinding(".".into(), "Toggle commit details panel".into()), + CommandEntry::keybinding("'".into(), "Toggle commit details panel".into()), ]; if !self.cherry_pick_clipboard.is_empty() { entries.insert( @@ -6123,7 +6124,7 @@ impl Gui { "Toggle tree view".into(), ), CommandEntry::keybinding("y".into(), "Copy to clipboard menu".into()), - CommandEntry::keybinding(".".into(), "Toggle commit details panel".into()), + CommandEntry::keybinding("'".into(), "Toggle commit details panel".into()), ], }, ContextId::Reflog => CommandSection { @@ -6147,7 +6148,7 @@ impl Gui { "Copy (cherry-pick)".into(), ), CommandEntry::keybinding("y".into(), "Copy to clipboard menu".into()), - CommandEntry::keybinding(".".into(), "Toggle commit details panel".into()), + CommandEntry::keybinding("'".into(), "Toggle commit details panel".into()), ], }, ContextId::Stash => CommandSection { @@ -8065,7 +8066,7 @@ impl Gui { } /// True when the active context is one where commit-details makes sense - /// (drives both the `.` toggle and layout-time `show_details`). + /// (drives both the `'` toggle and layout-time `show_details`). fn context_has_commit_details(&self) -> bool { matches!( self.context_mgr.active(), @@ -9452,7 +9453,7 @@ mod terminal_mouse_tests { gui.diff_mode.diff_files_selected = 0; gui.diff_mode.viewport_manually_scrolled = true; gui.needs_diff_refresh = false; - gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) .unwrap(); assert_eq!(gui.diff_mode.diff_files_selected, 1); assert!(!gui.diff_mode.viewport_manually_scrolled); @@ -9489,7 +9490,7 @@ mod terminal_mouse_tests { .keybinding .universal .tree_child = "".into(); - gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) .unwrap(); assert_eq!(gui.diff_mode.diff_files_selected, 0); gui.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) @@ -9503,25 +9504,25 @@ mod terminal_mouse_tests { let mut gui = tree_test_gui(&repo); gui.context_mgr.set_active(ContextId::CommitFiles); let details = gui.show_commit_details; - gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) .unwrap(); assert_eq!(gui.context_mgr.selected_active(), 1); assert_eq!(gui.show_commit_details, details); - // Other tree actions remapped to '.' also take precedence, even at a boundary. + // Other tree actions remapped to apostrophe also take precedence, even at a boundary. let kb = &mut Arc::get_mut(&mut gui.config) .unwrap() .user_config .keybinding; kb.universal.tree_child.clear(); - kb.universal.tree_parent = ".".into(); - gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + kb.universal.tree_parent = "'".into(); + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) .unwrap(); assert_eq!(gui.context_mgr.selected_active(), 0); - gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) .unwrap(); assert_eq!(gui.show_commit_details, details); gui.show_commit_file_tree = false; - gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) .unwrap(); assert_ne!(gui.show_commit_details, details); } @@ -9536,11 +9537,11 @@ mod terminal_mouse_tests { gui.context_mgr.set_selection(0); gui.show_commit_file_tree = tree; let details = gui.show_commit_details; - gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) .unwrap(); assert_ne!(gui.show_commit_details, details, "{ctx:?} {tree}"); assert_eq!(gui.context_mgr.selected_active(), 0); - gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) .unwrap(); assert_eq!(gui.context_mgr.selected_active(), usize::from(tree)); assert_ne!(gui.show_commit_details, details); @@ -9548,6 +9549,42 @@ mod terminal_mouse_tests { } } + #[test] + fn apostrophe_toggles_details_and_is_documented_in_commit_context_help() { + let repo = TempRepo::new("apostrophe-details-help"); + let mut gui = tree_test_gui(&repo); + for ctx in [ + ContextId::Commits, + ContextId::BranchCommits, + ContextId::Reflog, + ContextId::CommitFiles, + ContextId::BranchCommitFiles, + ContextId::StashFiles, + ] { + gui.popup = PopupState::None; + gui.context_mgr.set_active(ctx); + let details = gui.show_commit_details; + gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + .unwrap(); + assert_ne!(gui.show_commit_details, details, "{ctx:?}"); + gui.handle_key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)) + .unwrap(); + let PopupState::CommandPalette { sections, .. } = &gui.popup else { + panic!("expected help palette"); + }; + let details_entries: Vec<_> = sections + .iter() + .flat_map(|s| &s.entries) + .filter(|e| e.description == "Toggle commit details panel") + .collect(); + // Stash-file help did not previously include a details entry. + if ctx != ContextId::StashFiles { + assert_eq!(details_entries.len(), 1, "{ctx:?}"); + assert_eq!(details_entries[0].key, "'", "{ctx:?}"); + } + } + } + #[test] fn tree_help_is_only_available_in_active_tree_contexts() { use crate::gui::modes::diff_mode::DiffModeFocus; @@ -9583,7 +9620,7 @@ mod terminal_mouse_tests { section .entries .iter() - .any(|e| e.key == "'" + .any(|e| e.key == "." && e.description == "Select first visible child") ); assert_eq!(section.entries.iter().any(|e| e.key == "-"), !diff); @@ -9617,7 +9654,7 @@ mod terminal_mouse_tests { ) ); if let Some(section) = tree_section { - assert!(section.entries.iter().any(|e| e.key == "'")); + assert!(section.entries.iter().any(|e| e.key == ".")); assert_eq!( section.entries.iter().any(|e| e.key == "-"), focus == DiffModeFocus::CommitFiles @@ -9744,7 +9781,7 @@ mod terminal_mouse_tests { gui.diff_focused = diff; gui.context_mgr.viewport_manually_scrolled = true; gui.needs_diff_refresh = false; - gui.handle_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)) + gui.handle_key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE)) .unwrap(); assert_eq!(gui.context_mgr.selected_active(), 1); assert!(!gui.context_mgr.viewport_manually_scrolled); diff --git a/src/gui/presentation/commit_details.rs b/src/gui/presentation/commit_details.rs index 8bc2cc2..129be76 100644 --- a/src/gui/presentation/commit_details.rs +++ b/src/gui/presentation/commit_details.rs @@ -41,7 +41,7 @@ pub fn render_commit_details( Span::raw(" "), Span::styled("toggle ", Style::default().fg(theme.text_dimmed)), Span::styled( - ".", + "'", Style::default() .fg(theme.accent) .add_modifier(Modifier::BOLD), @@ -424,6 +424,48 @@ fn avatar_color_for(email: &str, theme: &Theme) -> ratatui::style::Color { mod tests { use super::*; + #[test] + fn top_right_toggle_hint_uses_apostrophe_in_both_layouts() { + use ratatui::{Terminal, backend::TestBackend}; + + let commit = Commit { + hash: "1234567890abcdef".into(), + name: "Test commit".into(), + status: CommitStatus::Pushed, + action: String::new(), + tags: vec![], + refs: vec![], + extra_info: String::new(), + author_name: "Test Author".into(), + author_email: String::new(), + unix_timestamp: 0, + parents: vec![], + divergence: Default::default(), + }; + for compact in [false, true] { + let mut terminal = Terminal::new(TestBackend::new(60, 10)).unwrap(); + terminal + .draw(|frame| { + render_commit_details( + frame, + frame.area(), + &commit, + None, + None, + &Theme::default(), + compact, + &mut 0, + ); + }) + .unwrap(); + let top: String = (0..60) + .map(|x| terminal.backend().buffer().cell((x, 0)).unwrap().symbol()) + .collect(); + assert!(top.ends_with(" toggle ' ┐"), "{top}"); + assert!(!top.contains("toggle ."), "{top}"); + } + } + #[test] fn parses_co_authored_by_trailers() { let msg = "Subject\n\nBody\n\nCo-authored-by: Ada Lovelace \nCo-Authored-By: Grace Hopper \n"; diff --git a/src/gui/presentation/diff_mode.rs b/src/gui/presentation/diff_mode.rs index 713f9c6..e782f7a 100644 --- a/src/gui/presentation/diff_mode.rs +++ b/src/gui/presentation/diff_mode.rs @@ -109,6 +109,7 @@ mod tests { &state, &DiffViewState::default(), &Theme::default(), + &crate::config::KeybindingConfig::default(), ) }) .unwrap(); @@ -130,33 +131,48 @@ mod tests { } #[test] - fn tree_compare_navigation_is_absent_from_footer() { + fn compare_footer_shows_only_enabled_folding_in_active_tree_lists() { for tree in [false, true] { for focus in [ DiffModeFocus::CommitFiles, DiffModeFocus::DiffExploration, DiffModeFocus::Commits, + DiffModeFocus::SelectorA, + DiffModeFocus::SelectorB, ] { - let mut state = DiffModeState::new(); - state.show_tree = tree; - state.set_focus(focus); - let mut terminal = Terminal::new(TestBackend::new(180, 1)).unwrap(); - terminal - .draw(|frame| { - render_status_bar( - frame, - frame.area(), - &state, - &DiffViewState::default(), - &Theme::default(), - ) - }) - .unwrap(); - let text = buffer_text(&terminal); - assert!(!text.contains("fold"), "{text}"); - assert!(!text.contains("nav"), "{text}"); - assert!(!text.contains("siblings"), "{text}"); - assert!(text.contains("? help"), "{text}"); + for (binding, hint) in [("-", "-"), ("", "ctrl+f"), ("", "")] { + let mut kb = crate::config::KeybindingConfig::default(); + kb.universal.fold_directory = binding.into(); + let mut state = DiffModeState::new(); + state.show_tree = tree; + state.set_focus(focus); + let mut terminal = Terminal::new(TestBackend::new(240, 1)).unwrap(); + terminal + .draw(|frame| { + render_status_bar( + frame, + frame.area(), + &state, + &DiffViewState::default(), + &Theme::default(), + &kb, + ) + }) + .unwrap(); + let text = buffer_text(&terminal); + assert_eq!( + text.contains("fold/unfold"), + tree && focus == DiffModeFocus::CommitFiles && !binding.is_empty(), + "{text}" + ); + if tree && focus == DiffModeFocus::CommitFiles && !binding.is_empty() { + assert!(text.contains(&format!("{hint} fold/unfold")), "{text}"); + } + assert!(!text.contains("nav"), "{text}"); + assert!(!text.contains("siblings"), "{text}"); + assert!(!text.contains("details"), "{text}"); + assert!(text.contains("? help"), "{text}"); + } } } } @@ -214,6 +230,7 @@ mod tests { &mut CommitListCache::default(), 1.0 / 3.0, mode, + &crate::config::KeybindingConfig::default(), ) }) .unwrap(); @@ -254,6 +271,7 @@ pub fn render( commit_cache: &mut CommitListCache, side_ratio: f64, screen_mode: crate::gui::ScreenMode, + keybindings: &crate::config::KeybindingConfig, ) { let layout = CompareLayout::new(frame.area(), side_ratio, screen_mode, state); let sidebar = layout.sidebar; @@ -286,7 +304,7 @@ pub fn render( } // Status bar - render_status_bar(frame, layout.status, state, diff_view, theme); + render_status_bar(frame, layout.status, state, diff_view, theme, keybindings); // Render combobox dropdown overlay on top of the sidebar if state.editing.is_some() && sidebar.iter().any(|r| !r.is_empty()) { @@ -670,6 +688,7 @@ fn render_status_bar( state: &DiffModeState, diff_view: &DiffViewState, theme: &Theme, + keybindings: &crate::config::KeybindingConfig, ) { // If search is active or has results, show search bar instead of hints if state.file_search_active { @@ -730,6 +749,7 @@ fn render_status_bar( return; } + let fold_key = crate::gui::views::format_key_hint(&keybindings.universal.fold_directory); let hints = if state.editing.is_some() { vec![("Enter", "select"), ("Esc", "cancel"), ("↑↓", "navigate")] } else { @@ -752,6 +772,9 @@ fn render_status_bar( ("`", "tree"), ]); hints.push(("\\", view_layout_hint)); + if state.show_tree && state.focus == DiffModeFocus::CommitFiles && !fold_key.is_empty() { + hints.push((fold_key.as_str(), "fold/unfold")); + } hints.push(("?", "help")); hints }; diff --git a/src/gui/views.rs b/src/gui/views.rs index 7253053..d8144da 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -67,6 +67,13 @@ pub fn render( ) { let area = frame.area(); let panel_count = SideWindow::ALL.len(); + let active_file_tree = match ctx_mgr.active() { + ContextId::Files => show_file_tree, + ContextId::CommitFiles | ContextId::BranchCommitFiles | ContextId::StashFiles => { + show_commit_file_tree + } + _ => false, + }; // Determine which panel index is active so it gets expanded let active_window = ctx_mgr.active_window(); @@ -419,6 +426,7 @@ pub fn render( !cherry_pick_clipboard.is_empty(), &config.user_config.keybinding, !active_commit_filters.is_empty(), + active_file_tree, ); // Render text selection highlight overlay and tooltip (must be before popup) render_selection_overlay(frame, diff_view, fl.main_panel, theme); @@ -1080,6 +1088,7 @@ pub fn render( !cherry_pick_clipboard.is_empty(), &config.user_config.keybinding, !active_commit_filters.is_empty(), + active_file_tree, ); // Render text selection highlight overlay and tooltip @@ -1440,10 +1449,9 @@ mod tests { } #[test] - fn tree_navigation_is_absent_from_footer_across_file_contexts() { + fn footer_shows_only_enabled_folding_in_active_file_tree_lists() { use super::{ContextId, ContextManager, DiffViewState, KeybindingConfig, Model}; let mut kb = KeybindingConfig::default(); - kb.universal.fold_directory = "f".into(); kb.universal.tree_parent = "p".into(); kb.universal.tree_child = "c".into(); kb.universal.tree_prev_sibling.clear(); @@ -1457,36 +1465,88 @@ mod tests { ContextId::StashFiles, ] { for focused in [false, true] { - let mut ctx_mgr = ContextManager::new(); - ctx_mgr.set_active(ctx); - let mut terminal = Terminal::new(TestBackend::new(240, 1)).unwrap(); - terminal - .draw(|frame| { - super::render_status_bar( - frame, - frame.area(), - &ctx_mgr, - &diff_view, - &Theme::default(), - &Model::default(), - focused, - false, - &kb, - false, - ) - }) - .unwrap(); - let text: String = (0..240) - .map(|x| terminal.backend().buffer().cell((x, 0)).unwrap().symbol()) - .collect(); - assert!(!text.contains("p/c nav"), "{ctx:?} {text}"); - assert!(!text.contains("n siblings"), "{text}"); - assert!(!text.contains("f fold"), "{text}"); - assert!(!text.contains(",/."), "{text}"); + for tree in [false, true] { + for (binding, hint) in [("-", "-"), ("", "ctrl+f"), ("", "")] { + kb.universal.fold_directory = binding.into(); + let mut ctx_mgr = ContextManager::new(); + ctx_mgr.set_active(ctx); + let mut terminal = Terminal::new(TestBackend::new(240, 1)).unwrap(); + terminal + .draw(|frame| { + super::render_status_bar( + frame, + frame.area(), + &ctx_mgr, + &diff_view, + &Theme::default(), + &Model::default(), + focused, + false, + &kb, + false, + tree, + ) + }) + .unwrap(); + let text: String = (0..240) + .map(|x| terminal.backend().buffer().cell((x, 0)).unwrap().symbol()) + .collect(); + assert_eq!( + text.contains("fold/unfold"), + tree && !focused && !binding.is_empty(), + "{ctx:?} {focused} {tree} {binding}: {text}" + ); + if tree && !focused && !binding.is_empty() { + assert!(text.contains(&format!("{hint} fold/unfold")), "{text}"); + } + assert!(!text.contains("p/c nav"), "{text}"); + assert!(!text.contains("n siblings"), "{text}"); + assert!(!text.contains("details"), "{text}"); + } + } } } } + #[test] + fn commit_list_footers_omit_details_toggle() { + for ctx in [ + super::ContextId::Commits, + super::ContextId::BranchCommits, + super::ContextId::Reflog, + ] { + let mut ctx_mgr = super::ContextManager::new(); + ctx_mgr.set_active(ctx); + let mut terminal = Terminal::new(TestBackend::new(240, 1)).unwrap(); + terminal + .draw(|frame| { + super::render_status_bar( + frame, + frame.area(), + &ctx_mgr, + &super::DiffViewState::default(), + &Theme::default(), + &super::Model::default(), + false, + false, + &super::KeybindingConfig::default(), + false, + false, + ) + }) + .unwrap(); + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|c| c.symbol()) + .collect(); + assert!(!text.contains("details"), "{ctx:?}: {text}"); + assert!(!text.contains("fold/unfold"), "{ctx:?}: {text}"); + } + } + #[test] fn worktrees_render_names_branches_and_main_label_in_every_mode() { let model = crate::model::Model { @@ -1621,6 +1681,7 @@ mod tests { false, &keybindings, false, + false, ); }) .unwrap(); @@ -1650,6 +1711,7 @@ mod tests { false, &keybindings, false, + false, ); }) .unwrap(); @@ -1679,6 +1741,7 @@ mod tests { false, &keybindings, false, + false, ); }) .unwrap(); @@ -1708,6 +1771,7 @@ mod tests { false, &keybindings, false, + false, ); }) .unwrap(); @@ -1737,6 +1801,7 @@ mod tests { false, &keybindings, false, + false, ); }) .unwrap(); @@ -1768,6 +1833,7 @@ mod tests { false, &custom_kb, false, + false, ); }) .unwrap(); @@ -1955,6 +2021,7 @@ mod tests { false, &keybindings, true, + false, ); }) .expect("should render status bar with active filters"); @@ -1988,6 +2055,7 @@ mod tests { false, &keybindings, false, + false, ); }) .expect("should render status bar without active filters"); @@ -2759,7 +2827,7 @@ fn get_info_content<'a>(model: &Model, ctx_mgr: &ContextManager) -> Vec } } -fn format_key_hint(key: &str) -> String { +pub(super) fn format_key_hint(key: &str) -> String { let key = key.trim(); let inner = if let Some(stripped) = key.strip_prefix('<').and_then(|k| k.strip_suffix('>')) { stripped.trim() @@ -2814,6 +2882,7 @@ fn render_search_bar_or_status_bar( has_copied_commits: bool, keybindings: &KeybindingConfig, has_active_filters: bool, + active_file_tree: bool, ) { if let Some((query, match_count, current_match)) = search_state { let match_info = if match_count > 0 { @@ -2873,6 +2942,7 @@ fn render_search_bar_or_status_bar( has_copied_commits, keybindings, has_active_filters, + active_file_tree, ); } } @@ -2888,11 +2958,17 @@ fn render_status_bar( has_copied_commits: bool, keybindings: &KeybindingConfig, has_active_filters: bool, + active_file_tree: bool, ) { let mut hints: Vec<(&str, &str)> = Vec::new(); let mut emphasized: Vec<&str> = Vec::new(); let open_log_menu_key = format_key_hint(&keybindings.commits.open_log_menu); let toggle_head_key = format_key_hint(&keybindings.universal.toggle_working_tree_and_head); + let fold_key = format_key_hint(&keybindings.universal.fold_directory); + + if active_file_tree && !diff_focused && !fold_key.is_empty() { + hints.push((fold_key.as_str(), "fold/unfold")); + } // When in a special state (rebasing/merging/cherry-picking), show those options prominently if model.is_rebasing { @@ -2979,21 +3055,13 @@ fn render_status_bar( if !toggle_head_key.is_empty() { hints.push((toggle_head_key.as_str(), "files")); } - hints.extend([ - ("enter", "commit files"), - ("\\", view_layout_hint), - (".", "details"), - ]); + hints.extend([("enter", "commit files"), ("\\", view_layout_hint)]); } ContextId::Reflog => { if !toggle_head_key.is_empty() { hints.push((toggle_head_key.as_str(), "files")); } - hints.extend([ - ("enter", "commit files"), - ("\\", view_layout_hint), - (".", "details"), - ]); + hints.extend([("enter", "commit files"), ("\\", view_layout_hint)]); } ContextId::Branches => { hints.extend([