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