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