git_ui: Improve the branch picker UI (#44217)

Follow up to https://github.com/zed-industries/zed/pull/42819 and
https://github.com/zed-industries/zed/pull/44206.

- Make this picker feel more consistent with other similar pickers
(namely, the project picker)
- Move actions to the footer and toggle them conditionally
- Only show the "Create" and "Create New From: {default}" when we're
selecting the "Create" list item _or_ when that item is the only
visible. This means I'm changing here the state transition to only
change to `NewBranch/NewRemote` if we only have those items available.
- Reuse more UI code and use components when available (e.g.,
`ListHeader`)
- Remove secondary actions from the list item

Next step (in another PR), will be refine the same picker in the
smaller, panel version.


https://github.com/user-attachments/assets/fe72ac06-c1df-4829-a8a4-df8a9222672f

Release Notes:

- N/A
This commit is contained in:
Danilo Leal
2025-12-05 17:17:50 -03:00
committed by GitHub
parent f9cea5af29
commit bd6ca841ad
+258 -214
View File
@@ -17,8 +17,8 @@ use settings::Settings;
use std::sync::Arc;
use time::OffsetDateTime;
use ui::{
CommonAnimationExt, Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip,
prelude::*,
CommonAnimationExt, Divider, HighlightedLabel, KeyBinding, ListHeader, ListItem,
ListItemSpacing, Tooltip, prelude::*,
};
use util::ResultExt;
use workspace::notifications::DetachAndPromptErr;
@@ -440,13 +440,6 @@ impl BranchListDelegate {
cx.emit(DismissEvent);
}
fn loader(&self) -> AnyElement {
Icon::new(IconName::LoadCircle)
.size(IconSize::Small)
.with_rotate_animation(3)
.into_any_element()
}
fn delete_at(&self, idx: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(entry) = self.matches.get(idx).cloned() else {
return;
@@ -683,10 +676,16 @@ impl PickerDelegate for BranchListDelegate {
} else {
Entry::NewBranch { name: query }
};
picker.delegate.state = if is_url {
PickerState::NewRemote
// Only transition to NewBranch/NewRemote states when we only show their list item
// Otherwise, stay in List state so footer buttons remain visible
picker.delegate.state = if matches.is_empty() {
if is_url {
PickerState::NewRemote
} else {
PickerState::NewBranch
}
} else {
PickerState::NewBranch
PickerState::List
};
matches.push(entry);
} else {
@@ -812,67 +811,35 @@ impl PickerDelegate for BranchListDelegate {
})
.unwrap_or_else(|| (None, None, None));
let icon = if let Some(default_branch) = self.default_branch.clone()
&& matches!(entry, Entry::NewBranch { .. })
{
let tooltip_text = format!("Create branch based off default: {default_branch}");
let entry_icon = match entry {
Entry::NewUrl { .. } | Entry::NewBranch { .. } => {
Icon::new(IconName::Plus).color(Color::Muted)
}
Some(
IconButton::new("branch-from-default", IconName::GitBranchAlt)
.on_click(cx.listener(move |this, _, window, cx| {
this.delegate.set_selected_index(ix, window, cx);
this.delegate.confirm(true, window, cx);
}))
.tooltip(move |_window, cx| {
Tooltip::for_action(tooltip_text.clone(), &menu::SecondaryConfirm, cx)
}),
)
} else {
None
Entry::Branch { .. } => {
if self.display_remotes {
Icon::new(IconName::Screen).color(Color::Muted)
} else {
Icon::new(IconName::GitBranchAlt).color(Color::Muted)
}
}
};
let icon_element = if self.display_remotes {
Icon::new(IconName::Screen)
} else {
Icon::new(IconName::GitBranchAlt)
};
let entry_name = match entry {
Entry::NewUrl { .. } => h_flex()
.gap_1()
.child(
Icon::new(IconName::Plus)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
Label::new("Create remote repository".to_string())
.single_line()
.truncate(),
)
let entry_title = match entry {
Entry::NewUrl { .. } => Label::new("Create Remote Repository")
.single_line()
.truncate()
.into_any_element(),
Entry::NewBranch { name } => h_flex()
.gap_1()
.child(
Icon::new(IconName::Plus)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
Label::new(format!("Create branch \"{name}\""))
.single_line()
.truncate(),
)
.into_any_element(),
Entry::Branch { branch, positions } => h_flex()
.max_w_48()
.child(h_flex().mr_1().child(icon_element))
.child(
HighlightedLabel::new(branch.name().to_string(), positions.clone())
.single_line()
.truncate(),
)
Entry::NewBranch { name } => Label::new(format!("Create Branch: \"{name}\""))
.single_line()
.truncate()
.into_any_element(),
Entry::Branch { branch, positions } => {
HighlightedLabel::new(branch.name().to_string(), positions.clone())
.single_line()
.truncate()
.into_any_element()
}
};
Some(
@@ -880,82 +847,96 @@ impl PickerDelegate for BranchListDelegate {
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.tooltip({
match entry {
Entry::Branch { branch, .. } => Tooltip::text(branch.name().to_string()),
Entry::NewUrl { .. } => {
Tooltip::text("Create remote repository".to_string())
}
Entry::NewBranch { name } => {
Tooltip::text(format!("Create branch \"{name}\""))
}
}
})
.child(
v_flex()
h_flex()
.w_full()
.overflow_hidden()
.gap_3()
.flex_grow()
.child(entry_icon)
.child(
h_flex()
.gap_6()
.justify_between()
.overflow_x_hidden()
.child(entry_name)
.when_some(commit_time, |label, commit_time| {
label.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({
let message = match entry {
Entry::NewUrl { url } => format!("based off {url}"),
Entry::NewBranch { .. } => {
if let Some(current_branch) =
self.repo.as_ref().and_then(|repo| {
repo.read(cx).branch.as_ref().map(|b| b.name())
})
{
format!("based off {}", current_branch)
} else {
"based off the current branch".to_string()
}
}
Entry::Branch { .. } => {
let show_author_name = ProjectSettings::get_global(cx)
.git
.branch_picker
.show_author_name;
v_flex()
.id("info_container")
.w_full()
.child(entry_title)
.child(
h_flex()
.w_full()
.justify_between()
.gap_1p5()
.when(self.style == BranchListStyle::Modal, |el| {
el.child(div().max_w_96().child({
let message = match entry {
Entry::NewUrl { url } => {
format!("Based off {url}")
}
Entry::NewBranch { .. } => {
if let Some(current_branch) =
self.repo.as_ref().and_then(|repo| {
repo.read(cx)
.branch
.as_ref()
.map(|b| b.name())
})
{
format!("Based off {}", current_branch)
} else {
"Based off the current branch"
.to_string()
}
}
Entry::Branch { .. } => {
let show_author_name =
ProjectSettings::get_global(cx)
.git
.branch_picker
.show_author_name;
subject.map_or("no commits found".into(), |subject| {
if show_author_name && author_name.is_some() {
format!("{}{}", author_name.unwrap(), subject)
} else {
subject.to_string()
}
subject.map_or(
"No commits found".into(),
|subject| {
if show_author_name
&& author_name.is_some()
{
format!(
"{}{}",
author_name.unwrap(),
subject
)
} else {
subject.to_string()
}
},
)
}
};
Label::new(message)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate()
}))
})
}
};
Label::new(message)
.size(LabelSize::Small)
.truncate()
.color(Color::Muted)
}))
}),
)
.end_slot::<IconButton>(icon),
.when_some(commit_time, |label, commit_time| {
label.child(
Label::new(commit_time)
.size(LabelSize::Small)
.color(Color::Muted),
)
}),
)
.when_some(
entry.as_branch().map(|b| b.name().to_string()),
|this, branch_name| this.tooltip(Tooltip::text(branch_name)),
),
),
),
)
}
fn render_header(
&self,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
_cx: &mut Context<Picker<Self>>,
) -> Option<AnyElement> {
matches!(self.state, PickerState::List).then(|| {
let label = if self.display_remotes {
@@ -964,83 +945,54 @@ impl PickerDelegate for BranchListDelegate {
"Local"
};
h_flex()
.w_full()
.p_1p5()
.gap_1()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(Label::new(label).size(LabelSize::Small).color(Color::Muted))
.into_any()
ListHeader::new(label).inset(true).into_any_element()
})
}
fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
let focus_handle = self.focus_handle.clone();
let loading_icon = Icon::new(IconName::LoadCircle)
.size(IconSize::Small)
.with_rotate_animation(3);
let footer_container = || {
h_flex()
.w_full()
.p_1p5()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
};
if self.loading {
return Some(
h_flex()
.w_full()
.p_1p5()
.gap_1()
.justify_end()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(self.loader())
.into_any(),
);
}
match self.state {
PickerState::List => Some(
h_flex()
.w_full()
.p_1p5()
.gap_0p5()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.justify_between()
.child({
let focus_handle = focus_handle.clone();
Button::new("filter-remotes", "Filter remotes")
PickerState::List => {
let selected_entry = self.matches.get(self.selected_index);
let branch_from_default_button = self
.default_branch
.as_ref()
.filter(|_| matches!(selected_entry, Some(Entry::NewBranch { .. })))
.map(|default_branch| {
let button_label = format!("Create New From: {default_branch}");
Button::new("branch-from-default", button_label)
.key_binding(
KeyBinding::for_action_in(
&branch_picker::FilterRemotes,
&menu::SecondaryConfirm,
&focus_handle,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_click, window, cx| {
window.dispatch_action(
branch_picker::FilterRemotes.boxed_clone(),
cx,
);
})
.disabled(self.loading)
.style(ButtonStyle::Subtle)
.toggle_state(self.display_remotes)
.tooltip({
let state = self.display_remotes;
.on_click(cx.listener(|this, _, window, cx| {
this.delegate.confirm(true, window, cx);
}))
});
move |_window, cx| {
let tooltip_text = if state {
"Show local branches"
} else {
"Show remote branches"
};
Tooltip::for_action_in(
tooltip_text,
&branch_picker::FilterRemotes,
&focus_handle,
cx,
)
}
})
})
let delete_and_select_btns = h_flex()
.gap_0p5()
.child(
Button::new("delete-branch", "Delete")
.disabled(self.loading)
.key_binding(
KeyBinding::for_action_in(
&branch_picker::DeleteBranch,
@@ -1049,43 +1001,134 @@ impl PickerDelegate for BranchListDelegate {
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.disabled(self.loading)
.on_click(|_, window, cx| {
window
.dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx);
}),
)
.when(self.loading, |this| this.child(self.loader()))
.into_any(),
),
.child(
Button::new("select_branch", "Select")
.key_binding(
KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(|this, _, window, cx| {
this.delegate.confirm(false, window, cx);
})),
);
Some(
footer_container()
.map(|this| {
if branch_from_default_button.is_some() {
this.justify_end().when_some(
branch_from_default_button,
|this, button| {
this.child(button).child(
Button::new("create", "Create")
.key_binding(
KeyBinding::for_action_in(
&menu::Confirm,
&focus_handle,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(|this, _, window, cx| {
this.delegate.confirm(false, window, cx);
})),
)
},
)
} else if self.loading {
this.justify_between()
.child(loading_icon)
.child(delete_and_select_btns)
} else {
this.justify_between()
.child({
let focus_handle = focus_handle.clone();
Button::new("filter-remotes", "Filter Remotes")
.disabled(self.loading)
.toggle_state(self.display_remotes)
.key_binding(
KeyBinding::for_action_in(
&branch_picker::FilterRemotes,
&focus_handle,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_click, window, cx| {
window.dispatch_action(
branch_picker::FilterRemotes.boxed_clone(),
cx,
);
})
})
.child(delete_and_select_btns)
}
})
.into_any_element(),
)
}
PickerState::NewBranch => {
let branch_from_default_button =
self.default_branch.as_ref().map(|default_branch| {
let button_label = format!("Create New From: {default_branch}");
Button::new("branch-from-default", button_label)
.key_binding(
KeyBinding::for_action_in(
&menu::SecondaryConfirm,
&focus_handle,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(|this, _, window, cx| {
this.delegate.confirm(true, window, cx);
}))
});
Some(
footer_container()
.gap_0p5()
.justify_end()
.when_some(branch_from_default_button, |this, button| {
this.child(button)
})
.child(
Button::new("branch-from-default", "Create")
.key_binding(
KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(|this, _, window, cx| {
this.delegate.confirm(false, window, cx);
})),
)
.into_any_element(),
)
}
PickerState::CreateRemote(_) => Some(
h_flex()
.w_full()
.p_1p5()
.gap_1()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
footer_container()
.justify_end()
.child(
Label::new("Choose a name for this remote repository")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
h_flex().w_full().justify_end().child(
Label::new("Save")
.size(LabelSize::Small)
.color(Color::Muted),
),
Label::new("Save")
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any(),
.into_any_element(),
),
PickerState::NewRemote | PickerState::NewBranch => None,
PickerState::NewRemote => None,
}
}
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
None
}
}
#[cfg(test)]
@@ -1515,6 +1558,7 @@ mod tests {
let last_match = picker.delegate.matches.last().unwrap();
assert!(last_match.is_new_branch());
assert_eq!(last_match.name(), "new-feature-branch");
// State is NewBranch because no existing branches fuzzy-match the query
assert!(matches!(picker.delegate.state, PickerState::NewBranch));
picker.delegate.confirm(false, window, cx);
})