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                .iter()
310                .cloned()
311                .map(|candidate| BranchEntry {
312                    branch: all_branches[candidate.candidate_id].clone(),
313                    positions: candidate.positions,
314                    is_new: false,
315                })
316                .collect()
317            };
318            picker
319                .update(cx, |picker, _| {
320                    #[allow(clippy::nonminimal_bool)]
321                    if !query.is_empty()
322                        && !matches
323                            .first()
324                            .is_some_and(|entry| entry.branch.name() == query)
325                    {
326                        matches.push(BranchEntry {
327                            branch: Branch {
328                                ref_name: format!("refs/heads/{query}").into(),
329                                is_head: false,
330                                upstream: None,
331                                most_recent_commit: None,
332                            },
333                            positions: Vec::new(),
334                            is_new: true,
335                        })
336                    }
337                    let delegate = &mut picker.delegate;
338                    delegate.matches = matches;
339                    if delegate.matches.is_empty() {
340                        delegate.selected_index = 0;
341                    } else {
342                        delegate.selected_index =
343                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
344                    }
345                    delegate.last_query = query;
346                })
347                .log_err();
348        })
349    }
350
351    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
352        let Some(entry) = self.matches.get(self.selected_index()) else {
353            return;
354        };
355        if entry.is_new {
356            self.create_branch(entry.branch.name().to_owned().into(), window, cx);
357            return;
358        }
359
360        let current_branch = self.repo.as_ref().map(|repo| {
361            repo.update(cx, |repo, _| {
362                repo.branch.as_ref().map(|branch| branch.ref_name.clone())
363            })
364        });
365
366        if current_branch
367            .flatten()
368            .is_some_and(|current_branch| current_branch == entry.branch.ref_name)
369        {
370            cx.emit(DismissEvent);
371            return;
372        }
373
374        cx.spawn_in(window, {
375            let branch = entry.branch.clone();
376            async move |picker, cx| {
377                let branch_change_task = picker.update(cx, |this, cx| {
378                    let repo = this
379                        .delegate
380                        .repo
381                        .as_ref()
382                        .ok_or_else(|| anyhow!("No active repository"))?
383                        .clone();
384
385                    let mut cx = cx.to_async();
386
387                    anyhow::Ok(async move {
388                        repo.update(&mut cx, |repo, _| {
389                            repo.change_branch(branch.name().to_string())
390                        })?
391                        .await?
392                    })
393                })??;
394
395                branch_change_task.await?;
396
397                picker.update(cx, |_, cx| {
398                    cx.emit(DismissEvent);
399
400                    anyhow::Ok(())
401                })
402            }
403        })
404        .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
405    }
406
407    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
408        cx.emit(DismissEvent);
409    }
410
411    fn render_header(&self, _: &mut Window, _cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
412        None
413    }
414
415    fn render_match(
416        &self,
417        ix: usize,
418        selected: bool,
419        _window: &mut Window,
420        cx: &mut Context<Picker<Self>>,
421    ) -> Option<Self::ListItem> {
422        let entry = &self.matches[ix];
423
424        let (commit_time, subject) = entry
425            .branch
426            .most_recent_commit
427            .as_ref()
428            .map(|commit| {
429                let subject = commit.subject.clone();
430                let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
431                    .unwrap_or_else(|_| OffsetDateTime::now_utc());
432                let formatted_time = format_local_timestamp(
433                    commit_time,
434                    OffsetDateTime::now_utc(),
435                    time_format::TimestampFormat::Relative,
436                );
437                (Some(formatted_time), Some(subject))
438            })
439            .unwrap_or_else(|| (None, None));
440
441        Some(
442            ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
443                .inset(true)
444                .spacing(match self.style {
445                    BranchListStyle::Modal => ListItemSpacing::default(),
446                    BranchListStyle::Popover => ListItemSpacing::ExtraDense,
447                })
448                .spacing(ListItemSpacing::Sparse)
449                .toggle_state(selected)
450                .child(
451                    v_flex()
452                        .w_full()
453                        .child(
454                            h_flex()
455                                .w_full()
456                                .flex_shrink()
457                                .overflow_x_hidden()
458                                .gap_2()
459                                .justify_between()
460                                .child(div().flex_shrink().overflow_x_hidden().child(
461                                    if entry.is_new {
462                                        Label::new(format!(
463                                            "Create branch \"{}\"",
464                                            entry.branch.name()
465                                        ))
466                                        .single_line()
467                                        .into_any_element()
468                                    } else {
469                                        HighlightedLabel::new(
470                                            entry.branch.name().to_owned(),
471                                            entry.positions.clone(),
472                                        )
473                                        .truncate()
474                                        .into_any_element()
475                                    },
476                                ))
477                                .when_some(commit_time, |el, commit_time| {
478                                    el.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}