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        cx.spawn(async move |_, cx| {
225            repo.update(cx, |repo, _| {
226                repo.create_branch(new_branch_name.to_string())
227            })?
228            .await??;
229            repo.update(cx, |repo, _| {
230                repo.change_branch(new_branch_name.to_string())
231            })?
232            .await??;
233
234            Ok(())
235        })
236        .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
237            Some(e.to_string())
238        });
239        cx.emit(DismissEvent);
240    }
241}
242
243impl PickerDelegate for BranchListDelegate {
244    type ListItem = ListItem;
245
246    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
247        "Select branch...".into()
248    }
249
250    fn editor_position(&self) -> PickerEditorPosition {
251        match self.style {
252            BranchListStyle::Modal => PickerEditorPosition::Start,
253            BranchListStyle::Popover => PickerEditorPosition::End,
254        }
255    }
256
257    fn match_count(&self) -> usize {
258        self.matches.len()
259    }
260
261    fn selected_index(&self) -> usize {
262        self.selected_index
263    }
264
265    fn set_selected_index(
266        &mut self,
267        ix: usize,
268        _window: &mut Window,
269        _: &mut Context<Picker<Self>>,
270    ) {
271        self.selected_index = ix;
272    }
273
274    fn update_matches(
275        &mut self,
276        query: String,
277        window: &mut Window,
278        cx: &mut Context<Picker<Self>>,
279    ) -> Task<()> {
280        let Some(all_branches) = self.all_branches.clone() else {
281            return Task::ready(());
282        };
283
284        const RECENT_BRANCHES_COUNT: usize = 10;
285        cx.spawn_in(window, async move |picker, cx| {
286            let mut matches: Vec<BranchEntry> = if query.is_empty() {
287                all_branches
288                    .into_iter()
289                    .filter(|branch| !branch.is_remote())
290                    .take(RECENT_BRANCHES_COUNT)
291                    .map(|branch| BranchEntry {
292                        branch,
293                        positions: Vec::new(),
294                        is_new: false,
295                    })
296                    .collect()
297            } else {
298                let candidates = all_branches
299                    .iter()
300                    .enumerate()
301                    .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
302                    .collect::<Vec<StringMatchCandidate>>();
303                fuzzy::match_strings(
304                    &candidates,
305                    &query,
306                    true,
307                    10000,
308                    &Default::default(),
309                    cx.background_executor().clone(),
310                )
311                .await
312                .into_iter()
313                .map(|candidate| BranchEntry {
314                    branch: all_branches[candidate.candidate_id].clone(),
315                    positions: candidate.positions,
316                    is_new: false,
317                })
318                .collect()
319            };
320            picker
321                .update(cx, |picker, _| {
322                    #[allow(clippy::nonminimal_bool)]
323                    if !query.is_empty()
324                        && !matches
325                            .first()
326                            .is_some_and(|entry| entry.branch.name() == query)
327                    {
328                        matches.push(BranchEntry {
329                            branch: Branch {
330                                ref_name: format!("refs/heads/{query}").into(),
331                                is_head: false,
332                                upstream: None,
333                                most_recent_commit: None,
334                            },
335                            positions: Vec::new(),
336                            is_new: true,
337                        })
338                    }
339                    let delegate = &mut picker.delegate;
340                    delegate.matches = matches;
341                    if delegate.matches.is_empty() {
342                        delegate.selected_index = 0;
343                    } else {
344                        delegate.selected_index =
345                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
346                    }
347                    delegate.last_query = query;
348                })
349                .log_err();
350        })
351    }
352
353    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
354        let Some(entry) = self.matches.get(self.selected_index()) else {
355            return;
356        };
357        if entry.is_new {
358            self.create_branch(entry.branch.name().to_owned().into(), window, cx);
359            return;
360        }
361
362        let current_branch = self.repo.as_ref().map(|repo| {
363            repo.read_with(cx, |repo, _| {
364                repo.branch.as_ref().map(|branch| branch.ref_name.clone())
365            })
366        });
367
368        if current_branch
369            .flatten()
370            .is_some_and(|current_branch| current_branch == entry.branch.ref_name)
371        {
372            cx.emit(DismissEvent);
373            return;
374        }
375
376        cx.spawn_in(window, {
377            let branch = entry.branch.clone();
378            async move |picker, cx| {
379                let branch_change_task = picker.update(cx, |this, cx| {
380                    let repo = this
381                        .delegate
382                        .repo
383                        .as_ref()
384                        .context("No active repository")?
385                        .clone();
386
387                    let mut cx = cx.to_async();
388
389                    anyhow::Ok(async move {
390                        repo.update(&mut cx, |repo, _| {
391                            repo.change_branch(branch.name().to_string())
392                        })?
393                        .await?
394                    })
395                })??;
396
397                branch_change_task.await?;
398
399                picker.update(cx, |_, cx| {
400                    cx.emit(DismissEvent);
401
402                    anyhow::Ok(())
403                })
404            }
405        })
406        .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
407    }
408
409    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
410        cx.emit(DismissEvent);
411    }
412
413    fn render_header(&self, _: &mut Window, _cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
414        None
415    }
416
417    fn render_match(
418        &self,
419        ix: usize,
420        selected: bool,
421        _window: &mut Window,
422        cx: &mut Context<Picker<Self>>,
423    ) -> Option<Self::ListItem> {
424        let entry = &self.matches[ix];
425
426        let (commit_time, subject) = entry
427            .branch
428            .most_recent_commit
429            .as_ref()
430            .map(|commit| {
431                let subject = commit.subject.clone();
432                let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
433                    .unwrap_or_else(|_| OffsetDateTime::now_utc());
434                let formatted_time = format_local_timestamp(
435                    commit_time,
436                    OffsetDateTime::now_utc(),
437                    time_format::TimestampFormat::Relative,
438                );
439                (Some(formatted_time), Some(subject))
440            })
441            .unwrap_or_else(|| (None, None));
442
443        Some(
444            ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
445                .inset(true)
446                .spacing(match self.style {
447                    BranchListStyle::Modal => ListItemSpacing::default(),
448                    BranchListStyle::Popover => ListItemSpacing::ExtraDense,
449                })
450                .spacing(ListItemSpacing::Sparse)
451                .toggle_state(selected)
452                .child(
453                    v_flex()
454                        .w_full()
455                        .child(
456                            h_flex()
457                                .w_full()
458                                .flex_shrink()
459                                .overflow_x_hidden()
460                                .gap_2()
461                                .justify_between()
462                                .child(div().flex_shrink().overflow_x_hidden().child(
463                                    if entry.is_new {
464                                        Label::new(format!(
465                                            "Create branch \"{}\"",
466                                            entry.branch.name()
467                                        ))
468                                        .single_line()
469                                        .into_any_element()
470                                    } else {
471                                        HighlightedLabel::new(
472                                            entry.branch.name().to_owned(),
473                                            entry.positions.clone(),
474                                        )
475                                        .truncate()
476                                        .into_any_element()
477                                    },
478                                ))
479                                .when_some(commit_time, |el, commit_time| {
480                                    el.child(
481                                        Label::new(commit_time)
482                                            .size(LabelSize::Small)
483                                            .color(Color::Muted)
484                                            .into_element(),
485                                    )
486                                }),
487                        )
488                        .when(self.style == BranchListStyle::Modal, |el| {
489                            el.child(div().max_w_96().child({
490                                let message = if entry.is_new {
491                                    if let Some(current_branch) =
492                                        self.repo.as_ref().and_then(|repo| {
493                                            repo.read(cx).branch.as_ref().map(|b| b.name())
494                                        })
495                                    {
496                                        format!("based off {}", current_branch)
497                                    } else {
498                                        "based off the current branch".to_string()
499                                    }
500                                } else {
501                                    subject.unwrap_or("no commits found".into()).to_string()
502                                };
503                                Label::new(message)
504                                    .size(LabelSize::Small)
505                                    .truncate()
506                                    .color(Color::Muted)
507                            }))
508                        }),
509                ),
510        )
511    }
512
513    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
514        None
515    }
516}