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