branch_picker.rs

  1use anyhow::{anyhow, Context as _};
  2use fuzzy::{StringMatch, StringMatchCandidate};
  3
  4use git::repository::Branch;
  5use gpui::{
  6    rems, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  7    InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
  8    SharedString, Styled, Subscription, Task, Window,
  9};
 10use picker::{Picker, PickerDelegate};
 11use project::git::Repository;
 12use std::sync::Arc;
 13use ui::{
 14    prelude::*, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip,
 15};
 16use util::ResultExt;
 17use workspace::notifications::DetachAndPromptErr;
 18use workspace::{ModalView, Workspace};
 19
 20pub fn init(cx: &mut App) {
 21    cx.observe_new(|workspace: &mut Workspace, _, _| {
 22        workspace.register_action(open);
 23        workspace.register_action(switch);
 24        workspace.register_action(checkout_branch);
 25    })
 26    .detach();
 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).clone();
 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(15.), 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 popover_handle: PopoverMenuHandle<Self>,
 81    pub picker: Entity<Picker<BranchListDelegate>>,
 82    _subscription: Subscription,
 83}
 84
 85impl BranchList {
 86    fn new(
 87        repository: Option<Entity<Repository>>,
 88        style: BranchListStyle,
 89        width: Rems,
 90        window: &mut Window,
 91        cx: &mut Context<Self>,
 92    ) -> Self {
 93        let popover_handle = PopoverMenuHandle::default();
 94        let all_branches_request = repository
 95            .clone()
 96            .map(|repository| repository.read(cx).branches());
 97
 98        cx.spawn_in(window, |this, mut cx| async move {
 99            let all_branches = all_branches_request
100                .context("No active repository")?
101                .await??;
102
103            this.update_in(&mut cx, |this, window, cx| {
104                this.picker.update(cx, |picker, cx| {
105                    picker.delegate.all_branches = Some(all_branches);
106                    picker.refresh(window, cx);
107                })
108            })?;
109
110            anyhow::Ok(())
111        })
112        .detach_and_log_err(cx);
113
114        let delegate = BranchListDelegate::new(repository.clone(), style, (1.6 * width.0) as usize);
115        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
116
117        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
118            cx.emit(DismissEvent);
119        });
120
121        Self {
122            picker,
123            width,
124            popover_handle,
125            _subscription,
126        }
127    }
128
129    fn handle_modifiers_changed(
130        &mut self,
131        ev: &ModifiersChangedEvent,
132        _: &mut Window,
133        cx: &mut Context<Self>,
134    ) {
135        self.picker
136            .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
137    }
138}
139impl ModalView for BranchList {}
140impl EventEmitter<DismissEvent> for BranchList {}
141
142impl Focusable for BranchList {
143    fn focus_handle(&self, cx: &App) -> FocusHandle {
144        self.picker.focus_handle(cx)
145    }
146}
147
148impl Render for BranchList {
149    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
150        v_flex()
151            .w(self.width)
152            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
153            .child(self.picker.clone())
154            .on_mouse_down_out({
155                cx.listener(move |this, _, window, cx| {
156                    this.picker.update(cx, |this, cx| {
157                        this.cancel(&Default::default(), window, cx);
158                    })
159                })
160            })
161    }
162}
163
164#[derive(Debug, Clone)]
165enum BranchEntry {
166    Branch(StringMatch),
167    History(String),
168}
169
170impl BranchEntry {
171    fn name(&self) -> &str {
172        match self {
173            Self::Branch(branch) => &branch.string,
174            Self::History(branch) => &branch,
175        }
176    }
177}
178
179pub struct BranchListDelegate {
180    matches: Vec<BranchEntry>,
181    all_branches: Option<Vec<Branch>>,
182    repo: Option<Entity<Repository>>,
183    style: BranchListStyle,
184    selected_index: usize,
185    last_query: String,
186    /// Max length of branch name before we truncate it and add a trailing `...`.
187    branch_name_trailoff_after: usize,
188    modifiers: Modifiers,
189}
190
191impl BranchListDelegate {
192    fn new(
193        repo: Option<Entity<Repository>>,
194        style: BranchListStyle,
195        branch_name_trailoff_after: usize,
196    ) -> Self {
197        Self {
198            matches: vec![],
199            repo,
200            style,
201            all_branches: None,
202            selected_index: 0,
203            last_query: Default::default(),
204            branch_name_trailoff_after,
205            modifiers: Default::default(),
206        }
207    }
208
209    fn has_exact_match(&self, target: &str) -> bool {
210        self.matches.iter().any(|mat| match mat {
211            BranchEntry::Branch(branch) => branch.string == target,
212            _ => false,
213        })
214    }
215
216    fn create_branch(
217        &self,
218        new_branch_name: SharedString,
219        window: &mut Window,
220        cx: &mut Context<Picker<Self>>,
221    ) {
222        let Some(repo) = self.repo.clone() else {
223            return;
224        };
225        cx.spawn(|_, cx| async move {
226            cx.update(|cx| repo.read(cx).create_branch(&new_branch_name))?
227                .await??;
228            cx.update(|cx| repo.read(cx).change_branch(&new_branch_name))?
229                .await??;
230            Ok(())
231        })
232        .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
233            Some(e.to_string())
234        });
235        cx.emit(DismissEvent);
236    }
237}
238
239impl PickerDelegate for BranchListDelegate {
240    type ListItem = ListItem;
241
242    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
243        "Select branch...".into()
244    }
245
246    fn match_count(&self) -> usize {
247        self.matches.len()
248    }
249
250    fn selected_index(&self) -> usize {
251        self.selected_index
252    }
253
254    fn set_selected_index(
255        &mut self,
256        ix: usize,
257        _window: &mut Window,
258        _: &mut Context<Picker<Self>>,
259    ) {
260        self.selected_index = ix;
261    }
262
263    fn update_matches(
264        &mut self,
265        query: String,
266        window: &mut Window,
267        cx: &mut Context<Picker<Self>>,
268    ) -> Task<()> {
269        let Some(mut all_branches) = self.all_branches.clone() else {
270            return Task::ready(());
271        };
272
273        cx.spawn_in(window, move |picker, mut cx| async move {
274            const RECENT_BRANCHES_COUNT: usize = 10;
275            if query.is_empty() {
276                if all_branches.len() > RECENT_BRANCHES_COUNT {
277                    // Truncate list of recent branches
278                    // Do a partial sort to show recent-ish branches first.
279                    all_branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
280                        rhs.priority_key().cmp(&lhs.priority_key())
281                    });
282                    all_branches.truncate(RECENT_BRANCHES_COUNT);
283                }
284                all_branches.sort_unstable_by(|lhs, rhs| {
285                    rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
286                });
287            }
288
289            let candidates = all_branches
290                .into_iter()
291                .enumerate()
292                .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
293                .collect::<Vec<StringMatchCandidate>>();
294            let matches: Vec<BranchEntry> = if query.is_empty() {
295                candidates
296                    .into_iter()
297                    .map(|candidate| BranchEntry::History(candidate.string))
298                    .collect()
299            } else {
300                fuzzy::match_strings(
301                    &candidates,
302                    &query,
303                    true,
304                    10000,
305                    &Default::default(),
306                    cx.background_executor().clone(),
307                )
308                .await
309                .iter()
310                .cloned()
311                .map(BranchEntry::Branch)
312                .collect()
313            };
314            picker
315                .update(&mut cx, |picker, _| {
316                    let delegate = &mut picker.delegate;
317                    delegate.matches = matches;
318                    if delegate.matches.is_empty() {
319                        delegate.selected_index = 0;
320                    } else {
321                        delegate.selected_index =
322                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
323                    }
324                    delegate.last_query = query;
325                })
326                .log_err();
327        })
328    }
329
330    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
331        let new_branch_name = SharedString::from(self.last_query.trim().replace(" ", "-"));
332        if !new_branch_name.is_empty()
333            && !self.has_exact_match(&new_branch_name)
334            && ((self.selected_index == 0 && self.matches.len() == 0) || secondary)
335        {
336            self.create_branch(new_branch_name, window, cx);
337            return;
338        }
339
340        let Some(branch) = self.matches.get(self.selected_index()) else {
341            return;
342        };
343
344        let current_branch = self.repo.as_ref().map(|repo| {
345            repo.update(cx, |repo, _| {
346                repo.current_branch().map(|branch| branch.name.clone())
347            })
348        });
349
350        if current_branch
351            .flatten()
352            .is_some_and(|current_branch| current_branch == branch.name())
353        {
354            cx.emit(DismissEvent);
355            return;
356        }
357
358        cx.spawn_in(window, {
359            let branch = branch.clone();
360            |picker, mut cx| async move {
361                let branch_change_task = picker.update(&mut cx, |this, cx| {
362                    let repo = this
363                        .delegate
364                        .repo
365                        .as_ref()
366                        .ok_or_else(|| anyhow!("No active repository"))?
367                        .clone();
368
369                    let cx = cx.to_async();
370
371                    anyhow::Ok(async move {
372                        match branch {
373                            BranchEntry::Branch(StringMatch {
374                                string: branch_name,
375                                ..
376                            })
377                            | BranchEntry::History(branch_name) => {
378                                cx.update(|cx| repo.read(cx).change_branch(&branch_name))?
379                                    .await?
380                            }
381                        }
382                    })
383                })??;
384
385                branch_change_task.await?;
386
387                picker.update(&mut cx, |_, cx| {
388                    cx.emit(DismissEvent);
389
390                    anyhow::Ok(())
391                })
392            }
393        })
394        .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
395    }
396
397    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
398        cx.emit(DismissEvent);
399    }
400
401    fn render_match(
402        &self,
403        ix: usize,
404        selected: bool,
405        _window: &mut Window,
406        _cx: &mut Context<Picker<Self>>,
407    ) -> Option<Self::ListItem> {
408        let hit = &self.matches[ix];
409        let shortened_branch_name =
410            util::truncate_and_trailoff(&hit.name(), self.branch_name_trailoff_after);
411
412        Some(
413            ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
414                .inset(true)
415                .spacing(match self.style {
416                    BranchListStyle::Modal => ListItemSpacing::default(),
417                    BranchListStyle::Popover => ListItemSpacing::ExtraDense,
418                })
419                .spacing(ListItemSpacing::Sparse)
420                .toggle_state(selected)
421                .when(matches!(hit, BranchEntry::History(_)), |el| {
422                    el.end_slot(
423                        Icon::new(IconName::HistoryRerun)
424                            .color(Color::Muted)
425                            .size(IconSize::Small),
426                    )
427                })
428                .map(|el| match hit {
429                    BranchEntry::Branch(branch) => {
430                        let highlights: Vec<_> = branch
431                            .positions
432                            .iter()
433                            .filter(|index| index < &&self.branch_name_trailoff_after)
434                            .copied()
435                            .collect();
436
437                        el.child(HighlightedLabel::new(shortened_branch_name, highlights))
438                    }
439                    BranchEntry::History(_) => el.child(Label::new(shortened_branch_name)),
440                }),
441        )
442    }
443
444    fn render_footer(
445        &self,
446        window: &mut Window,
447        cx: &mut Context<Picker<Self>>,
448    ) -> Option<AnyElement> {
449        let new_branch_name = SharedString::from(self.last_query.trim().replace(" ", "-"));
450        let handle = cx.weak_entity();
451        Some(
452            h_flex()
453                .w_full()
454                .p_2()
455                .gap_2()
456                .border_t_1()
457                .border_color(cx.theme().colors().border_variant)
458                .when(
459                    !new_branch_name.is_empty() && !self.has_exact_match(&new_branch_name),
460                    |el| {
461                        el.child(
462                            Button::new(
463                                "create-branch",
464                                format!("Create branch '{new_branch_name}'",),
465                            )
466                            .key_binding(KeyBinding::for_action(
467                                &menu::SecondaryConfirm,
468                                window,
469                                cx,
470                            ))
471                            .toggle_state(
472                                self.modifiers.secondary()
473                                    || (self.selected_index == 0 && self.matches.len() == 0),
474                            )
475                            .tooltip(Tooltip::for_action_title(
476                                "Create branch",
477                                &menu::SecondaryConfirm,
478                            ))
479                            .on_click(move |_, window, cx| {
480                                let new_branch_name = new_branch_name.clone();
481                                if let Some(picker) = handle.upgrade() {
482                                    picker.update(cx, |picker, cx| {
483                                        picker.delegate.create_branch(new_branch_name, window, cx)
484                                    });
485                                }
486                            }),
487                        )
488                    },
489                )
490                .into_any(),
491        )
492    }
493
494    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
495        None
496    }
497}