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