git_ui: Show more information in the branch picker (#25359)

Final product:

![CleanShot 2025-02-26 at 9  08
17@2x](https://github.com/user-attachments/assets/e5db1932-b2c6-4b32-ab67-ef0a0d19f022)

Release Notes:

- Added more information about Git branches to the branch picker.

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Marshall Bowers <git@maxdeviant.com>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Angelk90 2025-03-11 04:05:29 +01:00 committed by GitHub
parent 94e4aa626d
commit d562f58e76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 120 additions and 89 deletions

1
Cargo.lock generated
View file

@ -5477,6 +5477,7 @@ dependencies = [
"telemetry", "telemetry",
"theme", "theme",
"time", "time",
"time_format",
"ui", "ui",
"unindent", "unindent",
"util", "util",

View file

@ -1,4 +1,5 @@
use crate::status::FileStatus; use crate::status::FileStatus;
use crate::SHORT_SHA_LENGTH;
use crate::{blame::Blame, status::GitStatus}; use crate::{blame::Blame, status::GitStatus};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use askpass::{AskPassResult, AskPassSession}; use askpass::{AskPassResult, AskPassSession};
@ -127,6 +128,12 @@ pub struct CommitDetails {
pub committer_name: SharedString, pub committer_name: SharedString,
} }
impl CommitDetails {
pub fn short_sha(&self) -> SharedString {
self.sha[..SHORT_SHA_LENGTH].to_string().into()
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)] #[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct Remote { pub struct Remote {
pub name: SharedString, pub name: SharedString,

View file

@ -50,6 +50,7 @@ strum.workspace = true
telemetry.workspace = true telemetry.workspace = true
theme.workspace = true theme.workspace = true
time.workspace = true time.workspace = true
time_format.workspace = true
ui.workspace = true ui.workspace = true
util.workspace = true util.workspace = true
workspace.workspace = true workspace.workspace = true

View file

@ -1,5 +1,5 @@
use anyhow::{anyhow, Context as _}; use anyhow::{anyhow, Context as _};
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::StringMatchCandidate;
use git::repository::Branch; use git::repository::Branch;
use gpui::{ use gpui::{
@ -10,6 +10,8 @@ use gpui::{
use picker::{Picker, PickerDelegate}; use picker::{Picker, PickerDelegate};
use project::git::Repository; use project::git::Repository;
use std::sync::Arc; use std::sync::Arc;
use time::OffsetDateTime;
use time_format::format_local_timestamp;
use ui::{ use ui::{
prelude::*, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip, prelude::*, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip,
}; };
@ -63,7 +65,7 @@ pub fn popover(
cx: &mut App, cx: &mut App,
) -> Entity<BranchList> { ) -> Entity<BranchList> {
cx.new(|cx| { cx.new(|cx| {
let list = BranchList::new(repository, BranchListStyle::Popover, rems(15.), window, cx); let list = BranchList::new(repository, BranchListStyle::Popover, rems(20.), window, cx);
list.focus_handle(cx).focus(window); list.focus_handle(cx).focus(window);
list list
}) })
@ -96,10 +98,17 @@ impl BranchList {
.map(|repository| repository.read(cx).branches()); .map(|repository| repository.read(cx).branches());
cx.spawn_in(window, |this, mut cx| async move { cx.spawn_in(window, |this, mut cx| async move {
let all_branches = all_branches_request let mut all_branches = all_branches_request
.context("No active repository")? .context("No active repository")?
.await??; .await??;
all_branches.sort_by_key(|branch| {
branch
.most_recent_commit
.as_ref()
.map(|commit| 0 - commit.commit_timestamp)
});
this.update_in(&mut cx, |this, window, cx| { this.update_in(&mut cx, |this, window, cx| {
this.picker.update(cx, |picker, cx| { this.picker.update(cx, |picker, cx| {
picker.delegate.all_branches = Some(all_branches); picker.delegate.all_branches = Some(all_branches);
@ -111,7 +120,7 @@ impl BranchList {
}) })
.detach_and_log_err(cx); .detach_and_log_err(cx);
let delegate = BranchListDelegate::new(repository.clone(), style, (1.6 * width.0) as usize); let delegate = BranchListDelegate::new(repository.clone(), style);
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx)); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
let _subscription = cx.subscribe(&picker, |_, _, _, cx| { let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
@ -162,18 +171,9 @@ impl Render for BranchList {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum BranchEntry { struct BranchEntry {
Branch(StringMatch), branch: Branch,
History(String), positions: Vec<usize>,
}
impl BranchEntry {
fn name(&self) -> &str {
match self {
Self::Branch(branch) => &branch.string,
Self::History(branch) => &branch,
}
}
} }
pub struct BranchListDelegate { pub struct BranchListDelegate {
@ -183,17 +183,11 @@ pub struct BranchListDelegate {
style: BranchListStyle, style: BranchListStyle,
selected_index: usize, selected_index: usize,
last_query: String, last_query: String,
/// Max length of branch name before we truncate it and add a trailing `...`.
branch_name_trailoff_after: usize,
modifiers: Modifiers, modifiers: Modifiers,
} }
impl BranchListDelegate { impl BranchListDelegate {
fn new( fn new(repo: Option<Entity<Repository>>, style: BranchListStyle) -> Self {
repo: Option<Entity<Repository>>,
style: BranchListStyle,
branch_name_trailoff_after: usize,
) -> Self {
Self { Self {
matches: vec![], matches: vec![],
repo, repo,
@ -201,16 +195,12 @@ impl BranchListDelegate {
all_branches: None, all_branches: None,
selected_index: 0, selected_index: 0,
last_query: Default::default(), last_query: Default::default(),
branch_name_trailoff_after,
modifiers: Default::default(), modifiers: Default::default(),
} }
} }
fn has_exact_match(&self, target: &str) -> bool { fn has_exact_match(&self, target: &str) -> bool {
self.matches.iter().any(|mat| match mat { self.matches.iter().any(|entry| entry.branch.name == target)
BranchEntry::Branch(branch) => branch.string == target,
_ => false,
})
} }
fn create_branch( fn create_branch(
@ -266,37 +256,27 @@ impl PickerDelegate for BranchListDelegate {
window: &mut Window, window: &mut Window,
cx: &mut Context<Picker<Self>>, cx: &mut Context<Picker<Self>>,
) -> Task<()> { ) -> Task<()> {
let Some(mut all_branches) = self.all_branches.clone() else { let Some(all_branches) = self.all_branches.clone() else {
return Task::ready(()); return Task::ready(());
}; };
const RECENT_BRANCHES_COUNT: usize = 10;
cx.spawn_in(window, move |picker, mut cx| async move { cx.spawn_in(window, move |picker, mut cx| async move {
const RECENT_BRANCHES_COUNT: usize = 10; let matches = if query.is_empty() {
if query.is_empty() { all_branches
if all_branches.len() > RECENT_BRANCHES_COUNT {
// Truncate list of recent branches
// Do a partial sort to show recent-ish branches first.
all_branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
rhs.priority_key().cmp(&lhs.priority_key())
});
all_branches.truncate(RECENT_BRANCHES_COUNT);
}
all_branches.sort_unstable_by(|lhs, rhs| {
rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
});
}
let candidates = all_branches
.into_iter()
.enumerate()
.map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
.collect::<Vec<StringMatchCandidate>>();
let matches: Vec<BranchEntry> = if query.is_empty() {
candidates
.into_iter() .into_iter()
.map(|candidate| BranchEntry::History(candidate.string)) .take(RECENT_BRANCHES_COUNT)
.map(|branch| BranchEntry {
branch,
positions: Vec::new(),
})
.collect() .collect()
} else { } else {
let candidates = all_branches
.iter()
.enumerate()
.map(|(ix, command)| StringMatchCandidate::new(ix, &command.name.clone()))
.collect::<Vec<StringMatchCandidate>>();
fuzzy::match_strings( fuzzy::match_strings(
&candidates, &candidates,
&query, &query,
@ -308,7 +288,10 @@ impl PickerDelegate for BranchListDelegate {
.await .await
.iter() .iter()
.cloned() .cloned()
.map(BranchEntry::Branch) .map(|candidate| BranchEntry {
branch: all_branches[candidate.candidate_id].clone(),
positions: candidate.positions,
})
.collect() .collect()
}; };
picker picker
@ -337,7 +320,7 @@ impl PickerDelegate for BranchListDelegate {
return; return;
} }
let Some(branch) = self.matches.get(self.selected_index()) else { let Some(entry) = self.matches.get(self.selected_index()) else {
return; return;
}; };
@ -349,14 +332,14 @@ impl PickerDelegate for BranchListDelegate {
if current_branch if current_branch
.flatten() .flatten()
.is_some_and(|current_branch| current_branch == branch.name()) .is_some_and(|current_branch| current_branch == entry.branch.name)
{ {
cx.emit(DismissEvent); cx.emit(DismissEvent);
return; return;
} }
cx.spawn_in(window, { cx.spawn_in(window, {
let branch = branch.clone(); let branch = entry.branch.clone();
|picker, mut cx| async move { |picker, mut cx| async move {
let branch_change_task = picker.update(&mut cx, |this, cx| { let branch_change_task = picker.update(&mut cx, |this, cx| {
let repo = this let repo = this
@ -369,16 +352,8 @@ impl PickerDelegate for BranchListDelegate {
let cx = cx.to_async(); let cx = cx.to_async();
anyhow::Ok(async move { anyhow::Ok(async move {
match branch { cx.update(|cx| repo.read(cx).change_branch(&branch.name))?
BranchEntry::Branch(StringMatch { .await?
string: branch_name,
..
})
| BranchEntry::History(branch_name) => {
cx.update(|cx| repo.read(cx).change_branch(&branch_name))?
.await?
}
}
}) })
})??; })??;
@ -398,6 +373,10 @@ impl PickerDelegate for BranchListDelegate {
cx.emit(DismissEvent); cx.emit(DismissEvent);
} }
fn render_header(&self, _: &mut Window, _cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
None
}
fn render_match( fn render_match(
&self, &self,
ix: usize, ix: usize,
@ -405,9 +384,29 @@ impl PickerDelegate for BranchListDelegate {
_window: &mut Window, _window: &mut Window,
_cx: &mut Context<Picker<Self>>, _cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> { ) -> Option<Self::ListItem> {
let hit = &self.matches[ix]; let entry = &self.matches[ix];
let shortened_branch_name =
util::truncate_and_trailoff(&hit.name(), self.branch_name_trailoff_after); let (commit_time, commit_message) = entry
.branch
.most_recent_commit
.as_ref()
.map(|commit| {
let commit_message = commit.subject.clone();
let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
.unwrap_or_else(|_| OffsetDateTime::now_utc());
let formatted_time = format_local_timestamp(
commit_time,
OffsetDateTime::now_utc(),
time_format::TimestampFormat::Relative,
);
(formatted_time, commit_message)
})
.unwrap_or_else(|| {
(
"Unknown Date".to_string(),
SharedString::from("No commit message available"),
)
});
Some( Some(
ListItem::new(SharedString::from(format!("vcs-menu-{ix}"))) ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
@ -418,26 +417,49 @@ impl PickerDelegate for BranchListDelegate {
}) })
.spacing(ListItemSpacing::Sparse) .spacing(ListItemSpacing::Sparse)
.toggle_state(selected) .toggle_state(selected)
.when(matches!(hit, BranchEntry::History(_)), |el| { .child(
el.end_slot( v_flex()
Icon::new(IconName::HistoryRerun) .w_full()
.color(Color::Muted) .child(
.size(IconSize::Small), h_flex()
) .w_full()
}) .flex_shrink()
.map(|el| match hit { .overflow_x_hidden()
BranchEntry::Branch(branch) => { .gap_2()
let highlights: Vec<_> = branch .justify_between()
.positions .child(
.iter() div().flex_shrink().overflow_x_hidden().child(
.filter(|index| index < &&self.branch_name_trailoff_after) HighlightedLabel::new(
.copied() entry.branch.name.clone(),
.collect(); entry.positions.clone(),
)
el.child(HighlightedLabel::new(shortened_branch_name, highlights)) .truncate(),
} ),
BranchEntry::History(_) => el.child(Label::new(shortened_branch_name)), )
}), .child(
Label::new(commit_time)
.size(LabelSize::Small)
.color(Color::Muted)
.into_element(),
),
)
.when(self.style == BranchListStyle::Modal, |el| {
el.child(
div().max_w_96().child(
Label::new(
commit_message
.split('\n')
.next()
.unwrap_or_default()
.to_string(),
)
.size(LabelSize::Small)
.truncate()
.color(Color::Muted),
),
)
}),
),
) )
} }