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