branch_picker.rs

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