branch_picker: Add remote icons and fix edge cases (#61215)

# Objective

Make the branch picker easier to scan and ensure branch creation and
remote icons behave consistently across All, Local, and Remote filters.

## Solution

- Group local and remote branches under their own headers in the All
filter.
- Place the create-branch option above branch sections when there is no
exact branch-name match.
- Resolve each remote's hosting provider from its URL and show the
provider icon for branches from that remote.
- Include remote URLs in remote-workspace repository responses so
arbitrary remote names work without hardcoding `origin` or `upstream`.

## Edge Cases Fixed

- An exact branch can exist outside the active Local or Remote filter.
The picker now checks all branches before offering to create a duplicate
branch.
- A non-exact search can return fuzzy branch matches and a create
action. The create action now stays at the top, before the Local and
Remote section headers.
- When a filtered search has no branch matches, the create action is
shown without incorrectly placing it under a Local Branches or Remote
Branches header.
- Repositories can use any remote name, not only `origin` and
`upstream`. Provider icons are resolved independently for every
configured remote.
- Remotes hosted on an unknown or unsupported forge use the generic
server icon instead of implying a desktop or a specific provider.
- Remote-workspace repositories carry the same remote-name-to-URL data
as local repositories, so their branch icons follow the same
provider-resolution path.

## Testing

- `cargo check -p git_ui --no-default-features`
- Branch picker test suite (17 tests)
- `git diff --check`

Tested on macOS.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

## Showcase

<img width="560" height="522" alt="Screenshot 2026-07-17 at 11 19 27 PM"
src="https://github.com/user-attachments/assets/28d57315-b859-4a8d-86ca-53f9fb513bf5"
/>

The updated branch picker groups local and remote branches, keeps the
create-branch action above those sections, and shows forge-specific
icons for remote branches.

---

Release Notes:

- Improved branch picker filtering, grouping, branch creation
suggestions, and remote-provider icons.
This commit is contained in:
Sathwik Chirivelli authored and GitHub committed 2026-07-20 04:00:48 +00:00
1 parent ced39bbd9f
commit fdedc0c2b7
3 files changed
+372 -173

No files matched your search

+338 -173
View File
@@ -2,8 +2,9 @@ use anyhow::Context as _;
use editor::Editor;
use fuzzy_nucleo::StringMatchCandidate;
use collections::HashSet;
use collections::{HashMap, HashSet};
use git::repository::{Branch, delete_branch_flag};
use git::{GitHostingProviderRegistry, parse_git_remote_url};
use gpui::http_client::Url;
use gpui::{
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global,
@@ -277,6 +278,9 @@ impl BranchList {
let default_branch_request = repository.clone().map(|repository| {
repository.update(cx, |repository, _| repository.default_branch(false))
});
let remote_urls_request = repository
.clone()
.map(|repository| repository.update(cx, |repository, _| repository.remote_urls()));
let mut delegate = BranchListDelegate::new(
workspace,
@@ -289,7 +293,7 @@ impl BranchList {
delegate.branch_list_error = branch_list_error;
let picker = cx.new(|cx| {
Picker::uniform_list(delegate, window, cx)
Picker::list(delegate, window, cx)
.initial_width(width)
.show_scrollbar(true)
.when(embedded, |picker| picker.embedded())
@@ -350,6 +354,21 @@ impl BranchList {
})
.detach_and_log_err(cx);
cx.spawn(async move |this, cx| {
let remote_urls = remote_urls_request
.context("No active repository")?
.await??;
let remote_provider_icons = cx.update(|cx| remote_provider_icons(&remote_urls, cx));
this.update(cx, |this, cx| {
this.picker.update(cx, |picker, cx| {
picker.delegate.remote_provider_icons = remote_provider_icons;
cx.notify();
});
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
Self {
picker,
picker_focus_handle,
@@ -655,6 +674,7 @@ pub struct BranchListDelegate {
restore_selected_branch: Option<SharedString>,
show_footer: bool,
hovered_delete_index: Option<usize>,
remote_provider_icons: HashMap<String, IconName>,
}
enum BranchSelectionBehavior {
@@ -895,15 +915,25 @@ fn sort_branch_entries(
matches: &mut [Entry],
branch_selection_context: Option<&BranchSelectionContext>,
) {
let selected_branch_is_remote = branch_selection_context
.and_then(|context| context.selected_branch.as_ref())
.and_then(|selected_branch| {
matches.iter().find_map(|entry| {
let branch = entry.as_branch()?;
branch_matches_ref(branch, selected_branch).then(|| branch.is_remote())
})
})
.unwrap_or(false);
matches.sort_by_key(|entry| {
let Some(branch) = entry.as_branch() else {
return (4, false);
return (true, 4);
};
let priority = branch_selection_context
.map(|context| context.priority(branch))
.unwrap_or(0);
(priority, branch.is_remote())
(branch.is_remote() != selected_branch_is_remote, priority)
});
}
@@ -982,6 +1012,7 @@ impl BranchListDelegate {
restore_selected_branch,
show_footer: false,
hovered_delete_index: None,
remote_provider_icons: HashMap::default(),
}
}
@@ -1180,8 +1211,28 @@ impl BranchListDelegate {
}
}
fn remote_provider_icons(
remote_urls: &HashMap<String, String>,
cx: &App,
) -> HashMap<String, IconName> {
let Some(provider_registry) = GitHostingProviderRegistry::try_global(cx) else {
return HashMap::default();
};
remote_urls
.iter()
.filter_map(|(remote_name, remote_url)| {
let (provider, _) = parse_git_remote_url(provider_registry.clone(), remote_url)?;
Some((
remote_name.clone(),
crate::get_provider_icon(&provider.name()),
))
})
.collect()
}
impl PickerDelegate for BranchListDelegate {
type ListItem = ListItem;
type ListItem = AnyElement;
fn name() -> &'static str {
"branch picker"
@@ -1304,7 +1355,12 @@ impl PickerDelegate for BranchListDelegate {
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Option<AnyElement> {
if self.branch_filter == BranchFilter::All {
if self.branch_filter == BranchFilter::All
|| !self
.matches
.first()
.is_some_and(|entry| entry.as_branch().is_some())
{
return None;
}
@@ -1364,10 +1420,10 @@ impl PickerDelegate for BranchListDelegate {
let mut matches: Vec<Entry> = if query.is_empty() {
let mut matches: Vec<Entry> = all_branches
.into_iter()
.iter()
.filter(|branch| branch_matches_filter(branch))
.map(|branch| Entry::Branch {
branch,
branch: branch.clone(),
positions: Vec::new(),
})
.collect();
@@ -1426,7 +1482,7 @@ impl PickerDelegate for BranchListDelegate {
if !picker.delegate.is_select_only()
&& !query.is_empty()
&& !matches.first().is_some_and(|entry| entry.name() == query)
&& !all_branches.iter().any(|branch| branch.name() == query)
{
let query = normalize_branch_name(&query);
let is_url = query.trim_start_matches("git@").parse::<Url>().is_ok();
@@ -1618,7 +1674,11 @@ impl PickerDelegate for BranchListDelegate {
if is_checked_branch {
IconName::Check
} else if branch.is_remote() {
IconName::Screen
branch
.remote_name()
.and_then(|remote_name| self.remote_provider_icons.get(remote_name))
.copied()
.unwrap_or(IconName::Server)
} else {
IconName::GitBranch
}
@@ -1714,172 +1774,195 @@ impl PickerDelegate for BranchListDelegate {
.into_any_element()
});
Some(
ListItem::new(format!("vcs-menu-{ix}"))
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
h_flex()
.w_full()
.gap_2p5()
.flex_grow_1()
.child(
Icon::new(entry_icon)
.color(if is_checked_branch {
Color::Accent
} else {
Color::Muted
})
.size(IconSize::Small),
)
.child(
v_flex()
.id("info_container")
.w_full()
.child(entry_title)
.child({
let message = match entry {
Entry::NewUrl { url } => format!("Based off {url}"),
Entry::NewRemoteName { url, .. } => {
format!("Based off {url}")
let list_item = ListItem::new(format!("vcs-menu-{ix}"))
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
h_flex()
.w_full()
.gap_2p5()
.flex_grow_1()
.child(
Icon::new(entry_icon)
.color(if is_checked_branch {
Color::Accent
} else {
Color::Muted
})
.size(IconSize::Small),
)
.child(
v_flex()
.id("info_container")
.w_full()
.child(entry_title)
.child({
let message = match entry {
Entry::NewUrl { url } => format!("Based off {url}"),
Entry::NewRemoteName { 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::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 { .. } => String::new(),
}
Entry::Branch { .. } => String::new(),
};
if matches!(entry, Entry::Branch { .. }) {
let show_author_name = ProjectSettings::get_global(cx)
.git
.branch_picker
.show_author_name;
let has_author = show_author_name && author_name.is_some();
let has_commit = commit_time.is_some();
let author_for_meta =
if show_author_name { author_name } else { None };
let dot = || {
Label::new("•")
.alpha(0.5)
.color(Color::Muted)
.size(LabelSize::Small)
};
if matches!(entry, Entry::Branch { .. }) {
let show_author_name = ProjectSettings::get_global(cx)
.git
.branch_picker
.show_author_name;
let has_author = show_author_name && author_name.is_some();
let has_commit = commit_time.is_some();
let author_for_meta =
if show_author_name { author_name } else { None };
let dot = || {
Label::new("•")
.alpha(0.5)
.color(Color::Muted)
.size(LabelSize::Small)
};
h_flex()
.w_full()
.min_w_0()
.gap_1p5()
.when_some(author_for_meta, |this, author| {
this.child(
Label::new(author)
.color(Color::Muted)
.size(LabelSize::Small),
)
})
.when_some(commit_time, |this, time| {
this.when(has_author, |this| this.child(dot()))
.child(
Label::new(time)
.color(Color::Muted)
.size(LabelSize::Small),
)
})
.when_some(subject, |this, subj| {
this.when(has_commit, |this| this.child(dot()))
.child(
Label::new(subj.to_string())
.color(Color::Muted)
.size(LabelSize::Small)
.truncate()
.flex_1(),
)
})
.when(!has_commit, |this| {
this.child(
Label::new("No commits found")
.color(Color::Muted)
.size(LabelSize::Small),
)
})
.into_any_element()
} else {
Label::new(message)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate()
.into_any_element()
}
})
.when_some(
entry.as_branch().map(|b| b.name().to_string()),
|this, branch_name| {
let absolute_time = absolute_time.clone();
this.tooltip({
let is_head = is_head_branch;
let is_checked = is_checked_branch;
let is_select_only = self.is_select_only();
Tooltip::element(move |_, _| {
v_flex()
.child(Label::new(branch_name.clone()))
.when(is_select_only && is_checked, |this| {
this.child(
Label::new("Selected Branch")
.size(LabelSize::Small)
.color(Color::Muted),
)
})
.when(is_head, |this| {
this.child(
Label::new("Current Branch")
.size(LabelSize::Small)
.color(Color::Muted),
)
})
.when_some(
absolute_time.clone(),
|this, time| {
this.child(
Label::new(time)
.size(LabelSize::Small)
.color(Color::Muted),
)
},
)
.into_any_element()
})
h_flex()
.w_full()
.min_w_0()
.gap_1p5()
.when_some(author_for_meta, |this, author| {
this.child(
Label::new(author)
.color(Color::Muted)
.size(LabelSize::Small),
)
})
},
),
),
)
.when(
!self.is_select_only() && !is_new_items && !is_head_branch,
|this| {
this.end_slot(deleted_branch_icon(ix))
.show_end_slot_on_hover()
},
)
.when_some(
if is_new_items {
create_from_default_button
.when_some(commit_time, |this, time| {
this.when(has_author, |this| this.child(dot())).child(
Label::new(time)
.color(Color::Muted)
.size(LabelSize::Small),
)
})
.when_some(subject, |this, subj| {
this.when(has_commit, |this| this.child(dot())).child(
Label::new(subj.to_string())
.color(Color::Muted)
.size(LabelSize::Small)
.truncate()
.flex_1(),
)
})
.when(!has_commit, |this| {
this.child(
Label::new("No commits found")
.color(Color::Muted)
.size(LabelSize::Small),
)
})
.into_any_element()
} else {
Label::new(message)
.size(LabelSize::Small)
.color(Color::Muted)
.truncate()
.into_any_element()
}
})
.when_some(
entry.as_branch().map(|b| b.name().to_string()),
|this, branch_name| {
let absolute_time = absolute_time.clone();
this.tooltip({
let is_head = is_head_branch;
let is_checked = is_checked_branch;
let is_select_only = self.is_select_only();
Tooltip::element(move |_, _| {
v_flex()
.child(Label::new(branch_name.clone()))
.when(is_select_only && is_checked, |this| {
this.child(
Label::new("Selected Branch")
.size(LabelSize::Small)
.color(Color::Muted),
)
})
.when(is_head, |this| {
this.child(
Label::new("Current Branch")
.size(LabelSize::Small)
.color(Color::Muted),
)
})
.when_some(absolute_time.clone(), |this, time| {
this.child(
Label::new(time)
.size(LabelSize::Small)
.color(Color::Muted),
)
})
.into_any_element()
})
})
},
),
),
)
.when(
!self.is_select_only() && !is_new_items && !is_head_branch,
|this| {
this.end_slot(deleted_branch_icon(ix))
.show_end_slot_on_hover()
},
)
.when_some(
if is_new_items {
create_from_default_button
} else {
None
},
|this, create_from_default_button| {
this.end_slot(create_from_default_button)
.show_end_slot_on_hover()
},
);
let section_header = (self.branch_filter == BranchFilter::All)
.then(|| entry.as_branch())
.flatten()
.and_then(|branch| {
let starts_section = ix == 0
|| self.matches[ix - 1]
.as_branch()
.is_none_or(|previous_branch| {
previous_branch.is_remote() != branch.is_remote()
});
starts_section.then(|| {
if branch.is_remote() {
"Remote Branches"
} else {
None
},
|this, create_from_default_button| {
this.end_slot(create_from_default_button)
.show_end_slot_on_hover()
},
),
"Local Branches"
}
})
});
Some(
v_flex()
.w_full()
.when_some(section_header, |this, section_header| {
this.pt_1p5()
.child(ListSubHeader::new(section_header).inset(true))
})
.child(list_item)
.into_any_element(),
)
}
@@ -2140,6 +2223,7 @@ mod tests {
),
create_test_branch("main", false, Some("origin"), Some(1000)),
create_test_branch("feature", false, Some("origin"), Some(900)),
create_test_branch("topic", false, Some("origin"), Some(850)),
create_test_branch("main", false, Some("fork"), Some(800)),
]);
@@ -2183,11 +2267,24 @@ mod tests {
let ordered_branch_names = entries.iter().map(Entry::name).collect::<Vec<_>>();
assert_eq!(ordered_branch_names.first(), Some(&"origin/main"));
assert!(
ordered_branch_names.iter().position(|name| *name == "main")
entries
.windows(2)
.filter(|entries| {
entries[0].as_branch().map(Branch::is_remote)
!= entries[1].as_branch().map(Branch::is_remote)
})
.count()
<= 1,
"local and remote branches should each form a contiguous section"
);
assert!(
ordered_branch_names
.iter()
.position(|name| *name == "origin/topic")
< ordered_branch_names
.iter()
.position(|name| *name == "fork/main"),
"branches on the active branch's remote should be prioritized"
"branches on the active branch's remote should be prioritized within their section"
);
}
@@ -2317,6 +2414,74 @@ mod tests {
assert!(last_match.is_new_branch());
})
});
for branch_filter in [BranchFilter::Local, BranchFilter::Remote] {
branch_list
.update_in(cx, |branch_list, window, cx| {
branch_list.picker.update(cx, |picker, cx| {
picker.delegate.branch_filter = branch_filter;
picker
.delegate
.update_matches(String::from("missing-branch"), window, cx)
})
})
.await;
cx.run_until_parked();
branch_list.update_in(cx, |branch_list, window, cx| {
branch_list.picker.update(cx, |picker, cx| {
assert!(
picker.delegate.render_header(window, cx).is_none(),
"create-only results should not be shown under a branch section header"
);
assert!(matches!(
picker.delegate.matches.as_slice(),
[Entry::NewBranch { .. }]
));
})
});
}
branch_list
.update_in(cx, |branch_list, window, cx| {
branch_list.picker.update(cx, |picker, cx| {
picker.delegate.branch_filter = BranchFilter::Remote;
picker
.delegate
.update_matches(String::from("feature-ui"), window, cx)
})
})
.await;
cx.run_until_parked();
branch_list.update(cx, |branch_list, cx| {
branch_list.picker.update(cx, |picker, _cx| {
assert!(
picker.delegate.matches.is_empty(),
"an exact branch hidden by the active filter should not be offered for creation"
);
})
});
branch_list
.update_in(cx, |branch_list, window, cx| {
branch_list.picker.update(cx, |picker, cx| {
picker
.delegate
.update_matches(String::from("feature-u"), window, cx)
})
})
.await;
cx.run_until_parked();
branch_list.update(cx, |branch_list, cx| {
branch_list.picker.update(cx, |picker, _cx| {
assert!(matches!(
picker.delegate.matches.as_slice(),
[Entry::NewBranch { .. }]
));
})
});
}
async fn update_branch_list_matches_with_empty_query(
+33
View File
@@ -3150,12 +3150,18 @@ impl GitStore {
repository_handle.get_remotes(branch_name, is_push)
})
.await??;
let remote_urls = repository_handle
.update(&mut cx, |repository_handle, _| {
repository_handle.remote_urls()
})
.await??;
Ok(proto::GetRemotesResponse {
remotes: remotes
.into_iter()
.map(|remotes| proto::get_remotes_response::Remote {
name: remotes.name.to_string(),
url: remote_urls.get(remotes.name.as_ref()).cloned(),
})
.collect::<Vec<_>>(),
})
@@ -7949,6 +7955,33 @@ impl Repository {
})
}
pub fn remote_urls(&mut self) -> oneshot::Receiver<Result<HashMap<String, String>>> {
let id = self.id;
self.send_job("remote_urls", None, move |repo, _cx| async move {
match repo {
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
Ok(backend.remote_urls().await)
}
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
let response = client
.request(proto::GetRemotes {
project_id: project_id.0,
repository_id: id.to_proto(),
branch_name: None,
is_push: false,
})
.await?;
Ok(response
.remotes
.into_iter()
.filter_map(|remote| Some((remote.name, remote.url?)))
.collect())
}
}
})
}
pub fn branches(&mut self) -> oneshot::Receiver<Result<BranchesScanResult>> {
let id = self.id;
self.send_job("branches", None, move |repo, _| async move {
+1
View File
@@ -459,6 +459,7 @@ message GetRemotesResponse {
message Remote {
string name = 1;
optional string url = 2;
}
}