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.read(cx).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            cx.update(|cx| repo.read(cx).create_branch(new_branch_name.to_string()))?
206                .await??;
207            cx.update(|cx| repo.read(cx).change_branch(new_branch_name.to_string()))?
208                .await??;
209            Ok(())
210        })
211        .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
212            Some(e.to_string())
213        });
214        cx.emit(DismissEvent);
215    }
216}
217
218impl PickerDelegate for BranchListDelegate {
219    type ListItem = ListItem;
220
221    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
222        "Select branch...".into()
223    }
224
225    fn editor_position(&self) -> PickerEditorPosition {
226        match self.style {
227            BranchListStyle::Modal => PickerEditorPosition::Start,
228            BranchListStyle::Popover => PickerEditorPosition::End,
229        }
230    }
231
232    fn match_count(&self) -> usize {
233        self.matches.len()
234    }
235
236    fn selected_index(&self) -> usize {
237        self.selected_index
238    }
239
240    fn set_selected_index(
241        &mut self,
242        ix: usize,
243        _window: &mut Window,
244        _: &mut Context<Picker<Self>>,
245    ) {
246        self.selected_index = ix;
247    }
248
249    fn update_matches(
250        &mut self,
251        query: String,
252        window: &mut Window,
253        cx: &mut Context<Picker<Self>>,
254    ) -> Task<()> {
255        let Some(all_branches) = self.all_branches.clone() else {
256            return Task::ready(());
257        };
258
259        const RECENT_BRANCHES_COUNT: usize = 10;
260        cx.spawn_in(window, async move |picker, cx| {
261            let mut matches: Vec<BranchEntry> = if query.is_empty() {
262                all_branches
263                    .into_iter()
264                    .take(RECENT_BRANCHES_COUNT)
265                    .map(|branch| BranchEntry {
266                        branch,
267                        positions: Vec::new(),
268                        is_new: false,
269                    })
270                    .collect()
271            } else {
272                let candidates = all_branches
273                    .iter()
274                    .enumerate()
275                    .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name.clone()))
276                    .collect::<Vec<StringMatchCandidate>>();
277                fuzzy::match_strings(
278                    &candidates,
279                    &query,
280                    true,
281                    10000,
282                    &Default::default(),
283                    cx.background_executor().clone(),
284                )
285                .await
286                .iter()
287                .cloned()
288                .map(|candidate| BranchEntry {
289                    branch: all_branches[candidate.candidate_id].clone(),
290                    positions: candidate.positions,
291                    is_new: false,
292                })
293                .collect()
294            };
295            picker
296                .update(cx, |picker, _| {
297                    #[allow(clippy::nonminimal_bool)]
298                    if !query.is_empty()
299                        && !matches
300                            .first()
301                            .is_some_and(|entry| entry.branch.name == query)
302                    {
303                        matches.push(BranchEntry {
304                            branch: Branch {
305                                name: query.clone().into(),
306                                is_head: false,
307                                upstream: None,
308                                most_recent_commit: None,
309                            },
310                            positions: Vec::new(),
311                            is_new: true,
312                        })
313                    }
314                    let delegate = &mut picker.delegate;
315                    delegate.matches = matches;
316                    if delegate.matches.is_empty() {
317                        delegate.selected_index = 0;
318                    } else {
319                        delegate.selected_index =
320                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
321                    }
322                    delegate.last_query = query;
323                })
324                .log_err();
325        })
326    }
327
328    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
329        let Some(entry) = self.matches.get(self.selected_index()) else {
330            return;
331        };
332        if entry.is_new {
333            self.create_branch(entry.branch.name.clone(), window, cx);
334            return;
335        }
336
337        let current_branch = self.repo.as_ref().map(|repo| {
338            repo.update(cx, |repo, _| {
339                repo.branch.as_ref().map(|branch| branch.name.clone())
340            })
341        });
342
343        if current_branch
344            .flatten()
345            .is_some_and(|current_branch| current_branch == entry.branch.name)
346        {
347            cx.emit(DismissEvent);
348            return;
349        }
350
351        cx.spawn_in(window, {
352            let branch = entry.branch.clone();
353            async move |picker, cx| {
354                let branch_change_task = picker.update(cx, |this, cx| {
355                    let repo = this
356                        .delegate
357                        .repo
358                        .as_ref()
359                        .ok_or_else(|| anyhow!("No active repository"))?
360                        .clone();
361
362                    let cx = cx.to_async();
363
364                    anyhow::Ok(async move {
365                        cx.update(|cx| repo.read(cx).change_branch(branch.name.to_string()))?
366                            .await?
367                    })
368                })??;
369
370                branch_change_task.await?;
371
372                picker.update(cx, |_, cx| {
373                    cx.emit(DismissEvent);
374
375                    anyhow::Ok(())
376                })
377            }
378        })
379        .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
380    }
381
382    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
383        cx.emit(DismissEvent);
384    }
385
386    fn render_header(&self, _: &mut Window, _cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
387        None
388    }
389
390    fn render_match(
391        &self,
392        ix: usize,
393        selected: bool,
394        _window: &mut Window,
395        cx: &mut Context<Picker<Self>>,
396    ) -> Option<Self::ListItem> {
397        let entry = &self.matches[ix];
398
399        let (commit_time, subject) = entry
400            .branch
401            .most_recent_commit
402            .as_ref()
403            .map(|commit| {
404                let subject = commit.subject.clone();
405                let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
406                    .unwrap_or_else(|_| OffsetDateTime::now_utc());
407                let formatted_time = format_local_timestamp(
408                    commit_time,
409                    OffsetDateTime::now_utc(),
410                    time_format::TimestampFormat::Relative,
411                );
412                (Some(formatted_time), Some(subject))
413            })
414            .unwrap_or_else(|| (None, None));
415
416        Some(
417            ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
418                .inset(true)
419                .spacing(match self.style {
420                    BranchListStyle::Modal => ListItemSpacing::default(),
421                    BranchListStyle::Popover => ListItemSpacing::ExtraDense,
422                })
423                .spacing(ListItemSpacing::Sparse)
424                .toggle_state(selected)
425                .child(
426                    v_flex()
427                        .w_full()
428                        .child(
429                            h_flex()
430                                .w_full()
431                                .flex_shrink()
432                                .overflow_x_hidden()
433                                .gap_2()
434                                .justify_between()
435                                .child(div().flex_shrink().overflow_x_hidden().child(
436                                    if entry.is_new {
437                                        Label::new(format!(
438                                            "Create branch \"{}\"",
439                                            entry.branch.name
440                                        ))
441                                        .single_line()
442                                        .into_any_element()
443                                    } else {
444                                        HighlightedLabel::new(
445                                            entry.branch.name.clone(),
446                                            entry.positions.clone(),
447                                        )
448                                        .truncate()
449                                        .into_any_element()
450                                    },
451                                ))
452                                .when_some(commit_time, |el, commit_time| {
453                                    el.child(
454                                        Label::new(commit_time)
455                                            .size(LabelSize::Small)
456                                            .color(Color::Muted)
457                                            .into_element(),
458                                    )
459                                }),
460                        )
461                        .when(self.style == BranchListStyle::Modal, |el| {
462                            el.child(div().max_w_96().child({
463                                let message = if entry.is_new {
464                                    if let Some(current_branch) =
465                                        self.repo.as_ref().and_then(|repo| {
466                                            repo.read(cx).branch.as_ref().map(|b| b.name.clone())
467                                        })
468                                    {
469                                        format!("based off {}", current_branch)
470                                    } else {
471                                        "based off the current branch".to_string()
472                                    }
473                                } else {
474                                    subject.unwrap_or("no commits found".into()).to_string()
475                                };
476                                Label::new(message)
477                                    .size(LabelSize::Small)
478                                    .truncate()
479                                    .color(Color::Muted)
480                            }))
481                        }),
482                ),
483        )
484    }
485
486    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
487        None
488    }
489}