branch_picker.rs

  1use anyhow::{anyhow, Context as _};
  2use fuzzy::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 time::OffsetDateTime;
 14use time_format::format_local_timestamp;
 15use ui::{
 16    prelude::*, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip,
 17};
 18use util::ResultExt;
 19use workspace::notifications::DetachAndPromptErr;
 20use workspace::{ModalView, Workspace};
 21
 22pub fn init(cx: &mut App) {
 23    cx.observe_new(|workspace: &mut Workspace, _, _| {
 24        workspace.register_action(open);
 25        workspace.register_action(switch);
 26        workspace.register_action(checkout_branch);
 27    })
 28    .detach();
 29}
 30
 31pub fn checkout_branch(
 32    workspace: &mut Workspace,
 33    _: &zed_actions::git::CheckoutBranch,
 34    window: &mut Window,
 35    cx: &mut Context<Workspace>,
 36) {
 37    open(workspace, &zed_actions::git::Branch, window, cx);
 38}
 39
 40pub fn switch(
 41    workspace: &mut Workspace,
 42    _: &zed_actions::git::Switch,
 43    window: &mut Window,
 44    cx: &mut Context<Workspace>,
 45) {
 46    open(workspace, &zed_actions::git::Branch, window, cx);
 47}
 48
 49pub fn open(
 50    workspace: &mut Workspace,
 51    _: &zed_actions::git::Branch,
 52    window: &mut Window,
 53    cx: &mut Context<Workspace>,
 54) {
 55    let repository = workspace.project().read(cx).active_repository(cx).clone();
 56    let style = BranchListStyle::Modal;
 57    workspace.toggle_modal(window, cx, |window, cx| {
 58        BranchList::new(repository, style, rems(34.), window, cx)
 59    })
 60}
 61
 62pub fn popover(
 63    repository: Option<Entity<Repository>>,
 64    window: &mut Window,
 65    cx: &mut App,
 66) -> Entity<BranchList> {
 67    cx.new(|cx| {
 68        let list = BranchList::new(repository, BranchListStyle::Popover, rems(20.), window, cx);
 69        list.focus_handle(cx).focus(window);
 70        list
 71    })
 72}
 73
 74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 75enum BranchListStyle {
 76    Modal,
 77    Popover,
 78}
 79
 80pub struct BranchList {
 81    width: Rems,
 82    pub popover_handle: PopoverMenuHandle<Self>,
 83    pub picker: Entity<Picker<BranchListDelegate>>,
 84    _subscription: Subscription,
 85}
 86
 87impl BranchList {
 88    fn new(
 89        repository: Option<Entity<Repository>>,
 90        style: BranchListStyle,
 91        width: Rems,
 92        window: &mut Window,
 93        cx: &mut Context<Self>,
 94    ) -> Self {
 95        let popover_handle = PopoverMenuHandle::default();
 96        let all_branches_request = repository
 97            .clone()
 98            .map(|repository| repository.read(cx).branches());
 99
100        cx.spawn_in(window, |this, mut cx| async move {
101            let mut all_branches = all_branches_request
102                .context("No active repository")?
103                .await??;
104
105            all_branches.sort_by_key(|branch| {
106                branch
107                    .most_recent_commit
108                    .as_ref()
109                    .map(|commit| 0 - commit.commit_timestamp)
110            });
111
112            this.update_in(&mut cx, |this, window, cx| {
113                this.picker.update(cx, |picker, cx| {
114                    picker.delegate.all_branches = Some(all_branches);
115                    picker.refresh(window, cx);
116                })
117            })?;
118
119            anyhow::Ok(())
120        })
121        .detach_and_log_err(cx);
122
123        let delegate = BranchListDelegate::new(repository.clone(), style);
124        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
125
126        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
127            cx.emit(DismissEvent);
128        });
129
130        Self {
131            picker,
132            width,
133            popover_handle,
134            _subscription,
135        }
136    }
137
138    fn handle_modifiers_changed(
139        &mut self,
140        ev: &ModifiersChangedEvent,
141        _: &mut Window,
142        cx: &mut Context<Self>,
143    ) {
144        self.picker
145            .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
146    }
147}
148impl ModalView for BranchList {}
149impl EventEmitter<DismissEvent> for BranchList {}
150
151impl Focusable for BranchList {
152    fn focus_handle(&self, cx: &App) -> FocusHandle {
153        self.picker.focus_handle(cx)
154    }
155}
156
157impl Render for BranchList {
158    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
159        v_flex()
160            .w(self.width)
161            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
162            .child(self.picker.clone())
163            .on_mouse_down_out({
164                cx.listener(move |this, _, window, cx| {
165                    this.picker.update(cx, |this, cx| {
166                        this.cancel(&Default::default(), window, cx);
167                    })
168                })
169            })
170    }
171}
172
173#[derive(Debug, Clone)]
174struct BranchEntry {
175    branch: Branch,
176    positions: Vec<usize>,
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    modifiers: Modifiers,
187}
188
189impl BranchListDelegate {
190    fn new(repo: Option<Entity<Repository>>, style: BranchListStyle) -> Self {
191        Self {
192            matches: vec![],
193            repo,
194            style,
195            all_branches: None,
196            selected_index: 0,
197            last_query: Default::default(),
198            modifiers: Default::default(),
199        }
200    }
201
202    fn has_exact_match(&self, target: &str) -> bool {
203        self.matches.iter().any(|entry| entry.branch.name == target)
204    }
205
206    fn create_branch(
207        &self,
208        new_branch_name: SharedString,
209        window: &mut Window,
210        cx: &mut Context<Picker<Self>>,
211    ) {
212        let Some(repo) = self.repo.clone() else {
213            return;
214        };
215        cx.spawn(|_, cx| async move {
216            cx.update(|cx| repo.read(cx).create_branch(&new_branch_name))?
217                .await??;
218            cx.update(|cx| repo.read(cx).change_branch(&new_branch_name))?
219                .await??;
220            Ok(())
221        })
222        .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
223            Some(e.to_string())
224        });
225        cx.emit(DismissEvent);
226    }
227}
228
229impl PickerDelegate for BranchListDelegate {
230    type ListItem = ListItem;
231
232    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
233        "Select branch...".into()
234    }
235
236    fn match_count(&self) -> usize {
237        self.matches.len()
238    }
239
240    fn selected_index(&self) -> usize {
241        self.selected_index
242    }
243
244    fn set_selected_index(
245        &mut self,
246        ix: usize,
247        _window: &mut Window,
248        _: &mut Context<Picker<Self>>,
249    ) {
250        self.selected_index = ix;
251    }
252
253    fn update_matches(
254        &mut self,
255        query: String,
256        window: &mut Window,
257        cx: &mut Context<Picker<Self>>,
258    ) -> Task<()> {
259        let Some(all_branches) = self.all_branches.clone() else {
260            return Task::ready(());
261        };
262
263        const RECENT_BRANCHES_COUNT: usize = 10;
264        cx.spawn_in(window, move |picker, mut cx| async move {
265            let matches = if query.is_empty() {
266                all_branches
267                    .into_iter()
268                    .take(RECENT_BRANCHES_COUNT)
269                    .map(|branch| BranchEntry {
270                        branch,
271                        positions: Vec::new(),
272                    })
273                    .collect()
274            } else {
275                let candidates = all_branches
276                    .iter()
277                    .enumerate()
278                    .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name.clone()))
279                    .collect::<Vec<StringMatchCandidate>>();
280                fuzzy::match_strings(
281                    &candidates,
282                    &query,
283                    true,
284                    10000,
285                    &Default::default(),
286                    cx.background_executor().clone(),
287                )
288                .await
289                .iter()
290                .cloned()
291                .map(|candidate| BranchEntry {
292                    branch: all_branches[candidate.candidate_id].clone(),
293                    positions: candidate.positions,
294                })
295                .collect()
296            };
297            picker
298                .update(&mut cx, |picker, _| {
299                    let delegate = &mut picker.delegate;
300                    delegate.matches = matches;
301                    if delegate.matches.is_empty() {
302                        delegate.selected_index = 0;
303                    } else {
304                        delegate.selected_index =
305                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
306                    }
307                    delegate.last_query = query;
308                })
309                .log_err();
310        })
311    }
312
313    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
314        let new_branch_name = SharedString::from(self.last_query.trim().replace(" ", "-"));
315        if !new_branch_name.is_empty()
316            && !self.has_exact_match(&new_branch_name)
317            && ((self.selected_index == 0 && self.matches.len() == 0) || secondary)
318        {
319            self.create_branch(new_branch_name, window, cx);
320            return;
321        }
322
323        let Some(entry) = self.matches.get(self.selected_index()) else {
324            return;
325        };
326
327        let current_branch = self.repo.as_ref().map(|repo| {
328            repo.update(cx, |repo, _| {
329                repo.current_branch().map(|branch| branch.name.clone())
330            })
331        });
332
333        if current_branch
334            .flatten()
335            .is_some_and(|current_branch| current_branch == entry.branch.name)
336        {
337            cx.emit(DismissEvent);
338            return;
339        }
340
341        cx.spawn_in(window, {
342            let branch = entry.branch.clone();
343            |picker, mut cx| async move {
344                let branch_change_task = picker.update(&mut cx, |this, cx| {
345                    let repo = this
346                        .delegate
347                        .repo
348                        .as_ref()
349                        .ok_or_else(|| anyhow!("No active repository"))?
350                        .clone();
351
352                    let cx = cx.to_async();
353
354                    anyhow::Ok(async move {
355                        cx.update(|cx| repo.read(cx).change_branch(&branch.name))?
356                            .await?
357                    })
358                })??;
359
360                branch_change_task.await?;
361
362                picker.update(&mut cx, |_, cx| {
363                    cx.emit(DismissEvent);
364
365                    anyhow::Ok(())
366                })
367            }
368        })
369        .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
370    }
371
372    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
373        cx.emit(DismissEvent);
374    }
375
376    fn render_header(&self, _: &mut Window, _cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
377        None
378    }
379
380    fn render_match(
381        &self,
382        ix: usize,
383        selected: bool,
384        _window: &mut Window,
385        _cx: &mut Context<Picker<Self>>,
386    ) -> Option<Self::ListItem> {
387        let entry = &self.matches[ix];
388
389        let (commit_time, commit_message) = entry
390            .branch
391            .most_recent_commit
392            .as_ref()
393            .map(|commit| {
394                let commit_message = commit.subject.clone();
395                let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
396                    .unwrap_or_else(|_| OffsetDateTime::now_utc());
397                let formatted_time = format_local_timestamp(
398                    commit_time,
399                    OffsetDateTime::now_utc(),
400                    time_format::TimestampFormat::Relative,
401                );
402                (formatted_time, commit_message)
403            })
404            .unwrap_or_else(|| {
405                (
406                    "Unknown Date".to_string(),
407                    SharedString::from("No commit message available"),
408                )
409            });
410
411        Some(
412            ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
413                .inset(true)
414                .spacing(match self.style {
415                    BranchListStyle::Modal => ListItemSpacing::default(),
416                    BranchListStyle::Popover => ListItemSpacing::ExtraDense,
417                })
418                .spacing(ListItemSpacing::Sparse)
419                .toggle_state(selected)
420                .child(
421                    v_flex()
422                        .w_full()
423                        .child(
424                            h_flex()
425                                .w_full()
426                                .flex_shrink()
427                                .overflow_x_hidden()
428                                .gap_2()
429                                .justify_between()
430                                .child(
431                                    div().flex_shrink().overflow_x_hidden().child(
432                                        HighlightedLabel::new(
433                                            entry.branch.name.clone(),
434                                            entry.positions.clone(),
435                                        )
436                                        .truncate(),
437                                    ),
438                                )
439                                .child(
440                                    Label::new(commit_time)
441                                        .size(LabelSize::Small)
442                                        .color(Color::Muted)
443                                        .into_element(),
444                                ),
445                        )
446                        .when(self.style == BranchListStyle::Modal, |el| {
447                            el.child(
448                                div().max_w_96().child(
449                                    Label::new(
450                                        commit_message
451                                            .split('\n')
452                                            .next()
453                                            .unwrap_or_default()
454                                            .to_string(),
455                                    )
456                                    .size(LabelSize::Small)
457                                    .truncate()
458                                    .color(Color::Muted),
459                                ),
460                            )
461                        }),
462                ),
463        )
464    }
465
466    fn render_footer(
467        &self,
468        window: &mut Window,
469        cx: &mut Context<Picker<Self>>,
470    ) -> Option<AnyElement> {
471        let new_branch_name = SharedString::from(self.last_query.trim().replace(" ", "-"));
472        let handle = cx.weak_entity();
473        Some(
474            h_flex()
475                .w_full()
476                .p_2()
477                .gap_2()
478                .border_t_1()
479                .border_color(cx.theme().colors().border_variant)
480                .when(
481                    !new_branch_name.is_empty() && !self.has_exact_match(&new_branch_name),
482                    |el| {
483                        el.child(
484                            Button::new(
485                                "create-branch",
486                                format!("Create branch '{new_branch_name}'",),
487                            )
488                            .key_binding(KeyBinding::for_action(
489                                &menu::SecondaryConfirm,
490                                window,
491                                cx,
492                            ))
493                            .toggle_state(
494                                self.modifiers.secondary()
495                                    || (self.selected_index == 0 && self.matches.len() == 0),
496                            )
497                            .tooltip(Tooltip::for_action_title(
498                                "Create branch",
499                                &menu::SecondaryConfirm,
500                            ))
501                            .on_click(move |_, window, cx| {
502                                let new_branch_name = new_branch_name.clone();
503                                if let Some(picker) = handle.upgrade() {
504                                    picker.update(cx, |picker, cx| {
505                                        picker.delegate.create_branch(new_branch_name, window, cx)
506                                    });
507                                }
508                            }),
509                        )
510                    },
511                )
512                .into_any(),
513        )
514    }
515
516    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
517        None
518    }
519}