1use anyhow::{Context as _, Result};
2use fuzzy::{StringMatch, StringMatchCandidate};
3
4use git::repository::Branch;
5use gpui::{
6 rems, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
7 InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
8 Task, Window,
9};
10use picker::{Picker, PickerDelegate};
11use project::{Project, ProjectPath};
12use std::sync::Arc;
13use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenuHandle};
14use util::ResultExt;
15use workspace::notifications::DetachAndPromptErr;
16use workspace::{ModalView, Workspace};
17
18pub fn init(cx: &mut App) {
19 cx.observe_new(|workspace: &mut Workspace, _, _| {
20 workspace.register_action(open);
21 })
22 .detach();
23}
24
25pub fn open(
26 workspace: &mut Workspace,
27 _: &zed_actions::git::Branch,
28 window: &mut Window,
29 cx: &mut Context<Workspace>,
30) {
31 let project = workspace.project().clone();
32 let this = cx.entity();
33 let style = BranchListStyle::Modal;
34 cx.spawn_in(window, |_, mut cx| async move {
35 // Modal branch picker has a longer trailoff than a popover one.
36 let delegate = BranchListDelegate::new(project.clone(), style, 70, &cx).await?;
37
38 this.update_in(&mut cx, move |workspace, window, cx| {
39 workspace.toggle_modal(window, cx, |window, cx| {
40 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
41 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
42 cx.emit(DismissEvent);
43 });
44
45 let mut list = BranchList::new(project, style, 34., cx);
46 list._subscription = Some(_subscription);
47 list.picker = Some(picker);
48 list
49 })
50 })?;
51
52 Ok(())
53 })
54 .detach_and_prompt_err("Failed to read branches", window, cx, |_, _, _| None)
55}
56
57pub fn popover(project: Entity<Project>, window: &mut Window, cx: &mut App) -> Entity<BranchList> {
58 cx.new(|cx| {
59 let mut list = BranchList::new(project, BranchListStyle::Popover, 15., cx);
60 list.reload_branches(window, cx);
61 list
62 })
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66enum BranchListStyle {
67 Modal,
68 Popover,
69}
70
71pub struct BranchList {
72 rem_width: f32,
73 popover_handle: PopoverMenuHandle<Self>,
74 default_focus_handle: FocusHandle,
75 project: Entity<Project>,
76 style: BranchListStyle,
77 pub picker: Option<Entity<Picker<BranchListDelegate>>>,
78 _subscription: Option<Subscription>,
79}
80
81impl popover_button::TriggerablePopover for BranchList {
82 fn menu_handle(
83 &mut self,
84 _window: &mut Window,
85 _cx: &mut gpui::Context<Self>,
86 ) -> PopoverMenuHandle<Self> {
87 self.popover_handle.clone()
88 }
89}
90
91impl BranchList {
92 fn new(project: Entity<Project>, style: BranchListStyle, rem_width: f32, cx: &mut App) -> Self {
93 let popover_handle = PopoverMenuHandle::default();
94 Self {
95 project,
96 picker: None,
97 rem_width,
98 popover_handle,
99 default_focus_handle: cx.focus_handle(),
100 style,
101 _subscription: None,
102 }
103 }
104
105 fn reload_branches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
106 let project = self.project.clone();
107 let style = self.style;
108 cx.spawn_in(window, |this, mut cx| async move {
109 let delegate = BranchListDelegate::new(project, style, 20, &cx).await?;
110 let picker =
111 cx.new_window_entity(|window, cx| Picker::uniform_list(delegate, window, cx))?;
112
113 this.update(&mut cx, |branch_list, cx| {
114 let subscription =
115 cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| cx.emit(DismissEvent));
116
117 branch_list.picker = Some(picker);
118 branch_list._subscription = Some(subscription);
119
120 cx.notify();
121 })?;
122
123 anyhow::Ok(())
124 })
125 .detach_and_log_err(cx);
126 }
127}
128impl ModalView for BranchList {}
129impl EventEmitter<DismissEvent> for BranchList {}
130
131impl Focusable for BranchList {
132 fn focus_handle(&self, cx: &App) -> FocusHandle {
133 self.picker
134 .as_ref()
135 .map(|picker| picker.focus_handle(cx))
136 .unwrap_or_else(|| self.default_focus_handle.clone())
137 }
138}
139
140impl Render for BranchList {
141 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
142 v_flex()
143 .w(rems(self.rem_width))
144 .when_some(self.picker.clone(), |div, picker| {
145 div.child(picker.clone()).on_mouse_down_out({
146 let picker = picker.clone();
147 cx.listener(move |_, _, window, cx| {
148 picker.update(cx, |this, cx| {
149 this.cancel(&Default::default(), window, cx);
150 })
151 })
152 })
153 })
154 .when_none(&self.picker, |div| {
155 div.child(
156 h_flex()
157 .id("branch-picker-error")
158 .on_click(
159 cx.listener(|this, _, window, cx| this.reload_branches(window, cx)),
160 )
161 .child("Could not load branches.")
162 .child("Click to retry"),
163 )
164 })
165 }
166}
167
168#[derive(Debug, Clone)]
169enum BranchEntry {
170 Branch(StringMatch),
171 History(String),
172 NewBranch { name: String },
173}
174
175impl BranchEntry {
176 fn name(&self) -> &str {
177 match self {
178 Self::Branch(branch) => &branch.string,
179 Self::History(branch) => &branch,
180 Self::NewBranch { name } => &name,
181 }
182 }
183}
184
185pub struct BranchListDelegate {
186 matches: Vec<BranchEntry>,
187 all_branches: Vec<Branch>,
188 project: Entity<Project>,
189 style: BranchListStyle,
190 selected_index: usize,
191 last_query: String,
192 /// Max length of branch name before we truncate it and add a trailing `...`.
193 branch_name_trailoff_after: usize,
194}
195
196impl BranchListDelegate {
197 async fn new(
198 project: Entity<Project>,
199 style: BranchListStyle,
200 branch_name_trailoff_after: usize,
201 cx: &AsyncApp,
202 ) -> Result<Self> {
203 let all_branches_request = cx.update(|cx| {
204 let project = project.read(cx);
205 let first_worktree = project
206 .visible_worktrees(cx)
207 .next()
208 .context("No worktrees found")?;
209 let project_path = ProjectPath::root_path(first_worktree.read(cx).id());
210 anyhow::Ok(project.branches(project_path, cx))
211 })??;
212
213 let all_branches = all_branches_request.await?;
214
215 Ok(Self {
216 matches: vec![],
217 project,
218 style,
219 all_branches,
220 selected_index: 0,
221 last_query: Default::default(),
222 branch_name_trailoff_after,
223 })
224 }
225
226 pub fn branch_count(&self) -> usize {
227 self.matches
228 .iter()
229 .filter(|item| matches!(item, BranchEntry::Branch(_)))
230 .count()
231 }
232}
233
234impl PickerDelegate for BranchListDelegate {
235 type ListItem = ListItem;
236
237 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
238 "Select branch...".into()
239 }
240
241 fn match_count(&self) -> usize {
242 self.matches.len()
243 }
244
245 fn selected_index(&self) -> usize {
246 self.selected_index
247 }
248
249 fn set_selected_index(
250 &mut self,
251 ix: usize,
252 _window: &mut Window,
253 _: &mut Context<Picker<Self>>,
254 ) {
255 self.selected_index = ix;
256 }
257
258 fn update_matches(
259 &mut self,
260 query: String,
261 window: &mut Window,
262 cx: &mut Context<Picker<Self>>,
263 ) -> Task<()> {
264 cx.spawn_in(window, move |picker, mut cx| async move {
265 let candidates = picker.update(&mut cx, |picker, _| {
266 const RECENT_BRANCHES_COUNT: usize = 10;
267 let mut branches = picker.delegate.all_branches.clone();
268 if query.is_empty() {
269 if branches.len() > RECENT_BRANCHES_COUNT {
270 // Truncate list of recent branches
271 // Do a partial sort to show recent-ish branches first.
272 branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
273 rhs.priority_key().cmp(&lhs.priority_key())
274 });
275 branches.truncate(RECENT_BRANCHES_COUNT);
276 }
277 branches.sort_unstable_by(|lhs, rhs| {
278 rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
279 });
280 }
281 branches
282 .into_iter()
283 .enumerate()
284 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
285 .collect::<Vec<StringMatchCandidate>>()
286 });
287 let Some(candidates) = candidates.log_err() else {
288 return;
289 };
290 let matches: Vec<BranchEntry> = if query.is_empty() {
291 candidates
292 .into_iter()
293 .map(|candidate| BranchEntry::History(candidate.string))
294 .collect()
295 } else {
296 fuzzy::match_strings(
297 &candidates,
298 &query,
299 true,
300 10000,
301 &Default::default(),
302 cx.background_executor().clone(),
303 )
304 .await
305 .iter()
306 .cloned()
307 .map(BranchEntry::Branch)
308 .collect()
309 };
310 picker
311 .update(&mut cx, |picker, _| {
312 let delegate = &mut picker.delegate;
313 delegate.matches = matches;
314 if delegate.matches.is_empty() {
315 if !query.is_empty() {
316 delegate.matches.push(BranchEntry::NewBranch {
317 name: query.trim().replace(' ', "-"),
318 });
319 }
320
321 delegate.selected_index = 0;
322 } else {
323 delegate.selected_index =
324 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
325 }
326 delegate.last_query = query;
327 })
328 .log_err();
329 })
330 }
331
332 fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
333 let Some(branch) = self.matches.get(self.selected_index()) else {
334 return;
335 };
336
337 let current_branch = self.project.update(cx, |project, cx| {
338 project
339 .active_repository(cx)
340 .and_then(|repo| repo.read(cx).current_branch())
341 .map(|branch| branch.name.to_string())
342 });
343
344 if current_branch == Some(branch.name().to_string()) {
345 cx.emit(DismissEvent);
346 return;
347 }
348
349 cx.spawn_in(window, {
350 let branch = branch.clone();
351 |picker, mut cx| async move {
352 let branch_change_task = picker.update(&mut cx, |this, cx| {
353 let project = this.delegate.project.read(cx);
354 let branch_to_checkout = match branch {
355 BranchEntry::Branch(branch) => branch.string,
356 BranchEntry::History(string) => string,
357 BranchEntry::NewBranch { name: branch_name } => branch_name,
358 };
359 let worktree = project
360 .visible_worktrees(cx)
361 .next()
362 .context("worktree disappeared")?;
363 let repository = ProjectPath::root_path(worktree.read(cx).id());
364
365 anyhow::Ok(project.update_or_create_branch(repository, branch_to_checkout, cx))
366 })??;
367
368 branch_change_task.await?;
369
370 picker.update(&mut cx, |_, cx| {
371 cx.emit(DismissEvent);
372
373 Ok::<(), anyhow::Error>(())
374 })
375 }
376 })
377 .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
378 }
379
380 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
381 cx.emit(DismissEvent);
382 }
383
384 fn render_match(
385 &self,
386 ix: usize,
387 selected: bool,
388 _window: &mut Window,
389 _cx: &mut Context<Picker<Self>>,
390 ) -> Option<Self::ListItem> {
391 let hit = &self.matches[ix];
392 let shortened_branch_name =
393 util::truncate_and_trailoff(&hit.name(), self.branch_name_trailoff_after);
394
395 Some(
396 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
397 .inset(true)
398 .spacing(match self.style {
399 BranchListStyle::Modal => ListItemSpacing::default(),
400 BranchListStyle::Popover => ListItemSpacing::ExtraDense,
401 })
402 .spacing(ListItemSpacing::Sparse)
403 .toggle_state(selected)
404 .when(matches!(hit, BranchEntry::History(_)), |el| {
405 el.end_slot(
406 Icon::new(IconName::HistoryRerun)
407 .color(Color::Muted)
408 .size(IconSize::Small),
409 )
410 })
411 .map(|el| match hit {
412 BranchEntry::Branch(branch) => {
413 let highlights: Vec<_> = branch
414 .positions
415 .iter()
416 .filter(|index| index < &&self.branch_name_trailoff_after)
417 .copied()
418 .collect();
419
420 el.child(HighlightedLabel::new(shortened_branch_name, highlights))
421 }
422 BranchEntry::History(_) => el.child(Label::new(shortened_branch_name)),
423 BranchEntry::NewBranch { name } => {
424 el.child(Label::new(format!("Create branch '{name}'")))
425 }
426 }),
427 )
428 }
429}