From 0e58f267ba1073e834866d142d5f2e617b601ebf Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 7 Oct 2026 02:15:58 +0800 Subject: [PATCH] fix: remove HEAD toggle to restore Ctrl-G commit generation --- README.md | 9 +- npm/README.md | 136 +++++++++++++++++++- src/config/keybindings.rs | 13 +- src/gui/mod.rs | 248 +++++++++++++++-------------------- src/gui/views.rs | 264 ++++++-------------------------------- 5 files changed, 288 insertions(+), 382 deletions(-) diff --git a/README.md b/README.md index f92b0e0..2e476ea 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,10 @@ 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). +Use `lazygitrs --commits` to start at the checked-out HEAD commit. If filters +or pagination hide HEAD, it opens a separate HEAD history view without clearing +your filters. Use `2` and `4` to navigate between Files and Commits; `Ctrl+G` +in Files generates a commit message using `git.commit.generateCommand`. ### File tree navigation diff --git a/npm/README.md b/npm/README.md index 14ba13c..2e476ea 100644 --- a/npm/README.md +++ b/npm/README.md @@ -33,6 +33,52 @@ Then run: lazygitrs ``` +Use `lazygitrs --commits` to start at the checked-out HEAD commit. If filters +or pagination hide HEAD, it opens a separate HEAD history view without clearing +your filters. Use `2` and `4` to navigate between Files and Commits; `Ctrl+G` +in Files generates a commit message using `git.commit.generateCommand`. + +### File tree navigation + +In Files, commit/stash file lists, and compare mode, press backtick (`` ` ``) to +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. +- `<` / `>` select the previous / next sibling, skipping nested descendants. + Hierarchy navigation also works while the diff is focused. + +These shortcuts are configurable under `keybinding.universal` in your config: + +```yaml +keybinding: + universal: + foldDirectory: "-" + treeParent: "," + 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. 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 + +Detects how you installed (brew / npm / bun / cargo / install.sh) and upgrades in place: + +```sh +lazygitrs upgrade # latest +lazygitrs upgrade 0.0.32 # specific version +``` + ### What's different - [x] **AI commit messages** β€” works with whatever agent you already use (claude, opencode, codex, or my minimal shim [modelcli](https://github.com/blankeos/modelcli)). Set `git.commit.generateCommand` (see [Configuration](#configuration)): @@ -42,13 +88,13 @@ lazygitrs git: commit: # Using claude - generateCommand: "claude -p 'Generate a conventional commit message for this diff.' --no-session-persistence" + generateCommand: "claude -p 'Generate a conventional commit message for this diff. Do not hard-wrap lines; one bullet per line; blank line between paragraphs.' --no-session-persistence" # Using opencode - generateCommand: "opencode run 'Generate a conventional commit message for this diff.'" + generateCommand: "opencode run 'Generate a conventional commit message for this diff. Do not hard-wrap lines; one bullet per line; blank line between paragraphs.'" # Using codex - generateCommand: "codex exec --ephemeral 'Generate a conventional commit message for this diff.'" + generateCommand: "codex exec --ephemeral 'Generate a conventional commit message for this diff. Do not hard-wrap lines; one bullet per line; blank line between paragraphs.'" # Using modelcli - generateCommand: 'DIFF=$(git diff --cached) && modelcli "Generate a conventional commit message for this diff. Always provide a bulletpoint body. $DIFF"' + generateCommand: 'DIFF=$(git diff --cached) && modelcli "Generate a conventional commit message for this diff. Always provide a bulletpoint body. Do not hard-wrap lines; one bullet per line. $DIFF"' ``` - [x] **Side-by-side + unified diffs** with syntax highlighting by default and unified as well, no pager hacks needed @@ -63,6 +109,31 @@ lazygitrs - [x] `git diff/compare` (global `W`) and then asks what branch/commit A and B, has quick search. - [x] `git rebase` (global `I`) and then asks rebase on top of what branch/commit. - [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 @@ -96,6 +167,63 @@ lazygitrs ships with 30+ built-in color themes (Catppuccin, Dracula, Tokyo Night } ``` +### Editor integrations + +
+Helix β€” Space G g to open, Space G f for file history + +Add to `~/.config/helix/config.toml` β€” capital `G` keeps the built-in `space g` changed-file picker intact: + +```toml +[keys.normal.space.G] +g = [":insert-output lazygitrs", ":redraw"] +f = [":insert-output lazygitrs -f '%{file_path_absolute}'", ":redraw"] +``` + +Absolute path matters β€” `-f` resolves it to repo-relative (e.g. `apps/nextjs/next.config.ts` in a monorepo). + +For `e` (edit back in hx) β€” `~/.config/lazygitrs/config.yml`: + +```yaml +os: + editPreset: "helix" +``` + +For `o` (open), leave the default β€” OS opener (Finder for folders on macOS). + +
+ +
+Neovim (LazyVim / snacks.nvim) β€” <leader>gg to open, <leader>gF for file history + +`Snacks.lazygit()` hardcodes `lazygit`, so use `Snacks.terminal` instead. In `~/.config/nvim/lua/plugins/snacks-lazygitrs.lua`: + +```lua +return { + { + "folke/snacks.nvim", + opts = { lazygit = { configure = false } }, + keys = { + { "gg", function() Snacks.terminal({ "lazygitrs" }, { cwd = LazyVim.root.git(), win = { style = "lazygit" } }) end, desc = "Lazygitrs" }, + { "gF", function() Snacks.terminal({ "lazygitrs", "-f", vim.fn.expand("%:p") }, { cwd = LazyVim.root.git(), win = { style = "lazygit" } }) end, desc = "Lazygitrs file history" }, + }, + }, +} +``` + +Restart nvim (or `:Lazy reload snacks.nvim`) to pick it up. + +For `e` (edit back in nvim) β€” `~/.config/lazygitrs/config.yml`: + +```yaml +os: + editPreset: "nvim" +``` + +For `o` (open), leave the default β€” it uses the OS opener (Finder for folders on macOS). + +
+ ### Benchmarks diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 77fe68c..150b0f8 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -126,8 +126,6 @@ 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, #[serde(rename = "foldDirectory")] pub fold_directory: String, #[serde(rename = "treeParent")] @@ -193,7 +191,6 @@ impl Default for UniversalKeybinding { main_panel_full: "".into(), reset_side_panel: "".into(), toggle_diff_view_layout: "\\".into(), - toggle_working_tree_and_head: "".into(), fold_directory: "-".into(), tree_parent: ",".into(), tree_child: ".".into(), @@ -471,6 +468,16 @@ pub fn parse_key(s: &str) -> Option { mod tests { use super::*; + #[test] + fn legacy_head_toggle_config_does_not_override_ai_generation() { + let config: KeybindingConfig = + serde_yaml::from_str("universal:\n toggleWorkingTreeAndHead: ''\n").unwrap(); + assert_eq!(config.files.generate_ai_commit, ""); + assert_eq!(config.commit_message.ai_generate, ""); + let serialized = serde_yaml::to_string(&config).unwrap(); + assert!(!serialized.contains("toggleWorkingTreeAndHead")); + } + #[test] fn test_tree_keybindings_defaults_and_deserialization() { let default_config = KeybindingConfig::default(); diff --git a/src/gui/mod.rs b/src/gui/mod.rs index b80db26..23da5bb 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -3640,11 +3640,6 @@ 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) @@ -4205,11 +4200,6 @@ 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(); - } - if self.try_handle_tree_navigation(key) { return Ok(()); } @@ -5881,10 +5871,6 @@ 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()), @@ -6314,15 +6300,6 @@ 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()), @@ -8899,22 +8876,6 @@ impl Gui { 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, @@ -10296,38 +10257,103 @@ mod terminal_mouse_tests { } #[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(); + fn ctrl_g_in_files_opens_ai_commit_generation() { + let repo = TempRepo::new("ctrl-g-generation"); + repo.commit("file", "base"); + std::fs::write(repo.path().join("file"), "changed").unwrap(); + for staged in [false, true] { + if staged { + repo.git(&["add", "file"]); + } + let mut config = crate::config::AppConfig::default(); + config.user_config.git.commit.generate_command.clear(); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(config, git, None, false).unwrap(); + *gui.model.lock().unwrap() = gui.git.load_model().unwrap(); + gui.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(gui.context_mgr.active(), ContextId::Files); + if staged { + assert!(matches!( + &gui.popup, + PopupState::Message { title, .. } if title == "AI generation unavailable" + )); + } else { + assert!(matches!( + &gui.popup, + PopupState::Confirm { title, .. } if title == "No files staged" + )); + } + } + } + + #[test] + fn ctrl_g_in_commit_editor_still_generates_message() { + let repo = TempRepo::new("ctrl-g-editor"); + repo.commit("file", "base"); + std::fs::write(repo.path().join("file"), "changed").unwrap(); + repo.git(&["add", "file"]); + let mut config = crate::config::AppConfig::default(); + config.user_config.git.commit.generate_command.clear(); 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(); + *gui.model.lock().unwrap() = gui.git.load_model().unwrap(); + gui.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)) + .unwrap(); + assert!(matches!(gui.popup, PopupState::CommitInput { .. })); + gui.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)) + .unwrap(); assert_eq!(gui.context_mgr.active(), ContextId::Files); - assert!(gui.needs_diff_refresh); + assert!(matches!( + &gui.popup, + PopupState::Message { title, .. } if title == "AI generation unavailable" + )); + } - // Toggle from CommitFiles subcontext back to Files - gui.context_mgr.set_active(ContextId::CommitFiles); - gui.toggle_working_tree_and_head().unwrap(); + #[test] + fn files_ai_commit_shortcut_can_be_remapped() { + let repo = TempRepo::new("remapped-ai-commit"); + repo.commit("file", "base"); + std::fs::write(repo.path().join("file"), "changed").unwrap(); + let mut config = crate::config::AppConfig::default(); + config.user_config.keybinding.files.generate_ai_commit = "".into(); + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = Gui::new(config, git, None, false).unwrap(); + *gui.model.lock().unwrap() = gui.git.load_model().unwrap(); + gui.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)) + .unwrap(); + assert!(matches!(gui.popup, PopupState::None)); assert_eq!(gui.context_mgr.active(), ContextId::Files); + gui.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)) + .unwrap(); + assert!(matches!( + &gui.popup, + PopupState::Confirm { title, .. } if title == "No files staged" + )); + } - // 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 ctrl_g_no_longer_switches_panels() { + let repo = TempRepo::new("ctrl-g-no-toggle"); + repo.commit("file", "base"); + for context in [ + ContextId::Files, + ContextId::Commits, + ContextId::CommitFiles, + ContextId::Reflog, + ] { + for diff_focused in [false, true] { + let git = crate::git::GitCommands::new(repo.path()).unwrap(); + let mut gui = + Gui::new(crate::config::AppConfig::default(), git, None, false).unwrap(); + gui.context_mgr.set_active(context); + gui.diff_focused = diff_focused; + gui.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL)) + .unwrap(); + assert_eq!(gui.context_mgr.active(), context); + assert_eq!(gui.diff_focused, diff_focused); + } + } } #[test] @@ -10342,50 +10368,7 @@ mod terminal_mouse_tests { } #[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() { + fn head_focus_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"]); @@ -10396,7 +10379,7 @@ mod terminal_mouse_tests { 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(); + gui.focus_head_commit().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); @@ -10404,7 +10387,7 @@ mod terminal_mouse_tests { } #[test] - fn head_toggle_preserves_filters_and_uses_separate_head_history() { + fn head_focus_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"); @@ -10435,7 +10418,7 @@ mod terminal_mouse_tests { gui.context_mgr.set_active(ContextId::Files); gui.context_mgr.set_selection(2); gui.diff_focused = true; - gui.toggle_working_tree_and_head().unwrap(); + gui.focus_head_commit().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); @@ -10457,15 +10440,16 @@ mod terminal_mouse_tests { 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(); + gui.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)) + .unwrap(); assert_eq!(gui.context_mgr.active(), ContextId::Files); assert_eq!(gui.context_mgr.selected(ContextId::Files), 2); - assert!(gui.diff_focused); + assert!(!gui.diff_focused); } } #[test] - fn head_toggle_handles_head_outside_the_loaded_page_and_detached_head() { + fn head_focus_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"); @@ -10474,44 +10458,12 @@ mod terminal_mouse_tests { 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(); + gui.focus_head_commit().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"); diff --git a/src/gui/views.rs b/src/gui/views.rs index d8144da..5f925f1 100644 --- a/src/gui/views.rs +++ b/src/gui/views.rs @@ -1652,201 +1652,50 @@ mod tests { } #[test] - fn status_bar_shows_dual_diff_toggle_hint() { - use crate::config::KeybindingConfig; - use crate::gui::context::{ContextId, ContextManager}; + fn status_bar_has_no_removed_head_toggle_hint() { + use super::{ContextId, ContextManager, render_status_bar}; 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 terminal = Terminal::new(TestBackend::new(160, 1)).unwrap(); 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, - 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, - 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, - 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, - 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, - 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, - 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 - ); + for context in [ + ContextId::Files, + ContextId::Commits, + ContextId::CommitFiles, + ContextId::Reflog, + ] { + for diff_focused in [false, true] { + ctx_mgr.set_active(context); + let mut diff_view = crate::pager::side_by_side::DiffViewState::new(); + diff_view.load_from_diff_output("file", "@@ -1 +1 @@\n-old\n+new\n"); + terminal + .draw(|frame| { + render_status_bar( + frame, + Rect::new(0, 0, 160, 1), + &ctx_mgr, + &diff_view, + &Theme::default(), + &Model::default(), + diff_focused, + false, + &crate::config::KeybindingConfig::default(), + false, + false, + ); + }) + .unwrap(); + let content: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(!content.contains("ctrl+g head"), "{content}"); + assert!(!content.contains("ctrl+g files"), "{content}"); + } + } } #[test] @@ -2039,7 +1888,7 @@ mod tests { content ); - assert!(content.contains("ctrl+g files"), "{content}"); + assert!(!content.contains("ctrl+g files"), "{content}"); // Without active filters terminal @@ -2963,7 +2812,6 @@ 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); let fold_key = format_key_hint(&keybindings.universal.fold_directory); if active_file_tree && !diff_focused && !fold_key.is_empty() { @@ -2988,10 +2836,6 @@ 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"); @@ -3003,15 +2847,6 @@ 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")); @@ -3028,9 +2863,6 @@ 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"), @@ -3045,22 +2877,13 @@ 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"), ("`", "tree"), ("y", "copy")]); hints.push(("\\", view_layout_hint)); } ContextId::BranchCommits => { - if !toggle_head_key.is_empty() { - hints.push((toggle_head_key.as_str(), "files")); - } 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)]); } ContextId::Branches => { @@ -3073,9 +2896,6 @@ 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")); }