Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion src/item_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ impl ItemData {
| ItemData::BranchStatus(_, _, _)
)
}

pub(crate) fn to_ref_kind(&self) -> Option<RefKind> {
match self {
Self::Reference { kind, .. } => Some(kind.clone()),
_ => None,
}
}
}

impl Default for ItemData {
Expand All @@ -67,7 +74,7 @@ impl Default for ItemData {
}
}

#[derive(Clone, Debug)]
#[derive(PartialEq, Clone, Debug)]
pub(crate) enum RefKind {
Tag(String),
Branch(String),
Expand Down
58 changes: 43 additions & 15 deletions src/ops/branch.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use super::{Action, OpTrait, selected_rev};
use super::{Action, OpTrait};
use crate::{
Res,
app::{App, PromptParams, State},
Expand All @@ -9,6 +9,7 @@ use crate::{
},
item_data::{ItemData, RefKind},
menu::arg::Arg,
picker::{BranchesAndTagsOptions, PickerState},
term::Term,
};
use std::{process::Command, rc::Rc};
Expand All @@ -19,19 +20,24 @@ pub(crate) fn init_args() -> Vec<Arg> {

pub(crate) struct Checkout;
impl OpTrait for Checkout {
fn get_action(&self, _target: &ItemData) -> Option<Action> {
fn get_action(&self, target: &ItemData) -> Option<Action> {
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
}
}))
}

Expand All @@ -53,15 +59,32 @@ pub(crate) struct CheckoutNewBranch;
impl OpTrait for CheckoutNewBranch {
fn get_action(&self, _target: &ItemData) -> Option<Action> {
Some(Rc::new(|app: &mut App, term: &mut Term| {
let start_point_picker = PickerState::for_branches_and_tags(
"Create branch starting at",
&app.state.repo,
BranchesAndTagsOptions {
exclude_head: false,
allow_custom_input: true,
default: {
let head = app.state.repo.head().map_err(Error::GetHead)?;
RefKind::from_reference(&head)
},
},
)?;

let Some(starting_point) = app.picker(term, start_point_picker)? else {
return Ok(());
};

let branch_name = app.prompt(
term,
&PromptParams {
prompt: "Create and checkout branch:",
prompt: "Create and checkout branch",
..Default::default()
},
)?;

checkout_new_branch_prompt_update(app, term, &branch_name)?;
checkout_new_branch_prompt_update(app, term, &branch_name, starting_point.display())?;
Ok(())
}))
}
Expand All @@ -71,9 +94,14 @@ impl OpTrait for CheckoutNewBranch {
}
}

fn checkout_new_branch_prompt_update(app: &mut App, term: &mut Term, branch_name: &str) -> Res<()> {
fn checkout_new_branch_prompt_update(
app: &mut App,
term: &mut Term,
branch_name: &str,
starting_point: &str,
) -> Res<()> {
let mut cmd = Command::new("git");
cmd.args(["checkout", "-b", branch_name]);
cmd.args(["checkout", "-b", branch_name, starting_point]);

app.close_menu();
app.run_cmd(term, &[], cmd)?;
Expand Down
51 changes: 11 additions & 40 deletions src/ops/merge.rs
Original file line number Diff line number Diff line change
@@ -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,
};

Expand Down Expand Up @@ -70,47 +68,20 @@ pub(crate) struct Merge;
impl OpTrait for Merge {
fn get_action(&self, target: &ItemData) -> Option<Action> {
// 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<RefKind> = 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<RefKind> = 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 {
Expand Down
59 changes: 59 additions & 0 deletions src/picker.rs
Original file line number Diff line number Diff line change
@@ -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 _;
Expand Down Expand Up @@ -65,6 +67,12 @@ pub enum PickerStatus {
Cancelled,
}

pub(crate) struct BranchesAndTagsOptions {
pub exclude_head: bool,
pub allow_custom_input: bool,
pub default: Option<RefKind>,
}

/// State of the picker component
pub struct PickerState {
/// All available items (excluding custom input)
Expand Down Expand Up @@ -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<Cow<'static, str>>,
repo: &Repository,
options: BranchesAndTagsOptions,
) -> Result<Self, Error> {
// 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<RefKind> = 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
Expand Down
27 changes: 26 additions & 1 deletion src/tests/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,34 @@ fn switch_branch_input() {
snapshot!(setup(setup_clone!()), "Ybbmerged<enter>");
}

#[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_starting_point_picker() {
snapshot!(setup(setup_clone!()), "bc");
}

#[test]
fn checkout_new_branch_starting_point_picker_from_selected_rev() {
snapshot!(setup(setup_clone!()), "Yjjbc");
}

#[test]
fn checkout_new_branch_name_prompt() {
snapshot!(setup(setup_clone!()), "bc<enter>");
}

#[test]
fn checkout_new_branch() {
snapshot!(setup(setup_clone!()), "bcnew<enter>");
snapshot!(setup(setup_clone!()), "bc<enter>new<enter>");
}

#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ expression: ctx.redact_buffer()
|
|
────────────────────────────────────────────────────────────────────────────────|
$ git checkout -b new |
$ git checkout -b new main |
Switched to a new branch 'new' |
styles_hash: 2afc72138214b087
styles_hash: 51d00da50a627344
Original file line number Diff line number Diff line change
@@ -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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
────────────────────────────────────────────────────────────────────────────────|
? Create and checkout branch: › █ |
styles_hash: 5475e2883a3b1774
Original file line number Diff line number Diff line change
@@ -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 |
|
|
|
────────────────────────────────────────────────────────────────────────────────|
5/5 Create branch starting at › █ |
▌main |
merged |
unmerged |
origin/HEAD |
origin/main |
|
|
|
|
|
styles_hash: 106946388e06558f
Loading