1use anyhow::Context as _;
2use fuzzy::StringMatchCandidate;
3
4use collections::HashSet;
5use git::repository::Branch;
6use gpui::{
7 App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
8 IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, SharedString, Styled,
9 Subscription, Task, Window, 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 time_format::format_local_timestamp;
18use ui::{HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
19use util::ResultExt;
20use workspace::notifications::DetachAndPromptErr;
21use workspace::{ModalView, Workspace};
22
23pub fn register(workspace: &mut Workspace) {
24 workspace.register_action(open);
25 workspace.register_action(switch);
26 workspace.register_action(checkout_branch);
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);
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.update(cx, |repository, _| repository.branches()));
95 let default_branch_request = repository
96 .clone()
97 .map(|repository| repository.update(cx, |repository, _| repository.default_branch()));
98
99 cx.spawn_in(window, async move |this, cx| {
100 let mut all_branches = all_branches_request
101 .context("No active repository")?
102 .await??;
103 let default_branch = default_branch_request
104 .context("No active repository")?
105 .await
106 .map(Result::ok)
107 .ok()
108 .flatten()
109 .flatten();
110
111 let all_branches = cx
112 .background_spawn(async move {
113 let remote_upstreams: HashSet<_> = all_branches
114 .iter()
115 .filter_map(|branch| {
116 branch
117 .upstream
118 .as_ref()
119 .filter(|upstream| upstream.is_remote())
120 .map(|upstream| upstream.ref_name.clone())
121 })
122 .collect();
123
124 all_branches.retain(|branch| !remote_upstreams.contains(&branch.ref_name));
125
126 all_branches.sort_by_key(|branch| {
127 (
128 !branch.is_head, // Current branch (is_head=true) comes first
129 branch
130 .most_recent_commit
131 .as_ref()
132 .map(|commit| 0 - commit.commit_timestamp),
133 )
134 });
135
136 all_branches
137 })
138 .await;
139
140 this.update_in(cx, |this, window, cx| {
141 this.picker.update(cx, |picker, cx| {
142 picker.delegate.default_branch = default_branch;
143 picker.delegate.all_branches = Some(all_branches);
144 picker.refresh(window, cx);
145 })
146 })?;
147
148 anyhow::Ok(())
149 })
150 .detach_and_log_err(cx);
151
152 let delegate = BranchListDelegate::new(repository, style);
153 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
154
155 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
156 cx.emit(DismissEvent);
157 });
158
159 Self {
160 picker,
161 width,
162 _subscription,
163 }
164 }
165
166 fn handle_modifiers_changed(
167 &mut self,
168 ev: &ModifiersChangedEvent,
169 _: &mut Window,
170 cx: &mut Context<Self>,
171 ) {
172 self.picker
173 .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
174 }
175}
176impl ModalView for BranchList {}
177impl EventEmitter<DismissEvent> for BranchList {}
178
179impl Focusable for BranchList {
180 fn focus_handle(&self, cx: &App) -> FocusHandle {
181 self.picker.focus_handle(cx)
182 }
183}
184
185impl Render for BranchList {
186 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
187 v_flex()
188 .key_context("GitBranchSelector")
189 .w(self.width)
190 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
191 .child(self.picker.clone())
192 .on_mouse_down_out({
193 cx.listener(move |this, _, window, cx| {
194 this.picker.update(cx, |this, cx| {
195 this.cancel(&Default::default(), window, cx);
196 })
197 })
198 })
199 }
200}
201
202#[derive(Debug, Clone)]
203struct BranchEntry {
204 branch: Branch,
205 positions: Vec<usize>,
206 is_new: bool,
207}
208
209pub struct BranchListDelegate {
210 matches: Vec<BranchEntry>,
211 all_branches: Option<Vec<Branch>>,
212 default_branch: Option<SharedString>,
213 repo: Option<Entity<Repository>>,
214 style: BranchListStyle,
215 selected_index: usize,
216 last_query: String,
217 modifiers: Modifiers,
218}
219
220impl BranchListDelegate {
221 fn new(repo: Option<Entity<Repository>>, style: BranchListStyle) -> Self {
222 Self {
223 matches: vec![],
224 repo,
225 style,
226 all_branches: None,
227 default_branch: None,
228 selected_index: 0,
229 last_query: Default::default(),
230 modifiers: Default::default(),
231 }
232 }
233
234 fn create_branch(
235 &self,
236 from_branch: Option<SharedString>,
237 new_branch_name: SharedString,
238 window: &mut Window,
239 cx: &mut Context<Picker<Self>>,
240 ) {
241 let Some(repo) = self.repo.clone() else {
242 return;
243 };
244 let new_branch_name = new_branch_name.to_string().replace(' ', "-");
245 cx.spawn(async move |_, cx| {
246 if let Some(based_branch) = from_branch {
247 match repo
248 .update(cx, |repo, _| repo.change_branch(based_branch.to_string()))?
249 .await
250 {
251 Ok(Ok(_)) => {}
252 Ok(Err(error)) => return Err(error),
253 Err(_) => return Err(anyhow::anyhow!("Operation was canceled")),
254 }
255 }
256
257 match repo
258 .update(cx, |repo, _| repo.create_branch(new_branch_name.clone()))?
259 .await
260 {
261 Ok(Ok(_)) => {}
262 Ok(Err(error)) => return Err(error),
263 Err(_) => return Err(anyhow::anyhow!("Operation was canceled")),
264 }
265
266 match repo
267 .update(cx, |repo, _| repo.change_branch(new_branch_name))?
268 .await
269 {
270 Ok(Ok(_)) => {}
271 Ok(Err(error)) => return Err(error),
272 Err(_) => return Err(anyhow::anyhow!("Operation was canceled")),
273 }
274
275 Ok(())
276 })
277 .detach_and_prompt_err("Failed to create branch", window, cx, |_, _, _| None);
278 }
279}
280
281impl PickerDelegate for BranchListDelegate {
282 type ListItem = ListItem;
283
284 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
285 "Select branch…".into()
286 }
287
288 fn editor_position(&self) -> PickerEditorPosition {
289 match self.style {
290 BranchListStyle::Modal => PickerEditorPosition::Start,
291 BranchListStyle::Popover => PickerEditorPosition::End,
292 }
293 }
294
295 fn match_count(&self) -> usize {
296 self.matches.len()
297 }
298
299 fn selected_index(&self) -> usize {
300 self.selected_index
301 }
302
303 fn set_selected_index(
304 &mut self,
305 ix: usize,
306 _window: &mut Window,
307 _: &mut Context<Picker<Self>>,
308 ) {
309 self.selected_index = ix;
310 }
311
312 fn update_matches(
313 &mut self,
314 query: String,
315 window: &mut Window,
316 cx: &mut Context<Picker<Self>>,
317 ) -> Task<()> {
318 let Some(all_branches) = self.all_branches.clone() else {
319 return Task::ready(());
320 };
321
322 const RECENT_BRANCHES_COUNT: usize = 10;
323 cx.spawn_in(window, async move |picker, cx| {
324 let mut matches: Vec<BranchEntry> = if query.is_empty() {
325 all_branches
326 .into_iter()
327 .filter(|branch| !branch.is_remote())
328 .take(RECENT_BRANCHES_COUNT)
329 .map(|branch| BranchEntry {
330 branch,
331 positions: Vec::new(),
332 is_new: false,
333 })
334 .collect()
335 } else {
336 let candidates = all_branches
337 .iter()
338 .enumerate()
339 .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
340 .collect::<Vec<StringMatchCandidate>>();
341 fuzzy::match_strings(
342 &candidates,
343 &query,
344 true,
345 true,
346 10000,
347 &Default::default(),
348 cx.background_executor().clone(),
349 )
350 .await
351 .into_iter()
352 .map(|candidate| BranchEntry {
353 branch: all_branches[candidate.candidate_id].clone(),
354 positions: candidate.positions,
355 is_new: false,
356 })
357 .collect()
358 };
359 picker
360 .update(cx, |picker, _| {
361 if !query.is_empty()
362 && !matches
363 .first()
364 .is_some_and(|entry| entry.branch.name() == query)
365 {
366 let query = query.replace(' ', "-");
367 matches.push(BranchEntry {
368 branch: Branch {
369 ref_name: format!("refs/heads/{query}").into(),
370 is_head: false,
371 upstream: None,
372 most_recent_commit: None,
373 },
374 positions: Vec::new(),
375 is_new: true,
376 })
377 }
378 let delegate = &mut picker.delegate;
379 delegate.matches = matches;
380 if delegate.matches.is_empty() {
381 delegate.selected_index = 0;
382 } else {
383 delegate.selected_index =
384 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
385 }
386 delegate.last_query = query;
387 })
388 .log_err();
389 })
390 }
391
392 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
393 let Some(entry) = self.matches.get(self.selected_index()) else {
394 return;
395 };
396 if entry.is_new {
397 let from_branch = if secondary {
398 self.default_branch.clone()
399 } else {
400 None
401 };
402 self.create_branch(
403 from_branch,
404 entry.branch.name().to_owned().into(),
405 window,
406 cx,
407 );
408 return;
409 }
410
411 let current_branch = self.repo.as_ref().map(|repo| {
412 repo.read_with(cx, |repo, _| {
413 repo.branch.as_ref().map(|branch| branch.ref_name.clone())
414 })
415 });
416
417 if current_branch
418 .flatten()
419 .is_some_and(|current_branch| current_branch == entry.branch.ref_name)
420 {
421 cx.emit(DismissEvent);
422 return;
423 }
424
425 cx.spawn_in(window, {
426 let branch = entry.branch.clone();
427 async move |picker, cx| {
428 let branch_name = branch.name().to_string();
429
430 let branch_change_task = picker.update(cx, |this, cx| {
431 let repo = this
432 .delegate
433 .repo
434 .as_ref()
435 .context("No active repository")?
436 .clone();
437
438 let mut cx = cx.to_async();
439
440 anyhow::Ok(async move {
441 repo.update(&mut cx, |repo, _| repo.change_branch(branch_name))?
442 .await?
443 })
444 })??;
445
446 match branch_change_task.await {
447 Ok(_) => {
448 let _ = picker.update(cx, |_, cx| {
449 cx.emit(DismissEvent);
450 anyhow::Ok(())
451 })?;
452 }
453 Err(error) => {
454 let _ = picker.update(cx, |_, cx| {
455 cx.emit(DismissEvent);
456 anyhow::Ok(())
457 })?;
458 return Err(error);
459 }
460 }
461
462 anyhow::Ok(())
463 }
464 })
465 .detach_and_prompt_err("Failed to switch branch", window, cx, |_, _, _| None);
466 }
467
468 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
469 cx.emit(DismissEvent);
470 }
471
472 fn render_match(
473 &self,
474 ix: usize,
475 selected: bool,
476 _window: &mut Window,
477 cx: &mut Context<Picker<Self>>,
478 ) -> Option<Self::ListItem> {
479 let entry = &self.matches.get(ix)?;
480
481 let (commit_time, author_name, subject) = entry
482 .branch
483 .most_recent_commit
484 .as_ref()
485 .map(|commit| {
486 let subject = commit.subject.clone();
487 let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
488 .unwrap_or_else(|_| OffsetDateTime::now_utc());
489 let formatted_time = format_local_timestamp(
490 commit_time,
491 OffsetDateTime::now_utc(),
492 time_format::TimestampFormat::Relative,
493 );
494 let author = commit.author_name.clone();
495 (Some(formatted_time), Some(author), Some(subject))
496 })
497 .unwrap_or_else(|| (None, None, None));
498
499 let icon = if let Some(default_branch) = self.default_branch.clone()
500 && entry.is_new
501 {
502 Some(
503 IconButton::new("branch-from-default", IconName::GitBranchAlt)
504 .on_click(cx.listener(move |this, _, window, cx| {
505 this.delegate.set_selected_index(ix, window, cx);
506 this.delegate.confirm(true, window, cx);
507 }))
508 .tooltip(move |window, cx| {
509 Tooltip::for_action(
510 format!("Create branch based off default: {default_branch}"),
511 &menu::SecondaryConfirm,
512 window,
513 cx,
514 )
515 }),
516 )
517 } else {
518 None
519 };
520
521 let branch_name = if entry.is_new {
522 h_flex()
523 .gap_1()
524 .child(
525 Icon::new(IconName::Plus)
526 .size(IconSize::Small)
527 .color(Color::Muted),
528 )
529 .child(
530 Label::new(format!("Create branch \"{}\"…", entry.branch.name()))
531 .single_line()
532 .truncate(),
533 )
534 .into_any_element()
535 } else {
536 HighlightedLabel::new(entry.branch.name().to_owned(), entry.positions.clone())
537 .truncate()
538 .into_any_element()
539 };
540
541 Some(
542 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
543 .inset(true)
544 .spacing(ListItemSpacing::Sparse)
545 .toggle_state(selected)
546 .child(
547 v_flex()
548 .w_full()
549 .overflow_hidden()
550 .child(
551 h_flex()
552 .gap_6()
553 .justify_between()
554 .overflow_x_hidden()
555 .child(branch_name)
556 .when_some(commit_time, |label, commit_time| {
557 label.child(
558 Label::new(commit_time)
559 .size(LabelSize::Small)
560 .color(Color::Muted)
561 .into_element(),
562 )
563 }),
564 )
565 .when(self.style == BranchListStyle::Modal, |el| {
566 el.child(div().max_w_96().child({
567 let message = if entry.is_new {
568 if let Some(current_branch) =
569 self.repo.as_ref().and_then(|repo| {
570 repo.read(cx).branch.as_ref().map(|b| b.name())
571 })
572 {
573 format!("based off {}", current_branch)
574 } else {
575 "based off the current branch".to_string()
576 }
577 } else {
578 let show_author_name = ProjectSettings::get_global(cx)
579 .git
580 .branch_picker
581 .unwrap_or_default()
582 .show_author_name;
583
584 subject.map_or("no commits found".into(), |subject| {
585 if show_author_name && author_name.is_some() {
586 format!("{} • {}", author_name.unwrap(), subject)
587 } else {
588 subject.to_string()
589 }
590 })
591 };
592 Label::new(message)
593 .size(LabelSize::Small)
594 .truncate()
595 .color(Color::Muted)
596 }))
597 }),
598 )
599 .end_slot::<IconButton>(icon),
600 )
601 }
602
603 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
604 None
605 }
606}