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