branch_picker.rs

  1use anyhow::Context as _;
  2use fuzzy::StringMatchCandidate;
  3
  4use collections::HashSet;
  5use git::repository::Branch;
  6use gpui::{
  7    App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
  8    IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, SharedString, Styled,
  9    Subscription, Task, Window, rems,
 10};
 11use picker::{Picker, PickerDelegate, PickerEditorPosition};
 12use project::git_store::Repository;
 13use project::project_settings::ProjectSettings;
 14use settings::Settings;
 15use std::sync::Arc;
 16use time::OffsetDateTime;
 17use time_format::format_local_timestamp;
 18use ui::{HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
 19use util::ResultExt;
 20use workspace::notifications::DetachAndPromptErr;
 21use workspace::{ModalView, Workspace};
 22
 23pub fn register(workspace: &mut Workspace) {
 24    workspace.register_action(open);
 25    workspace.register_action(switch);
 26    workspace.register_action(checkout_branch);
 27}
 28
 29pub fn checkout_branch(
 30    workspace: &mut Workspace,
 31    _: &zed_actions::git::CheckoutBranch,
 32    window: &mut Window,
 33    cx: &mut Context<Workspace>,
 34) {
 35    open(workspace, &zed_actions::git::Branch, window, cx);
 36}
 37
 38pub fn switch(
 39    workspace: &mut Workspace,
 40    _: &zed_actions::git::Switch,
 41    window: &mut Window,
 42    cx: &mut Context<Workspace>,
 43) {
 44    open(workspace, &zed_actions::git::Branch, window, cx);
 45}
 46
 47pub fn open(
 48    workspace: &mut Workspace,
 49    _: &zed_actions::git::Branch,
 50    window: &mut Window,
 51    cx: &mut Context<Workspace>,
 52) {
 53    let repository = workspace.project().read(cx).active_repository(cx);
 54    let style = BranchListStyle::Modal;
 55    workspace.toggle_modal(window, cx, |window, cx| {
 56        BranchList::new(repository, style, rems(34.), window, cx)
 57    })
 58}
 59
 60pub fn popover(
 61    repository: Option<Entity<Repository>>,
 62    window: &mut Window,
 63    cx: &mut App,
 64) -> Entity<BranchList> {
 65    cx.new(|cx| {
 66        let list = BranchList::new(repository, BranchListStyle::Popover, rems(20.), window, cx);
 67        list.focus_handle(cx).focus(window);
 68        list
 69    })
 70}
 71
 72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 73enum BranchListStyle {
 74    Modal,
 75    Popover,
 76}
 77
 78pub struct BranchList {
 79    width: Rems,
 80    pub picker: Entity<Picker<BranchListDelegate>>,
 81    _subscription: Subscription,
 82}
 83
 84impl BranchList {
 85    fn new(
 86        repository: Option<Entity<Repository>>,
 87        style: BranchListStyle,
 88        width: Rems,
 89        window: &mut Window,
 90        cx: &mut Context<Self>,
 91    ) -> Self {
 92        let all_branches_request = repository
 93            .clone()
 94            .map(|repository| repository.update(cx, |repository, _| repository.branches()));
 95        let default_branch_request = repository
 96            .clone()
 97            .map(|repository| repository.update(cx, |repository, _| repository.default_branch()));
 98
 99        cx.spawn_in(window, async move |this, cx| {
100            let mut all_branches = all_branches_request
101                .context("No active repository")?
102                .await??;
103            let default_branch = default_branch_request
104                .context("No active repository")?
105                .await
106                .map(Result::ok)
107                .ok()
108                .flatten()
109                .flatten();
110
111            let all_branches = cx
112                .background_spawn(async move {
113                    let remote_upstreams: HashSet<_> = all_branches
114                        .iter()
115                        .filter_map(|branch| {
116                            branch
117                                .upstream
118                                .as_ref()
119                                .filter(|upstream| upstream.is_remote())
120                                .map(|upstream| upstream.ref_name.clone())
121                        })
122                        .collect();
123
124                    all_branches.retain(|branch| !remote_upstreams.contains(&branch.ref_name));
125
126                    all_branches.sort_by_key(|branch| {
127                        (
128                            !branch.is_head, // Current branch (is_head=true) comes first
129                            branch
130                                .most_recent_commit
131                                .as_ref()
132                                .map(|commit| 0 - commit.commit_timestamp),
133                        )
134                    });
135
136                    all_branches
137                })
138                .await;
139
140            this.update_in(cx, |this, window, cx| {
141                this.picker.update(cx, |picker, cx| {
142                    picker.delegate.default_branch = default_branch;
143                    picker.delegate.all_branches = Some(all_branches);
144                    picker.refresh(window, cx);
145                })
146            })?;
147
148            anyhow::Ok(())
149        })
150        .detach_and_log_err(cx);
151
152        let delegate = BranchListDelegate::new(repository, style);
153        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
154
155        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
156            cx.emit(DismissEvent);
157        });
158
159        Self {
160            picker,
161            width,
162            _subscription,
163        }
164    }
165
166    fn handle_modifiers_changed(
167        &mut self,
168        ev: &ModifiersChangedEvent,
169        _: &mut Window,
170        cx: &mut Context<Self>,
171    ) {
172        self.picker
173            .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
174    }
175}
176impl ModalView for BranchList {}
177impl EventEmitter<DismissEvent> for BranchList {}
178
179impl Focusable for BranchList {
180    fn focus_handle(&self, cx: &App) -> FocusHandle {
181        self.picker.focus_handle(cx)
182    }
183}
184
185impl Render for BranchList {
186    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
187        v_flex()
188            .key_context("GitBranchSelector")
189            .w(self.width)
190            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
191            .child(self.picker.clone())
192            .on_mouse_down_out({
193                cx.listener(move |this, _, window, cx| {
194                    this.picker.update(cx, |this, cx| {
195                        this.cancel(&Default::default(), window, cx);
196                    })
197                })
198            })
199    }
200}
201
202#[derive(Debug, Clone)]
203struct BranchEntry {
204    branch: Branch,
205    positions: Vec<usize>,
206    is_new: bool,
207}
208
209pub struct BranchListDelegate {
210    matches: Vec<BranchEntry>,
211    all_branches: Option<Vec<Branch>>,
212    default_branch: Option<SharedString>,
213    repo: Option<Entity<Repository>>,
214    style: BranchListStyle,
215    selected_index: usize,
216    last_query: String,
217    modifiers: Modifiers,
218}
219
220impl BranchListDelegate {
221    fn new(repo: Option<Entity<Repository>>, style: BranchListStyle) -> Self {
222        Self {
223            matches: vec![],
224            repo,
225            style,
226            all_branches: None,
227            default_branch: None,
228            selected_index: 0,
229            last_query: Default::default(),
230            modifiers: Default::default(),
231        }
232    }
233
234    fn create_branch(
235        &self,
236        from_branch: Option<SharedString>,
237        new_branch_name: SharedString,
238        window: &mut Window,
239        cx: &mut Context<Picker<Self>>,
240    ) {
241        let Some(repo) = self.repo.clone() else {
242            return;
243        };
244        let new_branch_name = new_branch_name.to_string().replace(' ', "-");
245        cx.spawn(async move |_, cx| {
246            if let Some(based_branch) = from_branch {
247                match repo
248                    .update(cx, |repo, _| repo.change_branch(based_branch.to_string()))?
249                    .await
250                {
251                    Ok(Ok(_)) => {}
252                    Ok(Err(error)) => return Err(error),
253                    Err(_) => return Err(anyhow::anyhow!("Operation was canceled")),
254                }
255            }
256
257            match repo
258                .update(cx, |repo, _| repo.create_branch(new_branch_name.clone()))?
259                .await
260            {
261                Ok(Ok(_)) => {}
262                Ok(Err(error)) => return Err(error),
263                Err(_) => return Err(anyhow::anyhow!("Operation was canceled")),
264            }
265
266            match repo
267                .update(cx, |repo, _| repo.change_branch(new_branch_name))?
268                .await
269            {
270                Ok(Ok(_)) => {}
271                Ok(Err(error)) => return Err(error),
272                Err(_) => return Err(anyhow::anyhow!("Operation was canceled")),
273            }
274
275            Ok(())
276        })
277        .detach_and_prompt_err("Failed to create branch", window, cx, |_, _, _| None);
278    }
279}
280
281impl PickerDelegate for BranchListDelegate {
282    type ListItem = ListItem;
283
284    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
285        "Select branch…".into()
286    }
287
288    fn editor_position(&self) -> PickerEditorPosition {
289        match self.style {
290            BranchListStyle::Modal => PickerEditorPosition::Start,
291            BranchListStyle::Popover => PickerEditorPosition::End,
292        }
293    }
294
295    fn match_count(&self) -> usize {
296        self.matches.len()
297    }
298
299    fn selected_index(&self) -> usize {
300        self.selected_index
301    }
302
303    fn set_selected_index(
304        &mut self,
305        ix: usize,
306        _window: &mut Window,
307        _: &mut Context<Picker<Self>>,
308    ) {
309        self.selected_index = ix;
310    }
311
312    fn update_matches(
313        &mut self,
314        query: String,
315        window: &mut Window,
316        cx: &mut Context<Picker<Self>>,
317    ) -> Task<()> {
318        let Some(all_branches) = self.all_branches.clone() else {
319            return Task::ready(());
320        };
321
322        const RECENT_BRANCHES_COUNT: usize = 10;
323        cx.spawn_in(window, async move |picker, cx| {
324            let mut matches: Vec<BranchEntry> = if query.is_empty() {
325                all_branches
326                    .into_iter()
327                    .filter(|branch| !branch.is_remote())
328                    .take(RECENT_BRANCHES_COUNT)
329                    .map(|branch| BranchEntry {
330                        branch,
331                        positions: Vec::new(),
332                        is_new: false,
333                    })
334                    .collect()
335            } else {
336                let candidates = all_branches
337                    .iter()
338                    .enumerate()
339                    .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
340                    .collect::<Vec<StringMatchCandidate>>();
341                fuzzy::match_strings(
342                    &candidates,
343                    &query,
344                    true,
345                    true,
346                    10000,
347                    &Default::default(),
348                    cx.background_executor().clone(),
349                )
350                .await
351                .into_iter()
352                .map(|candidate| BranchEntry {
353                    branch: all_branches[candidate.candidate_id].clone(),
354                    positions: candidate.positions,
355                    is_new: false,
356                })
357                .collect()
358            };
359            picker
360                .update(cx, |picker, _| {
361                    if !query.is_empty()
362                        && !matches
363                            .first()
364                            .is_some_and(|entry| entry.branch.name() == query)
365                    {
366                        let query = query.replace(' ', "-");
367                        matches.push(BranchEntry {
368                            branch: Branch {
369                                ref_name: format!("refs/heads/{query}").into(),
370                                is_head: false,
371                                upstream: None,
372                                most_recent_commit: None,
373                            },
374                            positions: Vec::new(),
375                            is_new: true,
376                        })
377                    }
378                    let delegate = &mut picker.delegate;
379                    delegate.matches = matches;
380                    if delegate.matches.is_empty() {
381                        delegate.selected_index = 0;
382                    } else {
383                        delegate.selected_index =
384                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
385                    }
386                    delegate.last_query = query;
387                })
388                .log_err();
389        })
390    }
391
392    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
393        let Some(entry) = self.matches.get(self.selected_index()) else {
394            return;
395        };
396        if entry.is_new {
397            let from_branch = if secondary {
398                self.default_branch.clone()
399            } else {
400                None
401            };
402            self.create_branch(
403                from_branch,
404                entry.branch.name().to_owned().into(),
405                window,
406                cx,
407            );
408            return;
409        }
410
411        let current_branch = self.repo.as_ref().map(|repo| {
412            repo.read_with(cx, |repo, _| {
413                repo.branch.as_ref().map(|branch| branch.ref_name.clone())
414            })
415        });
416
417        if current_branch
418            .flatten()
419            .is_some_and(|current_branch| current_branch == entry.branch.ref_name)
420        {
421            cx.emit(DismissEvent);
422            return;
423        }
424
425        cx.spawn_in(window, {
426            let branch = entry.branch.clone();
427            async move |picker, cx| {
428                let branch_name = branch.name().to_string();
429
430                let branch_change_task = picker.update(cx, |this, cx| {
431                    let repo = this
432                        .delegate
433                        .repo
434                        .as_ref()
435                        .context("No active repository")?
436                        .clone();
437
438                    let mut cx = cx.to_async();
439
440                    anyhow::Ok(async move {
441                        repo.update(&mut cx, |repo, _| repo.change_branch(branch_name))?
442                            .await?
443                    })
444                })??;
445
446                match branch_change_task.await {
447                    Ok(_) => {
448                        let _ = picker.update(cx, |_, cx| {
449                            cx.emit(DismissEvent);
450                            anyhow::Ok(())
451                        })?;
452                    }
453                    Err(error) => {
454                        let _ = picker.update(cx, |_, cx| {
455                            cx.emit(DismissEvent);
456                            anyhow::Ok(())
457                        })?;
458                        return Err(error);
459                    }
460                }
461
462                anyhow::Ok(())
463            }
464        })
465        .detach_and_prompt_err("Failed to switch branch", window, cx, |_, _, _| None);
466    }
467
468    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
469        cx.emit(DismissEvent);
470    }
471
472    fn render_match(
473        &self,
474        ix: usize,
475        selected: bool,
476        _window: &mut Window,
477        cx: &mut Context<Picker<Self>>,
478    ) -> Option<Self::ListItem> {
479        let entry = &self.matches.get(ix)?;
480
481        let (commit_time, author_name, subject) = entry
482            .branch
483            .most_recent_commit
484            .as_ref()
485            .map(|commit| {
486                let subject = commit.subject.clone();
487                let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
488                    .unwrap_or_else(|_| OffsetDateTime::now_utc());
489                let formatted_time = format_local_timestamp(
490                    commit_time,
491                    OffsetDateTime::now_utc(),
492                    time_format::TimestampFormat::Relative,
493                );
494                let author = commit.author_name.clone();
495                (Some(formatted_time), Some(author), Some(subject))
496            })
497            .unwrap_or_else(|| (None, None, None));
498
499        let icon = if let Some(default_branch) = self.default_branch.clone()
500            && entry.is_new
501        {
502            Some(
503                IconButton::new("branch-from-default", IconName::GitBranchAlt)
504                    .on_click(cx.listener(move |this, _, window, cx| {
505                        this.delegate.set_selected_index(ix, window, cx);
506                        this.delegate.confirm(true, window, cx);
507                    }))
508                    .tooltip(move |window, cx| {
509                        Tooltip::for_action(
510                            format!("Create branch based off default: {default_branch}"),
511                            &menu::SecondaryConfirm,
512                            window,
513                            cx,
514                        )
515                    }),
516            )
517        } else {
518            None
519        };
520
521        let branch_name = if entry.is_new {
522            h_flex()
523                .gap_1()
524                .child(
525                    Icon::new(IconName::Plus)
526                        .size(IconSize::Small)
527                        .color(Color::Muted),
528                )
529                .child(
530                    Label::new(format!("Create branch \"{}\"", entry.branch.name()))
531                        .single_line()
532                        .truncate(),
533                )
534                .into_any_element()
535        } else {
536            HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone())
537                .truncate()
538                .into_any_element()
539        };
540
541        Some(
542            ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
543                .inset(true)
544                .spacing(ListItemSpacing::Sparse)
545                .toggle_state(selected)
546                .child(
547                    v_flex()
548                        .w_full()
549                        .overflow_hidden()
550                        .child(
551                            h_flex()
552                                .gap_6()
553                                .justify_between()
554                                .overflow_x_hidden()
555                                .child(branch_name)
556                                .when_some(commit_time, |label, commit_time| {
557                                    label.child(
558                                        Label::new(commit_time)
559                                            .size(LabelSize::Small)
560                                            .color(Color::Muted)
561                                            .into_element(),
562                                    )
563                                }),
564                        )
565                        .when(self.style == BranchListStyle::Modal, |el| {
566                            el.child(div().max_w_96().child({
567                                let message = if entry.is_new {
568                                    if let Some(current_branch) =
569                                        self.repo.as_ref().and_then(|repo| {
570                                            repo.read(cx).branch.as_ref().map(|b| b.name())
571                                        })
572                                    {
573                                        format!("based off {}", current_branch)
574                                    } else {
575                                        "based off the current branch".to_string()
576                                    }
577                                } else {
578                                    let show_author_name = ProjectSettings::get_global(cx)
579                                        .git
580                                        .branch_picker
581                                        .unwrap_or_default()
582                                        .show_author_name;
583
584                                    subject.map_or("no commits found".into(), |subject| {
585                                        if show_author_name && author_name.is_some() {
586                                            format!("{}{}", author_name.unwrap(), subject)
587                                        } else {
588                                            subject.to_string()
589                                        }
590                                    })
591                                };
592                                Label::new(message)
593                                    .size(LabelSize::Small)
594                                    .truncate()
595                                    .color(Color::Muted)
596                            }))
597                        }),
598                )
599                .end_slot::<IconButton>(icon),
600        )
601    }
602
603    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
604        None
605    }
606}