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);
 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                        (
126                            !branch.is_head, // Current branch (is_head=true) comes first
127                            branch
128                                .most_recent_commit
129                                .as_ref()
130                                .map(|commit| 0 - commit.commit_timestamp),
131                        )
132                    });
133
134                    all_branches
135                })
136                .await;
137
138            this.update_in(cx, |this, window, cx| {
139                this.picker.update(cx, |picker, cx| {
140                    picker.delegate.default_branch = default_branch;
141                    picker.delegate.all_branches = Some(all_branches);
142                    picker.refresh(window, cx);
143                })
144            })?;
145
146            anyhow::Ok(())
147        })
148        .detach_and_log_err(cx);
149
150        let delegate = BranchListDelegate::new(repository, style);
151        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
152
153        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
154            cx.emit(DismissEvent);
155        });
156
157        Self {
158            picker,
159            width,
160            _subscription,
161        }
162    }
163
164    fn handle_modifiers_changed(
165        &mut self,
166        ev: &ModifiersChangedEvent,
167        _: &mut Window,
168        cx: &mut Context<Self>,
169    ) {
170        self.picker
171            .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
172    }
173}
174impl ModalView for BranchList {}
175impl EventEmitter<DismissEvent> for BranchList {}
176
177impl Focusable for BranchList {
178    fn focus_handle(&self, cx: &App) -> FocusHandle {
179        self.picker.focus_handle(cx)
180    }
181}
182
183impl Render for BranchList {
184    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
185        v_flex()
186            .key_context("GitBranchSelector")
187            .w(self.width)
188            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
189            .child(self.picker.clone())
190            .on_mouse_down_out({
191                cx.listener(move |this, _, window, cx| {
192                    this.picker.update(cx, |this, cx| {
193                        this.cancel(&Default::default(), window, cx);
194                    })
195                })
196            })
197    }
198}
199
200#[derive(Debug, Clone)]
201struct BranchEntry {
202    branch: Branch,
203    positions: Vec<usize>,
204    is_new: bool,
205}
206
207pub struct BranchListDelegate {
208    matches: Vec<BranchEntry>,
209    all_branches: Option<Vec<Branch>>,
210    default_branch: Option<SharedString>,
211    repo: Option<Entity<Repository>>,
212    style: BranchListStyle,
213    selected_index: usize,
214    last_query: String,
215    modifiers: Modifiers,
216}
217
218impl BranchListDelegate {
219    fn new(repo: Option<Entity<Repository>>, style: BranchListStyle) -> Self {
220        Self {
221            matches: vec![],
222            repo,
223            style,
224            all_branches: None,
225            default_branch: None,
226            selected_index: 0,
227            last_query: Default::default(),
228            modifiers: Default::default(),
229        }
230    }
231
232    fn create_branch(
233        &self,
234        from_branch: Option<SharedString>,
235        new_branch_name: SharedString,
236        window: &mut Window,
237        cx: &mut Context<Picker<Self>>,
238    ) {
239        let Some(repo) = self.repo.clone() else {
240            return;
241        };
242        let new_branch_name = new_branch_name.to_string().replace(' ', "-");
243        cx.spawn(async move |_, cx| {
244            if let Some(based_branch) = from_branch {
245                repo.update(cx, |repo, _| repo.change_branch(based_branch.to_string()))?
246                    .await??;
247            }
248
249            repo.update(cx, |repo, _| {
250                repo.create_branch(new_branch_name.to_string())
251            })?
252            .await??;
253            repo.update(cx, |repo, _| {
254                repo.change_branch(new_branch_name.to_string())
255            })?
256            .await??;
257
258            Ok(())
259        })
260        .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
261            Some(e.to_string())
262        });
263        cx.emit(DismissEvent);
264    }
265}
266
267impl PickerDelegate for BranchListDelegate {
268    type ListItem = ListItem;
269
270    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
271        "Select branch…".into()
272    }
273
274    fn editor_position(&self) -> PickerEditorPosition {
275        match self.style {
276            BranchListStyle::Modal => PickerEditorPosition::Start,
277            BranchListStyle::Popover => PickerEditorPosition::End,
278        }
279    }
280
281    fn match_count(&self) -> usize {
282        self.matches.len()
283    }
284
285    fn selected_index(&self) -> usize {
286        self.selected_index
287    }
288
289    fn set_selected_index(
290        &mut self,
291        ix: usize,
292        _window: &mut Window,
293        _: &mut Context<Picker<Self>>,
294    ) {
295        self.selected_index = ix;
296    }
297
298    fn update_matches(
299        &mut self,
300        query: String,
301        window: &mut Window,
302        cx: &mut Context<Picker<Self>>,
303    ) -> Task<()> {
304        let Some(all_branches) = self.all_branches.clone() else {
305            return Task::ready(());
306        };
307
308        const RECENT_BRANCHES_COUNT: usize = 10;
309        cx.spawn_in(window, async move |picker, cx| {
310            let mut matches: Vec<BranchEntry> = if query.is_empty() {
311                all_branches
312                    .into_iter()
313                    .filter(|branch| !branch.is_remote())
314                    .take(RECENT_BRANCHES_COUNT)
315                    .map(|branch| BranchEntry {
316                        branch,
317                        positions: Vec::new(),
318                        is_new: false,
319                    })
320                    .collect()
321            } else {
322                let candidates = all_branches
323                    .iter()
324                    .enumerate()
325                    .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
326                    .collect::<Vec<StringMatchCandidate>>();
327                fuzzy::match_strings(
328                    &candidates,
329                    &query,
330                    true,
331                    true,
332                    10000,
333                    &Default::default(),
334                    cx.background_executor().clone(),
335                )
336                .await
337                .into_iter()
338                .map(|candidate| BranchEntry {
339                    branch: all_branches[candidate.candidate_id].clone(),
340                    positions: candidate.positions,
341                    is_new: false,
342                })
343                .collect()
344            };
345            picker
346                .update(cx, |picker, _| {
347                    if !query.is_empty()
348                        && !matches
349                            .first()
350                            .is_some_and(|entry| entry.branch.name() == query)
351                    {
352                        let query = query.replace(' ', "-");
353                        matches.push(BranchEntry {
354                            branch: Branch {
355                                ref_name: format!("refs/heads/{query}").into(),
356                                is_head: false,
357                                upstream: None,
358                                most_recent_commit: None,
359                            },
360                            positions: Vec::new(),
361                            is_new: true,
362                        })
363                    }
364                    let delegate = &mut picker.delegate;
365                    delegate.matches = matches;
366                    if delegate.matches.is_empty() {
367                        delegate.selected_index = 0;
368                    } else {
369                        delegate.selected_index =
370                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
371                    }
372                    delegate.last_query = query;
373                })
374                .log_err();
375        })
376    }
377
378    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
379        let Some(entry) = self.matches.get(self.selected_index()) else {
380            return;
381        };
382        if entry.is_new {
383            let from_branch = if secondary {
384                self.default_branch.clone()
385            } else {
386                None
387            };
388            self.create_branch(
389                from_branch,
390                entry.branch.name().to_owned().into(),
391                window,
392                cx,
393            );
394            return;
395        }
396
397        let current_branch = self.repo.as_ref().map(|repo| {
398            repo.read_with(cx, |repo, _| {
399                repo.branch.as_ref().map(|branch| branch.ref_name.clone())
400            })
401        });
402
403        if current_branch
404            .flatten()
405            .is_some_and(|current_branch| current_branch == entry.branch.ref_name)
406        {
407            cx.emit(DismissEvent);
408            return;
409        }
410
411        cx.spawn_in(window, {
412            let branch = entry.branch.clone();
413            async move |picker, cx| {
414                let branch_change_task = picker.update(cx, |this, cx| {
415                    let repo = this
416                        .delegate
417                        .repo
418                        .as_ref()
419                        .context("No active repository")?
420                        .clone();
421
422                    let mut cx = cx.to_async();
423
424                    anyhow::Ok(async move {
425                        repo.update(&mut cx, |repo, _| {
426                            repo.change_branch(branch.name().to_string())
427                        })?
428                        .await?
429                    })
430                })??;
431
432                branch_change_task.await?;
433
434                picker.update(cx, |_, cx| {
435                    cx.emit(DismissEvent);
436
437                    anyhow::Ok(())
438                })
439            }
440        })
441        .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
442    }
443
444    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
445        cx.emit(DismissEvent);
446    }
447
448    fn render_match(
449        &self,
450        ix: usize,
451        selected: bool,
452        _window: &mut Window,
453        cx: &mut Context<Picker<Self>>,
454    ) -> Option<Self::ListItem> {
455        let entry = &self.matches[ix];
456
457        let (commit_time, subject) = entry
458            .branch
459            .most_recent_commit
460            .as_ref()
461            .map(|commit| {
462                let subject = commit.subject.clone();
463                let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
464                    .unwrap_or_else(|_| OffsetDateTime::now_utc());
465                let formatted_time = format_local_timestamp(
466                    commit_time,
467                    OffsetDateTime::now_utc(),
468                    time_format::TimestampFormat::Relative,
469                );
470                (Some(formatted_time), Some(subject))
471            })
472            .unwrap_or_else(|| (None, None));
473
474        let icon = if let Some(default_branch) = self.default_branch.clone()
475            && entry.is_new
476        {
477            Some(
478                IconButton::new("branch-from-default", IconName::GitBranchAlt)
479                    .on_click(cx.listener(move |this, _, window, cx| {
480                        this.delegate.set_selected_index(ix, window, cx);
481                        this.delegate.confirm(true, window, cx);
482                    }))
483                    .tooltip(move |window, cx| {
484                        Tooltip::for_action(
485                            format!("Create branch based off default: {default_branch}"),
486                            &menu::SecondaryConfirm,
487                            window,
488                            cx,
489                        )
490                    }),
491            )
492        } else {
493            None
494        };
495
496        let branch_name = if entry.is_new {
497            h_flex()
498                .gap_1()
499                .child(
500                    Icon::new(IconName::Plus)
501                        .size(IconSize::Small)
502                        .color(Color::Muted),
503                )
504                .child(
505                    Label::new(format!("Create branch \"{}\"", entry.branch.name()))
506                        .single_line()
507                        .truncate(),
508                )
509                .into_any_element()
510        } else {
511            HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone())
512                .truncate()
513                .into_any_element()
514        };
515
516        Some(
517            ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
518                .inset(true)
519                .spacing(ListItemSpacing::Sparse)
520                .toggle_state(selected)
521                .child(
522                    v_flex()
523                        .w_full()
524                        .overflow_hidden()
525                        .child(
526                            h_flex()
527                                .gap_6()
528                                .justify_between()
529                                .overflow_x_hidden()
530                                .child(branch_name)
531                                .when_some(commit_time, |label, commit_time| {
532                                    label.child(
533                                        Label::new(commit_time)
534                                            .size(LabelSize::Small)
535                                            .color(Color::Muted)
536                                            .into_element(),
537                                    )
538                                }),
539                        )
540                        .when(self.style == BranchListStyle::Modal, |el| {
541                            el.child(div().max_w_96().child({
542                                let message = if entry.is_new {
543                                    if let Some(current_branch) =
544                                        self.repo.as_ref().and_then(|repo| {
545                                            repo.read(cx).branch.as_ref().map(|b| b.name())
546                                        })
547                                    {
548                                        format!("based off {}", current_branch)
549                                    } else {
550                                        "based off the current branch".to_string()
551                                    }
552                                } else {
553                                    subject.unwrap_or("no commits found".into()).to_string()
554                                };
555                                Label::new(message)
556                                    .size(LabelSize::Small)
557                                    .truncate()
558                                    .color(Color::Muted)
559                            }))
560                        }),
561                )
562                .end_slot::<IconButton>(icon),
563        )
564    }
565
566    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
567        None
568    }
569}