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