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