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