1use anyhow::Context as _;
2use fuzzy::StringMatchCandidate;
3
4use collections::HashSet;
5use git::repository::Branch;
6use gpui::{
7 Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
8 InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
9 SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems,
10};
11use picker::{Picker, PickerDelegate, PickerEditorPosition};
12use project::git_store::Repository;
13use project::project_settings::ProjectSettings;
14use settings::Settings;
15use std::sync::Arc;
16use time::OffsetDateTime;
17use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
18use util::ResultExt;
19use workspace::notifications::DetachAndPromptErr;
20use workspace::{ModalView, Workspace};
21
22use crate::{branch_picker, git_panel::show_error_toast};
23
24actions!(
25 branch_picker,
26 [
27 /// Deletes the selected git branch.
28 DeleteBranch
29 ]
30);
31
32pub fn register(workspace: &mut Workspace) {
33 workspace.register_action(|workspace, branch: &zed_actions::git::Branch, window, cx| {
34 open(workspace, branch, window, cx);
35 });
36 workspace.register_action(switch);
37 workspace.register_action(checkout_branch);
38}
39
40pub fn checkout_branch(
41 workspace: &mut Workspace,
42 _: &zed_actions::git::CheckoutBranch,
43 window: &mut Window,
44 cx: &mut Context<Workspace>,
45) {
46 open(workspace, &zed_actions::git::Branch, window, cx);
47}
48
49pub fn switch(
50 workspace: &mut Workspace,
51 _: &zed_actions::git::Switch,
52 window: &mut Window,
53 cx: &mut Context<Workspace>,
54) {
55 open(workspace, &zed_actions::git::Branch, window, cx);
56}
57
58pub fn open(
59 workspace: &mut Workspace,
60 _: &zed_actions::git::Branch,
61 window: &mut Window,
62 cx: &mut Context<Workspace>,
63) {
64 let workspace_handle = workspace.weak_handle();
65 let repository = workspace.project().read(cx).active_repository(cx);
66 let style = BranchListStyle::Modal;
67 workspace.toggle_modal(window, cx, |window, cx| {
68 BranchList::new(
69 Some(workspace_handle),
70 repository,
71 style,
72 rems(34.),
73 window,
74 cx,
75 )
76 })
77}
78
79pub fn popover(
80 repository: Option<Entity<Repository>>,
81 window: &mut Window,
82 cx: &mut App,
83) -> Entity<BranchList> {
84 cx.new(|cx| {
85 let list = BranchList::new(
86 None,
87 repository,
88 BranchListStyle::Popover,
89 rems(20.),
90 window,
91 cx,
92 );
93 list.focus_handle(cx).focus(window);
94 list
95 })
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
99enum BranchListStyle {
100 Modal,
101 Popover,
102}
103
104pub struct BranchList {
105 width: Rems,
106 pub picker: Entity<Picker<BranchListDelegate>>,
107 picker_focus_handle: FocusHandle,
108 _subscription: Subscription,
109}
110
111impl BranchList {
112 fn new(
113 workspace: Option<WeakEntity<Workspace>>,
114 repository: Option<Entity<Repository>>,
115 style: BranchListStyle,
116 width: Rems,
117 window: &mut Window,
118 cx: &mut Context<Self>,
119 ) -> Self {
120 let all_branches_request = repository
121 .clone()
122 .map(|repository| repository.update(cx, |repository, _| repository.branches()));
123 let default_branch_request = repository
124 .clone()
125 .map(|repository| repository.update(cx, |repository, _| repository.default_branch()));
126
127 cx.spawn_in(window, async move |this, cx| {
128 let mut all_branches = all_branches_request
129 .context("No active repository")?
130 .await??;
131 let default_branch = default_branch_request
132 .context("No active repository")?
133 .await
134 .map(Result::ok)
135 .ok()
136 .flatten()
137 .flatten();
138
139 let all_branches = cx
140 .background_spawn(async move {
141 let remote_upstreams: HashSet<_> = all_branches
142 .iter()
143 .filter_map(|branch| {
144 branch
145 .upstream
146 .as_ref()
147 .filter(|upstream| upstream.is_remote())
148 .map(|upstream| upstream.ref_name.clone())
149 })
150 .collect();
151
152 all_branches.retain(|branch| !remote_upstreams.contains(&branch.ref_name));
153
154 all_branches.sort_by_key(|branch| {
155 (
156 !branch.is_head, // Current branch (is_head=true) comes first
157 branch
158 .most_recent_commit
159 .as_ref()
160 .map(|commit| 0 - commit.commit_timestamp),
161 )
162 });
163
164 all_branches
165 })
166 .await;
167
168 let _ = this.update_in(cx, |this, window, cx| {
169 this.picker.update(cx, |picker, cx| {
170 picker.delegate.default_branch = default_branch;
171 picker.delegate.all_branches = Some(all_branches);
172 picker.refresh(window, cx);
173 })
174 });
175
176 anyhow::Ok(())
177 })
178 .detach_and_log_err(cx);
179
180 let delegate = BranchListDelegate::new(workspace, repository, style, cx);
181 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
182 let picker_focus_handle = picker.focus_handle(cx);
183 picker.update(cx, |picker, _| {
184 picker.delegate.focus_handle = picker_focus_handle.clone();
185 });
186
187 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
188 cx.emit(DismissEvent);
189 });
190
191 Self {
192 picker,
193 picker_focus_handle,
194 width,
195 _subscription,
196 }
197 }
198
199 fn handle_modifiers_changed(
200 &mut self,
201 ev: &ModifiersChangedEvent,
202 _: &mut Window,
203 cx: &mut Context<Self>,
204 ) {
205 self.picker
206 .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
207 }
208
209 fn handle_delete_branch(
210 &mut self,
211 _: &branch_picker::DeleteBranch,
212 window: &mut Window,
213 cx: &mut Context<Self>,
214 ) {
215 self.picker.update(cx, |picker, cx| {
216 picker
217 .delegate
218 .delete_branch_at(picker.delegate.selected_index, window, cx)
219 })
220 }
221}
222impl ModalView for BranchList {}
223impl EventEmitter<DismissEvent> for BranchList {}
224
225impl Focusable for BranchList {
226 fn focus_handle(&self, _cx: &App) -> FocusHandle {
227 self.picker_focus_handle.clone()
228 }
229}
230
231impl Render for BranchList {
232 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
233 v_flex()
234 .key_context("GitBranchSelector")
235 .w(self.width)
236 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
237 .on_action(cx.listener(Self::handle_delete_branch))
238 .child(self.picker.clone())
239 .on_mouse_down_out({
240 cx.listener(move |this, _, window, cx| {
241 this.picker.update(cx, |this, cx| {
242 this.cancel(&Default::default(), window, cx);
243 })
244 })
245 })
246 }
247}
248
249#[derive(Debug, Clone)]
250struct BranchEntry {
251 branch: Branch,
252 positions: Vec<usize>,
253 is_new: bool,
254}
255
256pub struct BranchListDelegate {
257 workspace: Option<WeakEntity<Workspace>>,
258 matches: Vec<BranchEntry>,
259 all_branches: Option<Vec<Branch>>,
260 default_branch: Option<SharedString>,
261 repo: Option<Entity<Repository>>,
262 style: BranchListStyle,
263 selected_index: usize,
264 last_query: String,
265 modifiers: Modifiers,
266 focus_handle: FocusHandle,
267}
268
269impl BranchListDelegate {
270 fn new(
271 workspace: Option<WeakEntity<Workspace>>,
272 repo: Option<Entity<Repository>>,
273 style: BranchListStyle,
274 cx: &mut Context<BranchList>,
275 ) -> Self {
276 Self {
277 workspace,
278 matches: vec![],
279 repo,
280 style,
281 all_branches: None,
282 default_branch: None,
283 selected_index: 0,
284 last_query: Default::default(),
285 modifiers: Default::default(),
286 focus_handle: cx.focus_handle(),
287 }
288 }
289
290 fn create_branch(
291 &self,
292 from_branch: Option<SharedString>,
293 new_branch_name: SharedString,
294 window: &mut Window,
295 cx: &mut Context<Picker<Self>>,
296 ) {
297 let Some(repo) = self.repo.clone() else {
298 return;
299 };
300 let new_branch_name = new_branch_name.to_string().replace(' ', "-");
301 let base_branch = from_branch.map(|b| b.to_string());
302 cx.spawn(async move |_, cx| {
303 repo.update(cx, |repo, _| {
304 repo.create_branch(new_branch_name, base_branch)
305 })?
306 .await??;
307
308 Ok(())
309 })
310 .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
311 Some(e.to_string())
312 });
313 cx.emit(DismissEvent);
314 }
315
316 fn delete_branch_at(&self, idx: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
317 let Some(branch_entry) = self.matches.get(idx) else {
318 return;
319 };
320 let Some(repo) = self.repo.clone() else {
321 return;
322 };
323
324 let workspace = self.workspace.clone();
325 let branch_name = branch_entry.branch.name().to_string();
326 let branch_ref = branch_entry.branch.ref_name.clone();
327
328 cx.spawn_in(window, async move |picker, cx| {
329 let result = repo
330 .update(cx, |repo, _| repo.delete_branch(branch_name.clone()))?
331 .await?;
332
333 if let Err(e) = result {
334 log::error!("Failed to delete branch: {}", e);
335
336 if let Some(workspace) = workspace.and_then(|w| w.upgrade()) {
337 cx.update(|_window, cx| {
338 show_error_toast(workspace, format!("branch -d {branch_name}"), e, cx)
339 })?;
340 }
341
342 return Ok(());
343 }
344
345 picker.update_in(cx, |picker, _, cx| {
346 picker
347 .delegate
348 .matches
349 .retain(|entry| entry.branch.ref_name != branch_ref);
350
351 if let Some(all_branches) = &mut picker.delegate.all_branches {
352 all_branches.retain(|branch| branch.ref_name != branch_ref);
353 }
354
355 if picker.delegate.matches.is_empty() {
356 picker.delegate.selected_index = 0;
357 } else if picker.delegate.selected_index >= picker.delegate.matches.len() {
358 picker.delegate.selected_index = picker.delegate.matches.len() - 1;
359 }
360
361 cx.notify();
362 })?;
363
364 anyhow::Ok(())
365 })
366 .detach();
367 }
368}
369
370impl PickerDelegate for BranchListDelegate {
371 type ListItem = ListItem;
372
373 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
374 "Select branch…".into()
375 }
376
377 fn editor_position(&self) -> PickerEditorPosition {
378 match self.style {
379 BranchListStyle::Modal => PickerEditorPosition::Start,
380 BranchListStyle::Popover => PickerEditorPosition::End,
381 }
382 }
383
384 fn match_count(&self) -> usize {
385 self.matches.len()
386 }
387
388 fn selected_index(&self) -> usize {
389 self.selected_index
390 }
391
392 fn set_selected_index(
393 &mut self,
394 ix: usize,
395 _window: &mut Window,
396 _: &mut Context<Picker<Self>>,
397 ) {
398 self.selected_index = ix;
399 }
400
401 fn update_matches(
402 &mut self,
403 query: String,
404 window: &mut Window,
405 cx: &mut Context<Picker<Self>>,
406 ) -> Task<()> {
407 let Some(all_branches) = self.all_branches.clone() else {
408 return Task::ready(());
409 };
410
411 const RECENT_BRANCHES_COUNT: usize = 10;
412 cx.spawn_in(window, async move |picker, cx| {
413 let mut matches: Vec<BranchEntry> = if query.is_empty() {
414 all_branches
415 .into_iter()
416 .filter(|branch| !branch.is_remote())
417 .take(RECENT_BRANCHES_COUNT)
418 .map(|branch| BranchEntry {
419 branch,
420 positions: Vec::new(),
421 is_new: false,
422 })
423 .collect()
424 } else {
425 let candidates = all_branches
426 .iter()
427 .enumerate()
428 .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
429 .collect::<Vec<StringMatchCandidate>>();
430 fuzzy::match_strings(
431 &candidates,
432 &query,
433 true,
434 true,
435 10000,
436 &Default::default(),
437 cx.background_executor().clone(),
438 )
439 .await
440 .into_iter()
441 .map(|candidate| BranchEntry {
442 branch: all_branches[candidate.candidate_id].clone(),
443 positions: candidate.positions,
444 is_new: false,
445 })
446 .collect()
447 };
448 picker
449 .update(cx, |picker, _| {
450 if !query.is_empty()
451 && !matches
452 .first()
453 .is_some_and(|entry| entry.branch.name() == query)
454 {
455 let query = query.replace(' ', "-");
456 matches.push(BranchEntry {
457 branch: Branch {
458 ref_name: format!("refs/heads/{query}").into(),
459 is_head: false,
460 upstream: None,
461 most_recent_commit: None,
462 },
463 positions: Vec::new(),
464 is_new: true,
465 })
466 }
467 let delegate = &mut picker.delegate;
468 delegate.matches = matches;
469 if delegate.matches.is_empty() {
470 delegate.selected_index = 0;
471 } else {
472 delegate.selected_index =
473 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
474 }
475 delegate.last_query = query;
476 })
477 .log_err();
478 })
479 }
480
481 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
482 let Some(entry) = self.matches.get(self.selected_index()) else {
483 return;
484 };
485
486 if entry.is_new {
487 let from_branch = if secondary {
488 self.default_branch.clone()
489 } else {
490 None
491 };
492 self.create_branch(
493 from_branch,
494 entry.branch.name().to_owned().into(),
495 window,
496 cx,
497 );
498 return;
499 }
500
501 let current_branch = self.repo.as_ref().map(|repo| {
502 repo.read_with(cx, |repo, _| {
503 repo.branch.as_ref().map(|branch| branch.ref_name.clone())
504 })
505 });
506
507 if current_branch
508 .flatten()
509 .is_some_and(|current_branch| current_branch == entry.branch.ref_name)
510 {
511 cx.emit(DismissEvent);
512 return;
513 }
514
515 let Some(repo) = self.repo.clone() else {
516 return;
517 };
518
519 let branch = entry.branch.clone();
520 cx.spawn(async move |_, cx| {
521 repo.update(cx, |repo, _| repo.change_branch(branch.name().to_string()))?
522 .await??;
523
524 anyhow::Ok(())
525 })
526 .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
527
528 cx.emit(DismissEvent);
529 }
530
531 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
532 cx.emit(DismissEvent);
533 }
534
535 fn render_match(
536 &self,
537 ix: usize,
538 selected: bool,
539 _window: &mut Window,
540 cx: &mut Context<Picker<Self>>,
541 ) -> Option<Self::ListItem> {
542 let entry = &self.matches.get(ix)?;
543
544 let (commit_time, author_name, subject) = entry
545 .branch
546 .most_recent_commit
547 .as_ref()
548 .map(|commit| {
549 let subject = commit.subject.clone();
550 let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
551 .unwrap_or_else(|_| OffsetDateTime::now_utc());
552 let local_offset =
553 time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
554 let formatted_time = time_format::format_localized_timestamp(
555 commit_time,
556 OffsetDateTime::now_utc(),
557 local_offset,
558 time_format::TimestampFormat::Relative,
559 );
560 let author = commit.author_name.clone();
561 (Some(formatted_time), Some(author), Some(subject))
562 })
563 .unwrap_or_else(|| (None, None, None));
564
565 let icon = if let Some(default_branch) = self.default_branch.clone()
566 && entry.is_new
567 {
568 Some(
569 IconButton::new("branch-from-default", IconName::GitBranchAlt)
570 .on_click(cx.listener(move |this, _, window, cx| {
571 this.delegate.set_selected_index(ix, window, cx);
572 this.delegate.confirm(true, window, cx);
573 }))
574 .tooltip(move |_window, cx| {
575 Tooltip::for_action(
576 format!("Create branch based off default: {default_branch}"),
577 &menu::SecondaryConfirm,
578 cx,
579 )
580 }),
581 )
582 } else {
583 None
584 };
585
586 let branch_name = if entry.is_new {
587 h_flex()
588 .gap_1()
589 .child(
590 Icon::new(IconName::Plus)
591 .size(IconSize::Small)
592 .color(Color::Muted),
593 )
594 .child(
595 Label::new(format!("Create branch \"{}\"…", entry.branch.name()))
596 .single_line()
597 .truncate(),
598 )
599 .into_any_element()
600 } else {
601 h_flex()
602 .max_w_48()
603 .child(
604 HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone())
605 .truncate(),
606 )
607 .into_any_element()
608 };
609
610 Some(
611 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
612 .inset(true)
613 .spacing(ListItemSpacing::Sparse)
614 .toggle_state(selected)
615 .tooltip({
616 let branch_name = entry.branch.name().to_string();
617 if entry.is_new {
618 Tooltip::text(format!("Create branch \"{}\"", branch_name))
619 } else {
620 Tooltip::text(branch_name)
621 }
622 })
623 .child(
624 v_flex()
625 .w_full()
626 .overflow_hidden()
627 .child(
628 h_flex()
629 .gap_6()
630 .justify_between()
631 .overflow_x_hidden()
632 .child(branch_name)
633 .when_some(commit_time, |label, commit_time| {
634 label.child(
635 Label::new(commit_time)
636 .size(LabelSize::Small)
637 .color(Color::Muted)
638 .into_element(),
639 )
640 }),
641 )
642 .when(self.style == BranchListStyle::Modal, |el| {
643 el.child(div().max_w_96().child({
644 let message = if entry.is_new {
645 if let Some(current_branch) =
646 self.repo.as_ref().and_then(|repo| {
647 repo.read(cx).branch.as_ref().map(|b| b.name())
648 })
649 {
650 format!("based off {}", current_branch)
651 } else {
652 "based off the current branch".to_string()
653 }
654 } else {
655 let show_author_name = ProjectSettings::get_global(cx)
656 .git
657 .branch_picker
658 .show_author_name;
659
660 subject.map_or("no commits found".into(), |subject| {
661 if show_author_name && author_name.is_some() {
662 format!("{} • {}", author_name.unwrap(), subject)
663 } else {
664 subject.to_string()
665 }
666 })
667 };
668 Label::new(message)
669 .size(LabelSize::Small)
670 .truncate()
671 .color(Color::Muted)
672 }))
673 }),
674 )
675 .end_slot::<IconButton>(icon),
676 )
677 }
678
679 fn render_footer(
680 &self,
681 _window: &mut Window,
682 cx: &mut Context<Picker<Self>>,
683 ) -> Option<AnyElement> {
684 let focus_handle = self.focus_handle.clone();
685
686 Some(
687 h_flex()
688 .w_full()
689 .p_1p5()
690 .gap_0p5()
691 .justify_end()
692 .border_t_1()
693 .border_color(cx.theme().colors().border_variant)
694 .child(
695 Button::new("delete-branch", "Delete")
696 .key_binding(
697 KeyBinding::for_action_in(
698 &branch_picker::DeleteBranch,
699 &focus_handle,
700 cx,
701 )
702 .map(|kb| kb.size(rems_from_px(12.))),
703 )
704 .on_click(|_, window, cx| {
705 window.dispatch_action(branch_picker::DeleteBranch.boxed_clone(), cx);
706 }),
707 )
708 .into_any(),
709 )
710 }
711
712 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
713 None
714 }
715}