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); + } +}