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