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                    true,
309                    10000,
310                    &Default::default(),
311                    cx.background_executor().clone(),
312                )
313                .await
314                .into_iter()
315                .map(|candidate| BranchEntry {
316                    branch: all_branches[candidate.candidate_id].clone(),
317                    positions: candidate.positions,
318                    is_new: false,
319                })
320                .collect()
321            };
322            picker
323                .update(cx, |picker, _| {
324                    #[allow(clippy::nonminimal_bool)]
325                    if !query.is_empty()
326                        && !matches
327                            .first()
328                            .is_some_and(|entry| entry.branch.name() == query)
329                    {
330                        let query = query.replace(' ', "-");
331                        matches.push(BranchEntry {
332                            branch: Branch {
333                                ref_name: format!("refs/heads/{query}").into(),
334                                is_head: false,
335                                upstream: None,
336                                most_recent_commit: None,
337                            },
338                            positions: Vec::new(),
339                            is_new: true,
340                        })
341                    }
342                    let delegate = &mut picker.delegate;
343                    delegate.matches = matches;
344                    if delegate.matches.is_empty() {
345                        delegate.selected_index = 0;
346                    } else {
347                        delegate.selected_index =
348                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
349                    }
350                    delegate.last_query = query;
351                })
352                .log_err();
353        })
354    }
355
356    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
357        let Some(entry) = self.matches.get(self.selected_index()) else {
358            return;
359        };
360        if entry.is_new {
361            self.create_branch(entry.branch.name().to_owned().into(), window, cx);
362            return;
363        }
364
365        let current_branch = self.repo.as_ref().map(|repo| {
366            repo.read_with(cx, |repo, _| {
367                repo.branch.as_ref().map(|branch| branch.ref_name.clone())
368            })
369        });
370
371        if current_branch
372            .flatten()
373            .is_some_and(|current_branch| current_branch == entry.branch.ref_name)
374        {
375            cx.emit(DismissEvent);
376            return;
377        }
378
379        cx.spawn_in(window, {
380            let branch = entry.branch.clone();
381            async move |picker, cx| {
382                let branch_change_task = picker.update(cx, |this, cx| {
383                    let repo = this
384                        .delegate
385                        .repo
386                        .as_ref()
387                        .context("No active repository")?
388                        .clone();
389
390                    let mut cx = cx.to_async();
391
392                    anyhow::Ok(async move {
393                        repo.update(&mut cx, |repo, _| {
394                            repo.change_branch(branch.name().to_string())
395                        })?
396                        .await?
397                    })
398                })??;
399
400                branch_change_task.await?;
401
402                picker.update(cx, |_, cx| {
403                    cx.emit(DismissEvent);
404
405                    anyhow::Ok(())
406                })
407            }
408        })
409        .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
410    }
411
412    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
413        cx.emit(DismissEvent);
414    }
415
416    fn render_match(
417        &self,
418        ix: usize,
419        selected: bool,
420        _window: &mut Window,
421        cx: &mut Context<Picker<Self>>,
422    ) -> Option<Self::ListItem> {
423        let entry = &self.matches[ix];
424
425        let (commit_time, subject) = entry
426            .branch
427            .most_recent_commit
428            .as_ref()
429            .map(|commit| {
430                let subject = commit.subject.clone();
431                let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
432                    .unwrap_or_else(|_| OffsetDateTime::now_utc());
433                let formatted_time = format_local_timestamp(
434                    commit_time,
435                    OffsetDateTime::now_utc(),
436                    time_format::TimestampFormat::Relative,
437                );
438                (Some(formatted_time), Some(subject))
439            })
440            .unwrap_or_else(|| (None, None));
441
442        let branch_name = if entry.is_new {
443            h_flex()
444                .gap_1()
445                .child(
446                    Icon::new(IconName::Plus)
447                        .size(IconSize::Small)
448                        .color(Color::Muted),
449                )
450                .child(
451                    Label::new(format!("Create branch \"{}\"", entry.branch.name()))
452                        .single_line()
453                        .truncate(),
454                )
455                .into_any_element()
456        } else {
457            HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone())
458                .truncate()
459                .into_any_element()
460        };
461
462        Some(
463            ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
464                .inset(true)
465                .spacing(ListItemSpacing::Sparse)
466                .toggle_state(selected)
467                .child(
468                    v_flex()
469                        .w_full()
470                        .overflow_hidden()
471                        .child(
472                            h_flex()
473                                .gap_6()
474                                .justify_between()
475                                .overflow_x_hidden()
476                                .child(branch_name)
477                                .when_some(commit_time, |label, commit_time| {
478                                    label.child(
479                                        Label::new(commit_time)
480                                            .size(LabelSize::Small)
481                                            .color(Color::Muted)
482                                            .into_element(),
483                                    )
484                                }),
485                        )
486                        .when(self.style == BranchListStyle::Modal, |el| {
487                            el.child(div().max_w_96().child({
488                                let message = if entry.is_new {
489                                    if let Some(current_branch) =
490                                        self.repo.as_ref().and_then(|repo| {
491                                            repo.read(cx).branch.as_ref().map(|b| b.name())
492                                        })
493                                    {
494                                        format!("based off {}", current_branch)
495                                    } else {
496                                        "based off the current branch".to_string()
497                                    }
498                                } else {
499                                    subject.unwrap_or("no commits found".into()).to_string()
500                                };
501                                Label::new(message)
502                                    .size(LabelSize::Small)
503                                    .truncate()
504                                    .color(Color::Muted)
505                            }))
506                        }),
507                ),
508        )
509    }
510
511    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
512        None
513    }
514}