diff --git a/src/app.rs b/src/app.rs index eef004fb7a..e999465952 100644 --- a/src/app.rs +++ b/src/app.rs @@ -664,6 +664,10 @@ impl App { self.state.picker = Some(picker_state); let result = self.handle_picker(term); + if let Err(_) | Ok(None) = result { + self.close_menu(); + } + self.state.picker = None; result diff --git a/src/item_data.rs b/src/item_data.rs index 283322c5fb..f8735b1ad0 100644 --- a/src/item_data.rs +++ b/src/item_data.rs @@ -59,6 +59,13 @@ impl ItemData { | ItemData::BranchStatus(_, _, _) ) } + + pub(crate) fn to_ref_kind(&self) -> Option { + match self { + Self::Reference { kind, .. } => Some(kind.clone()), + _ => None, + } + } } impl Default for ItemData { @@ -67,7 +74,7 @@ impl Default for ItemData { } } -#[derive(Clone, Debug)] +#[derive(PartialEq, Clone, Debug)] pub(crate) enum RefKind { Tag(String), Branch(String), diff --git a/src/ops/branch.rs b/src/ops/branch.rs index 825234982d..4f60458944 100644 --- a/src/ops/branch.rs +++ b/src/ops/branch.rs @@ -1,4 +1,4 @@ -use super::{Action, OpTrait, selected_rev}; +use super::{Action, OpTrait}; use crate::{ Res, app::{App, PromptParams, State}, @@ -9,6 +9,7 @@ use crate::{ }, item_data::{ItemData, RefKind}, menu::arg::Arg, + picker::{BranchesAndTagsOptions, PickerState}, term::Term, }; use std::{process::Command, rc::Rc}; @@ -19,19 +20,24 @@ pub(crate) fn init_args() -> Vec { pub(crate) struct Checkout; impl OpTrait for Checkout { - fn get_action(&self, _target: &ItemData) -> Option { + fn get_action(&self, target: &ItemData) -> Option { + let default_ref = target.to_ref_kind(); + Some(Rc::new(move |app: &mut App, term: &mut Term| { - let rev = app.prompt( - term, - &PromptParams { - prompt: "Checkout", - create_default_value: Box::new(selected_rev), - ..Default::default() + // Allow custom input to support checking out other revisions not in the list + let picker = PickerState::for_branches_and_tags( + "Checkout", + &app.state.repo, + BranchesAndTagsOptions { + exclude_head: true, + allow_custom_input: true, + default: default_ref.clone(), }, )?; - - checkout(app, term, &rev)?; - Ok(()) + match app.picker(term, picker)? { + Some(data) => checkout(app, term, data.display()), + None => Ok(()), // picker got cancelled + } })) } diff --git a/src/ops/merge.rs b/src/ops/merge.rs index 43f7608ad1..56d0cceeb7 100644 --- a/src/ops/merge.rs +++ b/src/ops/merge.rs @@ -1,12 +1,10 @@ use super::{Action, OpTrait}; -use crate::item_data::RefKind; use crate::{ Res, app::{App, State}, - error::Error, item_data::ItemData, menu::arg::Arg, - picker::PickerState, + picker::{BranchesAndTagsOptions, PickerState}, term::Term, }; @@ -70,47 +68,20 @@ pub(crate) struct Merge; impl OpTrait for Merge { fn get_action(&self, target: &ItemData) -> Option { // Extract default ref from target if it's a Reference - let default_ref = if let ItemData::Reference { kind, .. } = target { - Some(kind.clone()) - } else { - None - }; + let default_ref = target.to_ref_kind(); Some(Rc::new(move |app: &mut App, term: &mut Term| { - // Get current HEAD reference to exclude it from picker - let exclude_ref = { - let head = app.state.repo.head().map_err(Error::GetHead)?; - RefKind::from_reference(&head) - }; - - // Collect all branches (local and remote) - let branches = app - .state - .repo - .branches(None) - .map_err(Error::ListGitReferences)? - .filter_map(|branch| { - let (branch, _) = branch.ok()?; - RefKind::from_reference(branch.get()) - }); - - // Collect all tags - let tags: Vec = app - .state - .repo - .tag_names(None) - .map_err(Error::ListGitReferences)? - .into_iter() - .flatten() - .map(|tag_name| RefKind::Tag(tag_name.to_string())) - .collect(); - - let all_refs: Vec = branches.chain(tags).collect(); - // Allow custom input to support commit hashes, relative refs (e.g., HEAD~3), // and other git revisions not in the predefined list - let picker = - PickerState::with_refs("Merge", all_refs, exclude_ref, default_ref.clone(), true); + let picker = PickerState::for_branches_and_tags( + "Merge", + &app.state.repo, + BranchesAndTagsOptions { + exclude_head: true, + allow_custom_input: true, + default: default_ref.clone(), + }, + )?; let result = app.picker(term, picker)?; if let Some(data) = result { diff --git a/src/picker.rs b/src/picker.rs index 55ef715ef5..bc4781ced4 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -1,5 +1,7 @@ +use crate::error::Error; use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::skim::SkimMatcherV2; +use git2::Repository; use std::borrow::Cow; use std::collections::HashMap; use tui_prompts::State as _; @@ -65,6 +67,12 @@ pub enum PickerStatus { Cancelled, } +pub(crate) struct BranchesAndTagsOptions { + pub exclude_head: bool, + pub allow_custom_input: bool, + pub default: Option, +} + /// State of the picker component pub struct PickerState { /// All available items (excluding custom input) @@ -109,6 +117,57 @@ impl PickerState { state } + /// Create a picker to select from existing branches or tags. + /// + /// The default option, if provided, will be displayed at the top and + /// is selected by default. + /// + /// Custom user input can be enabled to select arbitrary revisions, such as + /// HEAD~2. + pub(crate) fn for_branches_and_tags( + prompt: impl Into>, + repo: &Repository, + options: BranchesAndTagsOptions, + ) -> Result { + // Get current HEAD reference to exclude it from picker + let exclude_ref = if options.exclude_head { + let head = repo.head().map_err(Error::GetHead)?; + RefKind::from_reference(&head) + } else { + None + }; + // Ignore default if it's excluded. + let default = if options.default == exclude_ref { + None + } else { + options.default + }; + + let branches = repo + .branches(None) + .map_err(Error::ListGitReferences)? + .filter_map(|branch| { + let (branch, _) = branch.ok()?; + RefKind::from_reference(branch.get()) + }); + + let tags = repo.tag_names(None).map_err(Error::ListGitReferences)?; + let tags = tags + .into_iter() + .flatten() + .map(|tag_name| RefKind::Tag(tag_name.to_string())); + + let all_refs: Vec = branches.chain(tags).collect(); + + Ok(Self::with_refs( + prompt, + all_refs, + exclude_ref, + default, + options.allow_custom_input, + )) + } + /// Create a picker from RefKinds, automatically handling duplicates and sorting /// /// Items are sorted as: default (if provided) -> branches -> tags -> remotes diff --git a/src/tests/branch.rs b/src/tests/branch.rs index ce189cf4d1..7812646bf7 100644 --- a/src/tests/branch.rs +++ b/src/tests/branch.rs @@ -23,6 +23,16 @@ fn switch_branch_input() { snapshot!(setup(setup_clone!()), "Ybbmerged"); } +#[test] +fn switch_branch_picker() { + snapshot!(setup(setup_clone!()), "bb"); +} + +#[test] +fn switch_branch_selected_revision_picker() { + snapshot!(setup(setup_clone!()), "Yjjbb"); +} + #[test] fn checkout_new_branch() { snapshot!(setup(setup_clone!()), "bcnew"); diff --git a/src/tests/snapshots/gitu__tests__branch__switch_branch_picker.snap b/src/tests/snapshots/gitu__tests__branch__switch_branch_picker.snap new file mode 100644 index 0000000000..0e7ba2891d --- /dev/null +++ b/src/tests/snapshots/gitu__tests__branch__switch_branch_picker.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/branch.rs +expression: ctx.redact_buffer() +--- + On branch main | + Your branch is up to date with 'origin/main'. | + | + Recent commits | + b66a0bf main merged origin/main add initial-file | + | + | + | +────────────────────────────────────────────────────────────────────────────────| + 4/4 Checkout › █ | +▌merged | + unmerged | + origin/HEAD | + origin/main | + | + | + | + | + | + | +styles_hash: a64f5b0f5c87b3e5 diff --git a/src/tests/snapshots/gitu__tests__branch__switch_branch_selected_revision_picker.snap b/src/tests/snapshots/gitu__tests__branch__switch_branch_selected_revision_picker.snap new file mode 100644 index 0000000000..307126511c --- /dev/null +++ b/src/tests/snapshots/gitu__tests__branch__switch_branch_selected_revision_picker.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/branch.rs +expression: ctx.redact_buffer() +--- + Branches | + * main | + merged | + unmerged | + | + Remote origin | + origin/HEAD | + origin/main | +────────────────────────────────────────────────────────────────────────────────| + 4/4 Checkout › █ | +▌merged | + unmerged | + origin/HEAD | + origin/main | + | + | + | + | + | + | +styles_hash: 2cac6f3d25b0271c diff --git a/src/tests/snapshots/gitu__tests__editor__re_enter_prompt_from_menu.snap b/src/tests/snapshots/gitu__tests__editor__re_enter_prompt_from_menu.snap index 225503bda1..eea969d927 100644 --- a/src/tests/snapshots/gitu__tests__editor__re_enter_prompt_from_menu.snap +++ b/src/tests/snapshots/gitu__tests__editor__re_enter_prompt_from_menu.snap @@ -2,14 +2,18 @@ source: src/tests/editor.rs expression: ctx.redact_buffer() --- -▌On branch main | -▌Your branch is up to date with 'origin/main'. | + On branch main | + Your branch is up to date with 'origin/main'. | | Recent commits | b66a0bf main origin/main add initial-file | | | | +────────────────────────────────────────────────────────────────────────────────| + 2/2 Checkout › █ | +▌origin/HEAD | + origin/main | | | | @@ -18,8 +22,4 @@ expression: ctx.redact_buffer() | | | - | - | -────────────────────────────────────────────────────────────────────────────────| -? Checkout: › █ | -styles_hash: 43da189f53b3be3d +styles_hash: 49e995be810c0237 diff --git a/src/tests/snapshots/gitu__tests__merge__merge_picker_cancel.snap b/src/tests/snapshots/gitu__tests__merge__merge_picker_cancel.snap index 9e3885316e..65b3b5a298 100644 --- a/src/tests/snapshots/gitu__tests__merge__merge_picker_cancel.snap +++ b/src/tests/snapshots/gitu__tests__merge__merge_picker_cancel.snap @@ -16,10 +16,10 @@ expression: ctx.redact_buffer() | | | -────────────────────────────────────────────────────────────────────────────────| - Merge Arguments | - m merge -f Fast-forward only (--ff-only) | - a abort -n No fast-forward (--no-ff) | - c continue | - q/ Quit/Close | -styles_hash: 7b86e45a0c70a078 + | + | + | + | + | + | +styles_hash: f47a6512af0aca26