branch_picker.rs

  1use anyhow::{anyhow, Context as _};
  2use fuzzy::{StringMatch, StringMatchCandidate};
  3
  4use git::repository::Branch;
  5use gpui::{
  6    rems, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  7    InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
  8    Task, Window,
  9};
 10use picker::{Picker, PickerDelegate};
 11use project::git::Repository;
 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        workspace.register_action(switch);
 22        workspace.register_action(checkout_branch);
 23    })
 24    .detach();
 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, 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, 15., 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    rem_width: f32,
 78    pub popover_handle: PopoverMenuHandle<Self>,
 79    pub picker: Entity<Picker<BranchListDelegate>>,
 80    _subscription: Subscription,
 81}
 82
 83impl BranchList {
 84    fn new(
 85        repository: Option<Entity<Repository>>,
 86        style: BranchListStyle,
 87        rem_width: f32,
 88        window: &mut Window,
 89        cx: &mut Context<Self>,
 90    ) -> Self {
 91        let popover_handle = PopoverMenuHandle::default();
 92        let all_branches_request = repository
 93            .clone()
 94            .map(|repository| repository.read(cx).branches());
 95
 96        cx.spawn_in(window, |this, mut cx| async move {
 97            let all_branches = all_branches_request
 98                .context("No active repository")?
 99                .await??;
100
101            this.update_in(&mut cx, |this, window, cx| {
102                this.picker.update(cx, |picker, cx| {
103                    picker.delegate.all_branches = Some(all_branches);
104                    picker.refresh(window, cx);
105                })
106            })?;
107
108            anyhow::Ok(())
109        })
110        .detach_and_log_err(cx);
111
112        let delegate = BranchListDelegate::new(repository.clone(), style, 20);
113        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
114
115        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
116            cx.emit(DismissEvent);
117        });
118
119        Self {
120            picker,
121            rem_width,
122            popover_handle,
123            _subscription,
124        }
125    }
126}
127impl ModalView for BranchList {}
128impl EventEmitter<DismissEvent> for BranchList {}
129
130impl Focusable for BranchList {
131    fn focus_handle(&self, cx: &App) -> FocusHandle {
132        self.picker.focus_handle(cx)
133    }
134}
135
136impl Render for BranchList {
137    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
138        v_flex()
139            .w(rems(self.rem_width))
140            .child(self.picker.clone())
141            .on_mouse_down_out({
142                cx.listener(move |this, _, window, cx| {
143                    this.picker.update(cx, |this, cx| {
144                        this.cancel(&Default::default(), window, cx);
145                    })
146                })
147            })
148    }
149}
150
151#[derive(Debug, Clone)]
152enum BranchEntry {
153    Branch(StringMatch),
154    History(String),
155    NewBranch { name: String },
156}
157
158impl BranchEntry {
159    fn name(&self) -> &str {
160        match self {
161            Self::Branch(branch) => &branch.string,
162            Self::History(branch) => &branch,
163            Self::NewBranch { name } => &name,
164        }
165    }
166}
167
168pub struct BranchListDelegate {
169    matches: Vec<BranchEntry>,
170    all_branches: Option<Vec<Branch>>,
171    repo: Option<Entity<Repository>>,
172    style: BranchListStyle,
173    selected_index: usize,
174    last_query: String,
175    /// Max length of branch name before we truncate it and add a trailing `...`.
176    branch_name_trailoff_after: usize,
177}
178
179impl BranchListDelegate {
180    fn new(
181        repo: Option<Entity<Repository>>,
182        style: BranchListStyle,
183        branch_name_trailoff_after: usize,
184    ) -> Self {
185        Self {
186            matches: vec![],
187            repo,
188            style,
189            all_branches: None,
190            selected_index: 0,
191            last_query: Default::default(),
192            branch_name_trailoff_after,
193        }
194    }
195
196    pub fn branch_count(&self) -> usize {
197        self.matches
198            .iter()
199            .filter(|item| matches!(item, BranchEntry::Branch(_)))
200            .count()
201    }
202}
203
204impl PickerDelegate for BranchListDelegate {
205    type ListItem = ListItem;
206
207    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
208        "Select branch...".into()
209    }
210
211    fn match_count(&self) -> usize {
212        self.matches.len()
213    }
214
215    fn selected_index(&self) -> usize {
216        self.selected_index
217    }
218
219    fn set_selected_index(
220        &mut self,
221        ix: usize,
222        _window: &mut Window,
223        _: &mut Context<Picker<Self>>,
224    ) {
225        self.selected_index = ix;
226    }
227
228    fn update_matches(
229        &mut self,
230        query: String,
231        window: &mut Window,
232        cx: &mut Context<Picker<Self>>,
233    ) -> Task<()> {
234        let Some(mut all_branches) = self.all_branches.clone() else {
235            return Task::ready(());
236        };
237
238        cx.spawn_in(window, move |picker, mut cx| async move {
239            const RECENT_BRANCHES_COUNT: usize = 10;
240            if query.is_empty() {
241                if all_branches.len() > RECENT_BRANCHES_COUNT {
242                    // Truncate list of recent branches
243                    // Do a partial sort to show recent-ish branches first.
244                    all_branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
245                        rhs.priority_key().cmp(&lhs.priority_key())
246                    });
247                    all_branches.truncate(RECENT_BRANCHES_COUNT);
248                }
249                all_branches.sort_unstable_by(|lhs, rhs| {
250                    rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
251                });
252            }
253
254            let candidates = all_branches
255                .into_iter()
256                .enumerate()
257                .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
258                .collect::<Vec<StringMatchCandidate>>();
259            let matches: Vec<BranchEntry> = if query.is_empty() {
260                candidates
261                    .into_iter()
262                    .map(|candidate| BranchEntry::History(candidate.string))
263                    .collect()
264            } else {
265                fuzzy::match_strings(
266                    &candidates,
267                    &query,
268                    true,
269                    10000,
270                    &Default::default(),
271                    cx.background_executor().clone(),
272                )
273                .await
274                .iter()
275                .cloned()
276                .map(BranchEntry::Branch)
277                .collect()
278            };
279            picker
280                .update(&mut cx, |picker, _| {
281                    let delegate = &mut picker.delegate;
282                    delegate.matches = matches;
283                    if delegate.matches.is_empty() {
284                        if !query.is_empty() {
285                            delegate.matches.push(BranchEntry::NewBranch {
286                                name: query.trim().replace(' ', "-"),
287                            });
288                        }
289
290                        delegate.selected_index = 0;
291                    } else {
292                        delegate.selected_index =
293                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
294                    }
295                    delegate.last_query = query;
296                })
297                .log_err();
298        })
299    }
300
301    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
302        let Some(branch) = self.matches.get(self.selected_index()) else {
303            return;
304        };
305
306        let current_branch = self.repo.as_ref().map(|repo| {
307            repo.update(cx, |repo, _| {
308                repo.current_branch().map(|branch| branch.name.clone())
309            })
310        });
311
312        if current_branch
313            .flatten()
314            .is_some_and(|current_branch| current_branch == branch.name())
315        {
316            cx.emit(DismissEvent);
317            return;
318        }
319
320        cx.spawn_in(window, {
321            let branch = branch.clone();
322            |picker, mut cx| async move {
323                let branch_change_task = picker.update(&mut cx, |this, cx| {
324                    let repo = this
325                        .delegate
326                        .repo
327                        .as_ref()
328                        .ok_or_else(|| anyhow!("No active repository"))?
329                        .clone();
330
331                    let cx = cx.to_async();
332
333                    anyhow::Ok(async move {
334                        match branch {
335                            BranchEntry::Branch(StringMatch {
336                                string: branch_name,
337                                ..
338                            })
339                            | BranchEntry::History(branch_name) => {
340                                cx.update(|cx| repo.read(cx).change_branch(branch_name))?
341                                    .await?
342                            }
343                            BranchEntry::NewBranch { name: branch_name } => {
344                                cx.update(|cx| repo.read(cx).create_branch(branch_name.clone()))?
345                                    .await??;
346                                cx.update(|cx| repo.read(cx).change_branch(branch_name))?
347                                    .await?
348                            }
349                        }
350                    })
351                })??;
352
353                branch_change_task.await?;
354
355                picker.update(&mut cx, |_, cx| {
356                    cx.emit(DismissEvent);
357
358                    anyhow::Ok(())
359                })
360            }
361        })
362        .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
363    }
364
365    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
366        cx.emit(DismissEvent);
367    }
368
369    fn render_match(
370        &self,
371        ix: usize,
372        selected: bool,
373        _window: &mut Window,
374        _cx: &mut Context<Picker<Self>>,
375    ) -> Option<Self::ListItem> {
376        let hit = &self.matches[ix];
377        let shortened_branch_name =
378            util::truncate_and_trailoff(&hit.name(), self.branch_name_trailoff_after);
379
380        Some(
381            ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
382                .inset(true)
383                .spacing(match self.style {
384                    BranchListStyle::Modal => ListItemSpacing::default(),
385                    BranchListStyle::Popover => ListItemSpacing::ExtraDense,
386                })
387                .spacing(ListItemSpacing::Sparse)
388                .toggle_state(selected)
389                .when(matches!(hit, BranchEntry::History(_)), |el| {
390                    el.end_slot(
391                        Icon::new(IconName::HistoryRerun)
392                            .color(Color::Muted)
393                            .size(IconSize::Small),
394                    )
395                })
396                .map(|el| match hit {
397                    BranchEntry::Branch(branch) => {
398                        let highlights: Vec<_> = branch
399                            .positions
400                            .iter()
401                            .filter(|index| index < &&self.branch_name_trailoff_after)
402                            .copied()
403                            .collect();
404
405                        el.child(HighlightedLabel::new(shortened_branch_name, highlights))
406                    }
407                    BranchEntry::History(_) => el.child(Label::new(shortened_branch_name)),
408                    BranchEntry::NewBranch { name } => {
409                        el.child(Label::new(format!("Create branch '{name}'")))
410                    }
411                }),
412        )
413    }
414}