branch_picker.rs

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