branch_picker.rs

  1use anyhow::{Context as _, Result};
  2use fuzzy::{StringMatch, StringMatchCandidate};
  3
  4use git::repository::Branch;
  5use gpui::{
  6    rems, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  7    InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
  8    Task, Window,
  9};
 10use picker::{Picker, PickerDelegate};
 11use project::{Project, ProjectPath};
 12use std::sync::Arc;
 13use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenuHandle};
 14use util::ResultExt;
 15use workspace::notifications::DetachAndPromptErr;
 16use workspace::{ModalView, Workspace};
 17
 18pub fn init(cx: &mut App) {
 19    cx.observe_new(|workspace: &mut Workspace, _, _| {
 20        workspace.register_action(open);
 21    })
 22    .detach();
 23}
 24
 25pub fn open(
 26    workspace: &mut Workspace,
 27    _: &zed_actions::git::Branch,
 28    window: &mut Window,
 29    cx: &mut Context<Workspace>,
 30) {
 31    let project = workspace.project().clone();
 32    let this = cx.entity();
 33    let style = BranchListStyle::Modal;
 34    cx.spawn_in(window, |_, mut cx| async move {
 35        // Modal branch picker has a longer trailoff than a popover one.
 36        let delegate = BranchListDelegate::new(project.clone(), style, 70, &cx).await?;
 37
 38        this.update_in(&mut cx, move |workspace, window, cx| {
 39            workspace.toggle_modal(window, cx, |window, cx| {
 40                let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
 41                let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
 42                    cx.emit(DismissEvent);
 43                });
 44
 45                let mut list = BranchList::new(project, style, 34., cx);
 46                list._subscription = Some(_subscription);
 47                list.picker = Some(picker);
 48                list
 49            })
 50        })?;
 51
 52        Ok(())
 53    })
 54    .detach_and_prompt_err("Failed to read branches", window, cx, |_, _, _| None)
 55}
 56
 57pub fn popover(project: Entity<Project>, window: &mut Window, cx: &mut App) -> Entity<BranchList> {
 58    cx.new(|cx| {
 59        let mut list = BranchList::new(project, BranchListStyle::Popover, 15., cx);
 60        list.reload_branches(window, cx);
 61        list
 62    })
 63}
 64
 65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 66enum BranchListStyle {
 67    Modal,
 68    Popover,
 69}
 70
 71pub struct BranchList {
 72    rem_width: f32,
 73    popover_handle: PopoverMenuHandle<Self>,
 74    default_focus_handle: FocusHandle,
 75    project: Entity<Project>,
 76    style: BranchListStyle,
 77    pub picker: Option<Entity<Picker<BranchListDelegate>>>,
 78    _subscription: Option<Subscription>,
 79}
 80
 81impl popover_button::TriggerablePopover for BranchList {
 82    fn menu_handle(
 83        &mut self,
 84        _window: &mut Window,
 85        _cx: &mut gpui::Context<Self>,
 86    ) -> PopoverMenuHandle<Self> {
 87        self.popover_handle.clone()
 88    }
 89}
 90
 91impl BranchList {
 92    fn new(project: Entity<Project>, style: BranchListStyle, rem_width: f32, cx: &mut App) -> Self {
 93        let popover_handle = PopoverMenuHandle::default();
 94        Self {
 95            project,
 96            picker: None,
 97            rem_width,
 98            popover_handle,
 99            default_focus_handle: cx.focus_handle(),
100            style,
101            _subscription: None,
102        }
103    }
104
105    fn reload_branches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
106        let project = self.project.clone();
107        let style = self.style;
108        cx.spawn_in(window, |this, mut cx| async move {
109            let delegate = BranchListDelegate::new(project, style, 20, &cx).await?;
110            let picker =
111                cx.new_window_entity(|window, cx| Picker::uniform_list(delegate, window, cx))?;
112
113            this.update(&mut cx, |branch_list, cx| {
114                let subscription =
115                    cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| cx.emit(DismissEvent));
116
117                branch_list.picker = Some(picker);
118                branch_list._subscription = Some(subscription);
119
120                cx.notify();
121            })?;
122
123            anyhow::Ok(())
124        })
125        .detach_and_log_err(cx);
126    }
127}
128impl ModalView for BranchList {}
129impl EventEmitter<DismissEvent> for BranchList {}
130
131impl Focusable for BranchList {
132    fn focus_handle(&self, cx: &App) -> FocusHandle {
133        self.picker
134            .as_ref()
135            .map(|picker| picker.focus_handle(cx))
136            .unwrap_or_else(|| self.default_focus_handle.clone())
137    }
138}
139
140impl Render for BranchList {
141    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
142        v_flex()
143            .w(rems(self.rem_width))
144            .map(|parent| match self.picker.as_ref() {
145                Some(picker) => parent.child(picker.clone()).on_mouse_down_out({
146                    let picker = picker.clone();
147                    cx.listener(move |_, _, window, cx| {
148                        picker.update(cx, |this, cx| {
149                            this.cancel(&Default::default(), window, cx);
150                        })
151                    })
152                }),
153                None => parent.child(
154                    h_flex()
155                        .id("branch-picker-error")
156                        .on_click(
157                            cx.listener(|this, _, window, cx| this.reload_branches(window, cx)),
158                        )
159                        .child("Could not load branches.")
160                        .child("Click to retry"),
161                ),
162            })
163    }
164}
165
166#[derive(Debug, Clone)]
167enum BranchEntry {
168    Branch(StringMatch),
169    History(String),
170    NewBranch { name: String },
171}
172
173impl BranchEntry {
174    fn name(&self) -> &str {
175        match self {
176            Self::Branch(branch) => &branch.string,
177            Self::History(branch) => &branch,
178            Self::NewBranch { name } => &name,
179        }
180    }
181}
182
183pub struct BranchListDelegate {
184    matches: Vec<BranchEntry>,
185    all_branches: Vec<Branch>,
186    project: Entity<Project>,
187    style: BranchListStyle,
188    selected_index: usize,
189    last_query: String,
190    /// Max length of branch name before we truncate it and add a trailing `...`.
191    branch_name_trailoff_after: usize,
192}
193
194impl BranchListDelegate {
195    async fn new(
196        project: Entity<Project>,
197        style: BranchListStyle,
198        branch_name_trailoff_after: usize,
199        cx: &AsyncApp,
200    ) -> Result<Self> {
201        let all_branches_request = cx.update(|cx| {
202            let project = project.read(cx);
203            let first_worktree = project
204                .visible_worktrees(cx)
205                .next()
206                .context("No worktrees found")?;
207            let project_path = ProjectPath::root_path(first_worktree.read(cx).id());
208            anyhow::Ok(project.branches(project_path, cx))
209        })??;
210
211        let all_branches = all_branches_request.await?;
212
213        Ok(Self {
214            matches: vec![],
215            project,
216            style,
217            all_branches,
218            selected_index: 0,
219            last_query: Default::default(),
220            branch_name_trailoff_after,
221        })
222    }
223
224    pub fn branch_count(&self) -> usize {
225        self.matches
226            .iter()
227            .filter(|item| matches!(item, BranchEntry::Branch(_)))
228            .count()
229    }
230}
231
232impl PickerDelegate for BranchListDelegate {
233    type ListItem = ListItem;
234
235    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
236        "Select branch...".into()
237    }
238
239    fn match_count(&self) -> usize {
240        self.matches.len()
241    }
242
243    fn selected_index(&self) -> usize {
244        self.selected_index
245    }
246
247    fn set_selected_index(
248        &mut self,
249        ix: usize,
250        _window: &mut Window,
251        _: &mut Context<Picker<Self>>,
252    ) {
253        self.selected_index = ix;
254    }
255
256    fn update_matches(
257        &mut self,
258        query: String,
259        window: &mut Window,
260        cx: &mut Context<Picker<Self>>,
261    ) -> Task<()> {
262        cx.spawn_in(window, move |picker, mut cx| async move {
263            let candidates = picker.update(&mut cx, |picker, _| {
264                const RECENT_BRANCHES_COUNT: usize = 10;
265                let mut branches = picker.delegate.all_branches.clone();
266                if query.is_empty() {
267                    if branches.len() > RECENT_BRANCHES_COUNT {
268                        // Truncate list of recent branches
269                        // Do a partial sort to show recent-ish branches first.
270                        branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
271                            rhs.priority_key().cmp(&lhs.priority_key())
272                        });
273                        branches.truncate(RECENT_BRANCHES_COUNT);
274                    }
275                    branches.sort_unstable_by(|lhs, rhs| {
276                        rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
277                    });
278                }
279                branches
280                    .into_iter()
281                    .enumerate()
282                    .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
283                    .collect::<Vec<StringMatchCandidate>>()
284            });
285            let Some(candidates) = candidates.log_err() else {
286                return;
287            };
288            let matches: Vec<BranchEntry> = if query.is_empty() {
289                candidates
290                    .into_iter()
291                    .map(|candidate| BranchEntry::History(candidate.string))
292                    .collect()
293            } else {
294                fuzzy::match_strings(
295                    &candidates,
296                    &query,
297                    true,
298                    10000,
299                    &Default::default(),
300                    cx.background_executor().clone(),
301                )
302                .await
303                .iter()
304                .cloned()
305                .map(BranchEntry::Branch)
306                .collect()
307            };
308            picker
309                .update(&mut cx, |picker, _| {
310                    let delegate = &mut picker.delegate;
311                    delegate.matches = matches;
312                    if delegate.matches.is_empty() {
313                        if !query.is_empty() {
314                            delegate.matches.push(BranchEntry::NewBranch {
315                                name: query.trim().replace(' ', "-"),
316                            });
317                        }
318
319                        delegate.selected_index = 0;
320                    } else {
321                        delegate.selected_index =
322                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
323                    }
324                    delegate.last_query = query;
325                })
326                .log_err();
327        })
328    }
329
330    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
331        let Some(branch) = self.matches.get(self.selected_index()) else {
332            return;
333        };
334
335        let current_branch = self.project.update(cx, |project, cx| {
336            project
337                .active_repository(cx)
338                .and_then(|repo| repo.read(cx).current_branch())
339                .map(|branch| branch.name.to_string())
340        });
341
342        if current_branch == Some(branch.name().to_string()) {
343            cx.emit(DismissEvent);
344            return;
345        }
346
347        cx.spawn_in(window, {
348            let branch = branch.clone();
349            |picker, mut cx| async move {
350                let branch_change_task = picker.update(&mut cx, |this, cx| {
351                    let project = this.delegate.project.read(cx);
352                    let branch_to_checkout = match branch {
353                        BranchEntry::Branch(branch) => branch.string,
354                        BranchEntry::History(string) => string,
355                        BranchEntry::NewBranch { name: branch_name } => branch_name,
356                    };
357                    let worktree = project
358                        .visible_worktrees(cx)
359                        .next()
360                        .context("worktree disappeared")?;
361                    let repository = ProjectPath::root_path(worktree.read(cx).id());
362
363                    anyhow::Ok(project.update_or_create_branch(repository, branch_to_checkout, cx))
364                })??;
365
366                branch_change_task.await?;
367
368                picker.update(&mut cx, |_, cx| {
369                    cx.emit(DismissEvent);
370
371                    Ok::<(), anyhow::Error>(())
372                })
373            }
374        })
375        .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
376    }
377
378    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
379        cx.emit(DismissEvent);
380    }
381
382    fn render_match(
383        &self,
384        ix: usize,
385        selected: bool,
386        _window: &mut Window,
387        _cx: &mut Context<Picker<Self>>,
388    ) -> Option<Self::ListItem> {
389        let hit = &self.matches[ix];
390        let shortened_branch_name =
391            util::truncate_and_trailoff(&hit.name(), self.branch_name_trailoff_after);
392
393        Some(
394            ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
395                .inset(true)
396                .spacing(match self.style {
397                    BranchListStyle::Modal => ListItemSpacing::default(),
398                    BranchListStyle::Popover => ListItemSpacing::ExtraDense,
399                })
400                .spacing(ListItemSpacing::Sparse)
401                .toggle_state(selected)
402                .when(matches!(hit, BranchEntry::History(_)), |el| {
403                    el.end_slot(
404                        Icon::new(IconName::HistoryRerun)
405                            .color(Color::Muted)
406                            .size(IconSize::Small),
407                    )
408                })
409                .map(|el| match hit {
410                    BranchEntry::Branch(branch) => {
411                        let highlights: Vec<_> = branch
412                            .positions
413                            .iter()
414                            .filter(|index| index < &&self.branch_name_trailoff_after)
415                            .copied()
416                            .collect();
417
418                        el.child(HighlightedLabel::new(shortened_branch_name, highlights))
419                    }
420                    BranchEntry::History(_) => el.child(Label::new(shortened_branch_name)),
421                    BranchEntry::NewBranch { name } => {
422                        el.child(Label::new(format!("Create branch '{name}'")))
423                    }
424                }),
425        )
426    }
427}