git_ui: Add branch picker filter menu (#60857)

# Objective

Make branch filtering consistent and more flexible across the title bar
and Git panel branch pickers.

## Solution

- Replace the remote-only filter toggle with a dropdown for all, local,
and remote branches.
- Preserve the selected filter across branch picker instances.
- Add a ListFilter icon and active-filter indicator.
- Prevent nested filter-menu interactions from dismissing the parent
picker.
- Cycle through all, local, and remote filters with the existing
keyboard shortcut.

## Testing

- Ran `cargo check -p git_ui --tests --no-default-features`.
- Verified formatting with `cargo fmt -p git_ui`.
- Manually checked the filter dropdown from the title bar and Git panel.

## 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

- Filters: All, Local, Remote
- "Cmd + Shift + I" to cycle over the filters

<details>
  <summary>New Branch Picker Filter Dropdown</summary>
  
<img width="1624" height="1030" alt="Screenshot 2026-07-12 at 3 53
30 PM"
src="https://github.com/user-attachments/assets/1323d9c9-f4b9-41ee-ba93-841299872c02"
/>

<img width="1624" height="1030" alt="Screenshot 2026-07-12 at 3 54
35 PM"
src="https://github.com/user-attachments/assets/839345ff-de3b-48f0-b4e7-0f8d27e94204"
/>

</details>


Release Notes:

- Improved branch filtering with local, remote, and all-branch options
in the branch picker.
This commit is contained in:
Sathwik Chirivelli authored and GitHub committed 2026-07-12 21:29:44 +00:00
1 parent 1b15ec62e0
commit 86a73f0035
5 files changed
+325 -91

No files matched your search

+1 -1
View File
@@ -1516,7 +1516,7 @@
"bindings": {
"ctrl-shift-backspace": "branch_picker::DeleteBranch",
"ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
"ctrl-shift-i": "branch_picker::FilterRemotes",
"ctrl-shift-i": "branch_picker::CycleBranchFilter",
},
},
{
+1 -1
View File
@@ -1569,7 +1569,7 @@
"bindings": {
"cmd-shift-backspace": "branch_picker::DeleteBranch",
"cmd-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
"cmd-shift-i": "branch_picker::FilterRemotes",
"cmd-shift-i": "branch_picker::CycleBranchFilter",
},
},
{
+1 -1
View File
@@ -1494,7 +1494,7 @@
"bindings": {
"ctrl-shift-backspace": "branch_picker::DeleteBranch",
"ctrl-alt-shift-backspace": "branch_picker::ForceDeleteBranch",
"ctrl-shift-i": "branch_picker::FilterRemotes",
"ctrl-shift-i": "branch_picker::CycleBranchFilter",
},
},
{
+263 -82
View File
@@ -6,7 +6,7 @@ use collections::HashSet;
use git::repository::{Branch, delete_branch_flag};
use gpui::http_client::Url;
use gpui::{
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global,
InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PromptLevel,
Render, SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems,
};
@@ -14,11 +14,12 @@ use picker::{Picker, PickerDelegate, PickerEditorPosition};
use project::git_store::{Repository, RepositoryEvent};
use project::project_settings::ProjectSettings;
use settings::Settings;
use std::rc::Rc;
use std::sync::Arc;
use time::OffsetDateTime;
use ui::{
Banner, Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Severity, Tooltip,
prelude::*,
Banner, ContextMenu, ContextMenuEntry, Divider, HighlightedLabel, Indicator, KeyBinding,
ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, Severity, Tooltip, prelude::*,
};
use ui_input::ErasedEditor;
use util::ResultExt;
@@ -34,8 +35,14 @@ actions!(
DeleteBranch,
/// Force deletes the selected git branch or remote.
ForceDeleteBranch,
/// Filter the list of remotes
FilterRemotes
/// Show all branches.
ShowAllBranches,
/// Show only local branches.
ShowLocalBranches,
/// Show only remote branches.
ShowRemoteBranches,
/// Cycle through branch filters.
CycleBranchFilter
]
);
@@ -181,8 +188,10 @@ impl BranchList {
) -> Self {
let mut this = Self::new_inner(workspace, repository, style, width, false, window, cx);
this._subscriptions
.push(cx.subscribe(&this.picker, |_, _, _, cx| {
cx.emit(DismissEvent);
.push(cx.subscribe(&this.picker, |this, _, _, cx| {
if !this.branch_filter_menu_open(cx) {
cx.emit(DismissEvent);
}
}));
this
}
@@ -232,8 +241,10 @@ impl BranchList {
cx,
);
this._subscriptions
.push(cx.subscribe(&this.picker, |_, _, _, cx| {
cx.emit(DismissEvent);
.push(cx.subscribe(&this.picker, |this, _, _, cx| {
if !this.branch_filter_menu_open(cx) {
cx.emit(DismissEvent);
}
}));
this
}
@@ -416,19 +427,32 @@ impl BranchList {
})
}
pub fn handle_filter(
pub(crate) fn set_branch_filter(
&mut self,
_: &branch_picker::FilterRemotes,
branch_filter: BranchFilter,
window: &mut Window,
cx: &mut Context<Self>,
) {
cx.set_global(GlobalBranchFilter(branch_filter));
self.picker.update(cx, |picker, cx| {
picker.delegate.branch_filter = picker.delegate.branch_filter.invert();
if picker.delegate.branch_filter == branch_filter {
return;
}
picker.delegate.branch_filter = branch_filter;
picker.update_matches(picker.query(cx), window, cx);
picker.refresh_placeholder(window, cx);
cx.notify();
});
}
pub(crate) fn branch_filter_menu_open(&self, cx: &App) -> bool {
self.picker.read(cx).delegate.branch_filter_menu_open
}
pub(crate) fn cycle_branch_filter(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let branch_filter = self.picker.read(cx).delegate.branch_filter.next();
self.set_branch_filter(branch_filter, window, cx);
}
}
impl ModalView for BranchList {}
impl EventEmitter<DismissEvent> for BranchList {}
@@ -446,11 +470,35 @@ impl Render for BranchList {
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
.on_action(cx.listener(Self::handle_delete))
.on_action(cx.listener(Self::handle_force_delete))
.on_action(cx.listener(Self::handle_filter))
.on_action(
cx.listener(|this, _: &branch_picker::ShowAllBranches, window, cx| {
this.set_branch_filter(BranchFilter::All, window, cx);
}),
)
.on_action(
cx.listener(|this, _: &branch_picker::ShowLocalBranches, window, cx| {
this.set_branch_filter(BranchFilter::Local, window, cx);
}),
)
.on_action(
cx.listener(|this, _: &branch_picker::ShowRemoteBranches, window, cx| {
this.set_branch_filter(BranchFilter::Remote, window, cx);
}),
)
.on_action(
cx.listener(|this, _: &branch_picker::CycleBranchFilter, window, cx| {
this.cycle_branch_filter(window, cx);
}),
)
.child(self.picker.clone())
.when(!self.embedded, |this| {
this.on_mouse_down_out({
cx.listener(move |this, _, window, cx| {
// The filter menu is a deferred popover, so clicks within it are outside
// the branch picker's bounds even though it is part of this interaction.
if this.picker.read(cx).delegate.branch_filter_menu_open {
return;
}
this.picker.update(cx, |this, cx| {
this.cancel(&Default::default(), window, cx);
})
@@ -507,22 +555,96 @@ impl Entry {
}
#[derive(Clone, Copy, PartialEq)]
enum BranchFilter {
pub(crate) enum BranchFilter {
/// Show both local and remote branches.
All,
/// Only show local branches.
Local,
/// Only show remote branches.
Remote,
}
impl BranchFilter {
fn invert(&self) -> Self {
fn next(self) -> Self {
match self {
BranchFilter::All => BranchFilter::Remote,
BranchFilter::Remote => BranchFilter::All,
Self::All => Self::Local,
Self::Local => Self::Remote,
Self::Remote => Self::All,
}
}
}
struct GlobalBranchFilter(BranchFilter);
impl Global for GlobalBranchFilter {}
fn branch_filter_menu(
branch_filter: BranchFilter,
picker: WeakEntity<Picker<BranchListDelegate>>,
window: &mut Window,
cx: &mut App,
) -> Entity<ContextMenu> {
let picker_for_menu = picker.clone();
let menu = ContextMenu::build(window, cx, |menu, _, _| {
menu.header("Show")
.item(branch_filter_menu_entry(
"All Branches",
BranchFilter::All,
branch_filter,
picker_for_menu.clone(),
))
.item(branch_filter_menu_entry(
"Local Branches",
BranchFilter::Local,
branch_filter,
picker_for_menu.clone(),
))
.item(branch_filter_menu_entry(
"Remote Branches",
BranchFilter::Remote,
branch_filter,
picker_for_menu,
))
});
window
.subscribe(&menu, cx, move |_, _: &DismissEvent, window, cx| {
let picker = picker.clone();
window.defer(cx, move |_, cx| {
picker
.update(cx, |picker, _| {
picker.delegate.branch_filter_menu_open = false;
})
.log_err();
});
})
.detach();
menu
}
fn branch_filter_menu_entry(
label: &'static str,
branch_filter: BranchFilter,
current_branch_filter: BranchFilter,
picker: WeakEntity<Picker<BranchListDelegate>>,
) -> ContextMenuEntry {
ContextMenuEntry::new(label)
.toggle(IconPosition::End, branch_filter == current_branch_filter)
.handler(move |window, cx| {
picker
.update(cx, |picker, cx| {
cx.set_global(GlobalBranchFilter(branch_filter));
if picker.delegate.branch_filter == branch_filter {
return;
}
picker.delegate.branch_filter = branch_filter;
picker.update_matches(picker.query(cx), window, cx);
picker.refresh_placeholder(window, cx);
cx.notify();
})
.log_err();
})
}
pub struct BranchListDelegate {
workspace: WeakEntity<Workspace>,
matches: Vec<Entry>,
@@ -535,6 +657,8 @@ pub struct BranchListDelegate {
last_query: String,
modifiers: Modifiers,
branch_filter: BranchFilter,
branch_filter_menu_handle: PopoverMenuHandle<ContextMenu>,
branch_filter_menu_open: bool,
state: PickerState,
branch_selection_behavior: BranchSelectionBehavior,
focus_handle: FocusHandle,
@@ -846,6 +970,9 @@ impl BranchListDelegate {
selected_branch, ..
} => selected_branch.clone(),
};
let branch_filter = cx
.try_global::<GlobalBranchFilter>()
.map_or(BranchFilter::All, |filter| filter.0);
Self {
workspace,
@@ -858,7 +985,9 @@ impl BranchListDelegate {
selected_index: 0,
last_query: Default::default(),
modifiers: Default::default(),
branch_filter: BranchFilter::All,
branch_filter,
branch_filter_menu_handle: PopoverMenuHandle::default(),
branch_filter_menu_open: false,
state: PickerState::List,
branch_selection_behavior,
focus_handle: cx.focus_handle(),
@@ -1072,9 +1201,9 @@ impl PickerDelegate for BranchListDelegate {
&self,
editor: &Arc<dyn ErasedEditor>,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
cx: &mut Context<Picker<Self>>,
) -> Div {
let focus_handle = self.focus_handle.clone();
let picker = cx.weak_entity();
let editor = editor.as_any().downcast_ref::<Entity<Editor>>().unwrap();
let show_inline_filter =
@@ -1110,30 +1239,36 @@ impl PickerDelegate for BranchListDelegate {
.px_2p5()
.child(editor.clone())
.when(show_inline_filter, |this| {
let tooltip_label = match self.branch_filter {
BranchFilter::All => "Filter Remote Branches",
BranchFilter::Remote => "Show All Branches",
};
this.gap_1().justify_between().child({
IconButton::new("filter-remotes", IconName::Filter)
.toggle_state(self.branch_filter == BranchFilter::Remote)
.icon_size(IconSize::Small)
.tooltip(move |_, cx| {
Tooltip::for_action_in(
tooltip_label,
&branch_picker::FilterRemotes,
&focus_handle,
let branch_filter = self.branch_filter;
let picker_for_menu_open = picker.clone();
this.gap_1().justify_between().child(
PopoverMenu::new("branch-filter-menu")
.with_handle(self.branch_filter_menu_handle.clone())
.on_open(Rc::new(move |_, cx| {
picker_for_menu_open
.update(cx, |picker, _| {
picker.delegate.branch_filter_menu_open = true;
})
.log_err();
}))
.trigger(
IconButton::new("branch-filter", IconName::ListFilter)
.toggle_state(branch_filter != BranchFilter::All)
.when(branch_filter != BranchFilter::All, |this| {
this.indicator(Indicator::dot().color(Color::Info))
})
.icon_size(IconSize::Small)
.tooltip(Tooltip::text("Filter branches")),
)
.menu(move |window, cx| {
Some(branch_filter_menu(
branch_filter,
picker.clone(),
window,
cx,
)
})
.on_click(|_click, window, cx| {
window.dispatch_action(
branch_picker::FilterRemotes.boxed_clone(),
cx,
);
})
})
))
}),
)
}),
)
.when(
@@ -1189,6 +1324,7 @@ impl PickerDelegate for BranchListDelegate {
cx.spawn_in(window, async move |picker, cx| {
let branch_matches_filter = |branch: &Branch| match branch_filter {
BranchFilter::All => true,
BranchFilter::Local => !branch.is_remote(),
BranchFilter::Remote => branch.is_remote(),
};
@@ -1721,6 +1857,7 @@ impl PickerDelegate for BranchListDelegate {
return None;
}
let focus_handle = self.focus_handle.clone();
let picker = cx.weak_entity();
let footer_container = || {
h_flex()
@@ -1818,29 +1955,31 @@ impl PickerDelegate for BranchListDelegate {
} else {
this.justify_between()
.child({
let focus_handle = focus_handle.clone();
let filter_label = match self.branch_filter {
BranchFilter::All => "Filter Remote",
BranchFilter::Remote => "Show All",
};
Button::new("filter-remotes", filter_label)
.toggle_state(matches!(
self.branch_filter,
BranchFilter::Remote
))
.key_binding(
KeyBinding::for_action_in(
&branch_picker::FilterRemotes,
&focus_handle,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
let branch_filter = self.branch_filter;
let picker_for_menu_open = picker.clone();
PopoverMenu::new("branch-filter-footer-menu")
.with_handle(self.branch_filter_menu_handle.clone())
.on_open(Rc::new(move |_, cx| {
picker_for_menu_open
.update(cx, |picker, _| {
picker.delegate.branch_filter_menu_open =
true;
})
.log_err();
}))
.trigger(
Button::new("branch-filter", "Filter…")
.toggle_state(
branch_filter != BranchFilter::All,
),
)
.on_click(|_click, window, cx| {
window.dispatch_action(
branch_picker::FilterRemotes.boxed_clone(),
.menu(move |window, cx| {
Some(branch_filter_menu(
branch_filter,
picker.clone(),
window,
cx,
);
))
})
})
.child(delete_and_select_btns)
@@ -2597,7 +2736,7 @@ mod tests {
}
#[gpui::test]
async fn test_branch_filter_shows_all_then_remotes_and_applies_query(cx: &mut TestAppContext) {
async fn test_branch_filter_shows_all_local_and_remote_branches(cx: &mut TestAppContext) {
init_test(cx);
let branches = vec![
@@ -2650,32 +2789,54 @@ mod tests {
branch_list.update(cx, |branch_list, cx| {
branch_list.picker.update(cx, |picker, _cx| {
picker.delegate.branch_filter = BranchFilter::Local;
})
});
update_branch_list_matches_with_empty_query(&branch_list, cx).await;
branch_list.update(cx, |branch_list, cx| {
branch_list.picker.update(cx, |picker, _cx| {
assert_eq!(
picker
.delegate
.matches
.iter()
.map(|entry| entry.name())
.collect::<HashSet<_>>(),
["feature-ui", "develop"].into_iter().collect()
);
picker.delegate.branch_filter = BranchFilter::Remote;
})
});
update_branch_list_matches_with_empty_query(&branch_list, cx).await;
branch_list.update(cx, |branch_list, cx| {
branch_list.picker.update(cx, |picker, _cx| {
assert_eq!(picker.delegate.matches.len(), 2);
let branches = picker
.delegate
.matches
.iter()
.map(|be| be.name())
.collect::<HashSet<_>>();
assert_eq!(
branches,
["origin/main", "fork/feature-auth"]
.into_iter()
.collect::<HashSet<_>>()
);
// Verify the last entry is NOT the "create new branch" option
let last_match = picker.delegate.matches.last().unwrap();
assert!(!last_match.is_new_url());
})
});
branch_list
.update_in(cx, |branch_list, window, cx| {
branch_list.picker.update(cx, |picker, cx| {
assert_eq!(picker.delegate.matches.len(), 2);
let branches = picker
.delegate
.matches
.iter()
.map(|be| be.name())
.collect::<HashSet<_>>();
assert_eq!(
branches,
["origin/main", "fork/feature-auth"]
.into_iter()
.collect::<HashSet<_>>()
);
// Verify the last entry is NOT the "create new branch" option
let last_match = picker.delegate.matches.last().unwrap();
assert!(!last_match.is_new_url());
picker.delegate.branch_filter = BranchFilter::Remote;
picker
.delegate
@@ -2703,6 +2864,26 @@ mod tests {
});
}
#[gpui::test]
async fn test_branch_filter_is_restored_for_new_pickers(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| cx.set_global(GlobalBranchFilter(BranchFilter::Local)));
let (branch_list, mut ctx) = init_branch_list_test(None, create_test_branches(), cx).await;
branch_list.update(&mut ctx, |branch_list, cx| {
branch_list.picker.update(cx, |picker, _| {
assert!(picker.delegate.branch_filter == BranchFilter::Local);
});
});
}
#[test]
fn test_branch_filter_cycle() {
assert!(BranchFilter::All.next() == BranchFilter::Local);
assert!(BranchFilter::Local.next() == BranchFilter::Remote);
assert!(BranchFilter::Remote.next() == BranchFilter::All);
}
#[gpui::test]
async fn test_new_branch_creation_with_query(cx: &mut TestAppContext) {
const MAIN_BRANCH: &str = "main";
+59 -6
View File
@@ -12,7 +12,10 @@ use ui::{
};
use workspace::{ModalView, Workspace, pane};
use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes, ForceDeleteBranch};
use crate::branch_picker::{
self, BranchList, CycleBranchFilter, DeleteBranch, ForceDeleteBranch, ShowAllBranches,
ShowLocalBranches, ShowRemoteBranches,
};
use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList};
actions!(git_picker, [ActivateBranchesTab, ActivateStashTab,]);
@@ -308,15 +311,55 @@ impl GitPicker {
}
}
fn handle_filter_remotes(
fn set_active_branch_filter(
&mut self,
_: &FilterRemotes,
branch_filter: branch_picker::BranchFilter,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(branch_list) = &self.branch_list {
branch_list.update(cx, |list, cx| {
list.handle_filter(&FilterRemotes, window, cx);
list.set_branch_filter(branch_filter, window, cx);
});
}
}
fn handle_show_all_branches(
&mut self,
_: &ShowAllBranches,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.set_active_branch_filter(branch_picker::BranchFilter::All, window, cx);
}
fn handle_show_local_branches(
&mut self,
_: &ShowLocalBranches,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.set_active_branch_filter(branch_picker::BranchFilter::Local, window, cx);
}
fn handle_show_remote_branches(
&mut self,
_: &ShowRemoteBranches,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.set_active_branch_filter(branch_picker::BranchFilter::Remote, window, cx);
}
fn handle_cycle_branch_filter(
&mut self,
_: &CycleBranchFilter,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(branch_list) = &self.branch_list {
branch_list.update(cx, |list, cx| {
list.cycle_branch_filter(window, cx);
});
}
}
@@ -377,7 +420,14 @@ impl Render for GitPicker {
.elevation_3(cx)
.overflow_hidden()
.when(self.popover_style, |el| {
el.on_mouse_down_out(cx.listener(|_, _, _, cx| {
el.on_mouse_down_out(cx.listener(|this, _, _, cx| {
if this
.branch_list
.as_ref()
.is_some_and(|branch_list| branch_list.read(cx).branch_filter_menu_open(cx))
{
return;
}
cx.emit(DismissEvent);
}))
})
@@ -421,7 +471,10 @@ impl Render for GitPicker {
.when(self.tab == GitPickerTab::Branches, |el| {
el.on_action(cx.listener(Self::handle_delete_branch))
.on_action(cx.listener(Self::handle_force_delete_branch))
.on_action(cx.listener(Self::handle_filter_remotes))
.on_action(cx.listener(Self::handle_show_all_branches))
.on_action(cx.listener(Self::handle_show_local_branches))
.on_action(cx.listener(Self::handle_show_remote_branches))
.on_action(cx.listener(Self::handle_cycle_branch_filter))
})
.when(self.tab == GitPickerTab::Stashes, |el| {
el.on_action(cx.listener(Self::handle_drop_stash))