branch_picker.rs

  1use anyhow::{anyhow, Context as _};
  2use fuzzy::StringMatchCandidate;
  3
  4use git::repository::Branch;
  5use gpui::{
  6    rems, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  7    InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
  8    SharedString, Styled, Subscription, Task, Window,
  9};
 10use picker::{Picker, PickerDelegate};
 11use project::git::Repository;
 12use std::sync::Arc;
 13use time::OffsetDateTime;
 14use time_format::format_local_timestamp;
 15use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
 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        workspace.register_action(switch);
 24        workspace.register_action(checkout_branch);
 25    })
 26    .detach();
 27}
 28
 29pub fn checkout_branch(
 30    workspace: &mut Workspace,
 31    _: &zed_actions::git::CheckoutBranch,
 32    window: &mut Window,
 33    cx: &mut Context<Workspace>,
 34) {
 35    open(workspace, &zed_actions::git::Branch, window, cx);
 36}
 37
 38pub fn switch(
 39    workspace: &mut Workspace,
 40    _: &zed_actions::git::Switch,
 41    window: &mut Window,
 42    cx: &mut Context<Workspace>,
 43) {
 44    open(workspace, &zed_actions::git::Branch, window, cx);
 45}
 46
 47pub fn open(
 48    workspace: &mut Workspace,
 49    _: &zed_actions::git::Branch,
 50    window: &mut Window,
 51    cx: &mut Context<Workspace>,
 52) {
 53    let repository = workspace.project().read(cx).active_repository(cx).clone();
 54    let style = BranchListStyle::Modal;
 55    workspace.toggle_modal(window, cx, |window, cx| {
 56        BranchList::new(repository, style, rems(34.), window, cx)
 57    })
 58}
 59
 60pub fn popover(
 61    repository: Option<Entity<Repository>>,
 62    window: &mut Window,
 63    cx: &mut App,
 64) -> Entity<BranchList> {
 65    cx.new(|cx| {
 66        let list = BranchList::new(repository, BranchListStyle::Popover, rems(20.), window, cx);
 67        list.focus_handle(cx).focus(window);
 68        list
 69    })
 70}
 71
 72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 73enum BranchListStyle {
 74    Modal,
 75    Popover,
 76}
 77
 78pub struct BranchList {
 79    width: Rems,
 80    pub picker: Entity<Picker<BranchListDelegate>>,
 81    _subscription: Subscription,
 82}
 83
 84impl BranchList {
 85    fn new(
 86        repository: Option<Entity<Repository>>,
 87        style: BranchListStyle,
 88        width: Rems,
 89        window: &mut Window,
 90        cx: &mut Context<Self>,
 91    ) -> Self {
 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 mut all_branches = all_branches_request
 98                .context("No active repository")?
 99                .await??;
100
101            all_branches.sort_by_key(|branch| {
102                branch
103                    .most_recent_commit
104                    .as_ref()
105                    .map(|commit| 0 - commit.commit_timestamp)
106            });
107
108            this.update_in(&mut cx, |this, window, cx| {
109                this.picker.update(cx, |picker, cx| {
110                    picker.delegate.all_branches = Some(all_branches);
111                    picker.refresh(window, cx);
112                })
113            })?;
114
115            anyhow::Ok(())
116        })
117        .detach_and_log_err(cx);
118
119        let delegate = BranchListDelegate::new(repository.clone(), style);
120        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
121
122        let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
123            cx.emit(DismissEvent);
124        });
125
126        Self {
127            picker,
128            width,
129            _subscription,
130        }
131    }
132
133    fn handle_modifiers_changed(
134        &mut self,
135        ev: &ModifiersChangedEvent,
136        _: &mut Window,
137        cx: &mut Context<Self>,
138    ) {
139        self.picker
140            .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
141    }
142}
143impl ModalView for BranchList {}
144impl EventEmitter<DismissEvent> for BranchList {}
145
146impl Focusable for BranchList {
147    fn focus_handle(&self, cx: &App) -> FocusHandle {
148        self.picker.focus_handle(cx)
149    }
150}
151
152impl Render for BranchList {
153    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
154        v_flex()
155            .w(self.width)
156            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
157            .child(self.picker.clone())
158            .on_mouse_down_out({
159                cx.listener(move |this, _, window, cx| {
160                    this.picker.update(cx, |this, cx| {
161                        this.cancel(&Default::default(), window, cx);
162                    })
163                })
164            })
165    }
166}
167
168#[derive(Debug, Clone)]
169struct BranchEntry {
170    branch: Branch,
171    positions: Vec<usize>,
172    is_new: bool,
173}
174
175pub struct BranchListDelegate {
176    matches: Vec<BranchEntry>,
177    all_branches: Option<Vec<Branch>>,
178    repo: Option<Entity<Repository>>,
179    style: BranchListStyle,
180    selected_index: usize,
181    last_query: String,
182    modifiers: Modifiers,
183}
184
185impl BranchListDelegate {
186    fn new(repo: Option<Entity<Repository>>, style: BranchListStyle) -> Self {
187        Self {
188            matches: vec![],
189            repo,
190            style,
191            all_branches: None,
192            selected_index: 0,
193            last_query: Default::default(),
194            modifiers: Default::default(),
195        }
196    }
197
198    fn create_branch(
199        &self,
200        new_branch_name: SharedString,
201        window: &mut Window,
202        cx: &mut Context<Picker<Self>>,
203    ) {
204        let Some(repo) = self.repo.clone() else {
205            return;
206        };
207        cx.spawn(|_, cx| async move {
208            cx.update(|cx| repo.read(cx).create_branch(new_branch_name.to_string()))?
209                .await??;
210            cx.update(|cx| repo.read(cx).change_branch(new_branch_name.to_string()))?
211                .await??;
212            Ok(())
213        })
214        .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
215            Some(e.to_string())
216        });
217        cx.emit(DismissEvent);
218    }
219}
220
221impl PickerDelegate for BranchListDelegate {
222    type ListItem = ListItem;
223
224    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
225        "Select branch...".into()
226    }
227
228    fn match_count(&self) -> usize {
229        self.matches.len()
230    }
231
232    fn selected_index(&self) -> usize {
233        self.selected_index
234    }
235
236    fn set_selected_index(
237        &mut self,
238        ix: usize,
239        _window: &mut Window,
240        _: &mut Context<Picker<Self>>,
241    ) {
242        self.selected_index = ix;
243    }
244
245    fn update_matches(
246        &mut self,
247        query: String,
248        window: &mut Window,
249        cx: &mut Context<Picker<Self>>,
250    ) -> Task<()> {
251        let Some(all_branches) = self.all_branches.clone() else {
252            return Task::ready(());
253        };
254
255        const RECENT_BRANCHES_COUNT: usize = 10;
256        cx.spawn_in(window, move |picker, mut cx| async move {
257            let mut matches: Vec<BranchEntry> = if query.is_empty() {
258                all_branches
259                    .into_iter()
260                    .take(RECENT_BRANCHES_COUNT)
261                    .map(|branch| BranchEntry {
262                        branch,
263                        positions: Vec::new(),
264                        is_new: false,
265                    })
266                    .collect()
267            } else {
268                let candidates = all_branches
269                    .iter()
270                    .enumerate()
271                    .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name.clone()))
272                    .collect::<Vec<StringMatchCandidate>>();
273                fuzzy::match_strings(
274                    &candidates,
275                    &query,
276                    true,
277                    10000,
278                    &Default::default(),
279                    cx.background_executor().clone(),
280                )
281                .await
282                .iter()
283                .cloned()
284                .map(|candidate| BranchEntry {
285                    branch: all_branches[candidate.candidate_id].clone(),
286                    positions: candidate.positions,
287                    is_new: false,
288                })
289                .collect()
290            };
291            picker
292                .update(&mut cx, |picker, _| {
293                    #[allow(clippy::nonminimal_bool)]
294                    if !query.is_empty()
295                        && !matches
296                            .first()
297                            .is_some_and(|entry| entry.branch.name == query)
298                    {
299                        matches.push(BranchEntry {
300                            branch: Branch {
301                                name: query.clone().into(),
302                                is_head: false,
303                                upstream: None,
304                                most_recent_commit: None,
305                            },
306                            positions: Vec::new(),
307                            is_new: true,
308                        })
309                    }
310                    let delegate = &mut picker.delegate;
311                    delegate.matches = matches;
312                    if delegate.matches.is_empty() {
313                        delegate.selected_index = 0;
314                    } else {
315                        delegate.selected_index =
316                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
317                    }
318                    delegate.last_query = query;
319                })
320                .log_err();
321        })
322    }
323
324    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
325        let Some(entry) = self.matches.get(self.selected_index()) else {
326            return;
327        };
328        if entry.is_new {
329            self.create_branch(entry.branch.name.clone(), window, cx);
330            return;
331        }
332
333        let current_branch = self.repo.as_ref().map(|repo| {
334            repo.update(cx, |repo, _| {
335                repo.current_branch().map(|branch| branch.name.clone())
336            })
337        });
338
339        if current_branch
340            .flatten()
341            .is_some_and(|current_branch| current_branch == entry.branch.name)
342        {
343            cx.emit(DismissEvent);
344            return;
345        }
346
347        cx.spawn_in(window, {
348            let branch = entry.branch.clone();
349            |picker, mut cx| async move {
350                let branch_change_task = picker.update(&mut cx, |this, cx| {
351                    let repo = this
352                        .delegate
353                        .repo
354                        .as_ref()
355                        .ok_or_else(|| anyhow!("No active repository"))?
356                        .clone();
357
358                    let cx = cx.to_async();
359
360                    anyhow::Ok(async move {
361                        cx.update(|cx| repo.read(cx).change_branch(branch.name.to_string()))?
362                            .await?
363                    })
364                })??;
365
366                branch_change_task.await?;
367
368                picker.update(&mut cx, |_, cx| {
369                    cx.emit(DismissEvent);
370
371                    anyhow::Ok(())
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_header(&self, _: &mut Window, _cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
383        None
384    }
385
386    fn render_match(
387        &self,
388        ix: usize,
389        selected: bool,
390        _window: &mut Window,
391        cx: &mut Context<Picker<Self>>,
392    ) -> Option<Self::ListItem> {
393        let entry = &self.matches[ix];
394
395        let (commit_time, subject) = entry
396            .branch
397            .most_recent_commit
398            .as_ref()
399            .map(|commit| {
400                let subject = commit.subject.clone();
401                let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
402                    .unwrap_or_else(|_| OffsetDateTime::now_utc());
403                let formatted_time = format_local_timestamp(
404                    commit_time,
405                    OffsetDateTime::now_utc(),
406                    time_format::TimestampFormat::Relative,
407                );
408                (Some(formatted_time), Some(subject))
409            })
410            .unwrap_or_else(|| (None, None));
411
412        Some(
413            ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
414                .inset(true)
415                .spacing(match self.style {
416                    BranchListStyle::Modal => ListItemSpacing::default(),
417                    BranchListStyle::Popover => ListItemSpacing::ExtraDense,
418                })
419                .spacing(ListItemSpacing::Sparse)
420                .toggle_state(selected)
421                .child(
422                    v_flex()
423                        .w_full()
424                        .child(
425                            h_flex()
426                                .w_full()
427                                .flex_shrink()
428                                .overflow_x_hidden()
429                                .gap_2()
430                                .justify_between()
431                                .child(div().flex_shrink().overflow_x_hidden().child(
432                                    if entry.is_new {
433                                        Label::new(format!(
434                                            "Create branch \"{}\"",
435                                            entry.branch.name
436                                        ))
437                                        .single_line()
438                                        .into_any_element()
439                                    } else {
440                                        HighlightedLabel::new(
441                                            entry.branch.name.clone(),
442                                            entry.positions.clone(),
443                                        )
444                                        .truncate()
445                                        .into_any_element()
446                                    },
447                                ))
448                                .when_some(commit_time, |el, commit_time| {
449                                    el.child(
450                                        Label::new(commit_time)
451                                            .size(LabelSize::Small)
452                                            .color(Color::Muted)
453                                            .into_element(),
454                                    )
455                                }),
456                        )
457                        .when(self.style == BranchListStyle::Modal, |el| {
458                            el.child(div().max_w_96().child({
459                                let message = if entry.is_new {
460                                    if let Some(current_branch) =
461                                        self.repo.as_ref().and_then(|repo| {
462                                            repo.read(cx).current_branch().map(|b| b.name.clone())
463                                        })
464                                    {
465                                        format!("based off {}", current_branch)
466                                    } else {
467                                        "based off the current branch".to_string()
468                                    }
469                                } else {
470                                    subject.unwrap_or("no commits found".into()).to_string()
471                                };
472                                Label::new(message)
473                                    .size(LabelSize::Small)
474                                    .truncate()
475                                    .color(Color::Muted)
476                            }))
477                        }),
478                ),
479        )
480    }
481
482    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
483        None
484    }
485}