branch_picker.rs

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