branch_picker.rs

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