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