From 7a0f664eb256999c467a7f3add737059b771c42e Mon Sep 17 00:00:00 2001 From: Marcel Gotsch Date: Sun, 8 Feb 2026 14:09:09 +0100 Subject: [PATCH 1/3] fix: picker doesn't close menu on error or cancellation --- src/app.rs | 4 ++++ .../gitu__tests__merge__merge_picker_cancel.snap | 14 +++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) 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/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 From 63b60574803d99ba3219884558ce7fdc1fbeeb85 Mon Sep 17 00:00:00 2001 From: Marcel Gotsch Date: Sun, 8 Feb 2026 14:35:35 +0100 Subject: [PATCH 2/3] add convenience function for starting a picker for branches & tags --- src/item_data.rs | 2 +- src/ops/merge.rs | 45 ++++++++---------------------------- src/picker.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/src/item_data.rs b/src/item_data.rs index 283322c5fb..5bd6f54488 100644 --- a/src/item_data.rs +++ b/src/item_data.rs @@ -67,7 +67,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/merge.rs b/src/ops/merge.rs index 43f7608ad1..963eec37b9 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, }; @@ -77,40 +75,17 @@ impl OpTrait for Merge { }; 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 From 06bfb6ad738df6dac3300df8afc418f18f474a1f Mon Sep 17 00:00:00 2001 From: Marcel Gotsch Date: Sun, 1 Feb 2026 21:26:51 +0100 Subject: [PATCH 3/3] feat: checkout branch/revision with picker The checkout action shows an interactive picker to select a branch to checkout using fuzzy matching. Checking out a custom revision not found in the branch list, e.g. a commit hash, is also possible. If the checkout is started from a previously selected revision, e.g. by selecting one in Show Refs or from the recent commits section, the revision is put at the top, allowing users to continue by just hitting like before. --- src/item_data.rs | 7 +++++ src/ops/branch.rs | 28 +++++++++++-------- src/ops/merge.rs | 6 +--- src/tests/branch.rs | 10 +++++++ ...__tests__branch__switch_branch_picker.snap | 25 +++++++++++++++++ ...witch_branch_selected_revision_picker.snap | 25 +++++++++++++++++ ...ts__editor__re_enter_prompt_from_menu.snap | 14 +++++----- 7 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 src/tests/snapshots/gitu__tests__branch__switch_branch_picker.snap create mode 100644 src/tests/snapshots/gitu__tests__branch__switch_branch_selected_revision_picker.snap diff --git a/src/item_data.rs b/src/item_data.rs index 5bd6f54488..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 { 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 963eec37b9..56d0cceeb7 100644 --- a/src/ops/merge.rs +++ b/src/ops/merge.rs @@ -68,11 +68,7 @@ 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| { // Allow custom input to support commit hashes, relative refs (e.g., HEAD~3), 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