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