1use anyhow::{anyhow, Context as _};
2use fuzzy::StringMatchCandidate;
3
4use git::repository::Branch;
5use gpui::{
6 rems, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
7 InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
8 SharedString, Styled, Subscription, Task, Window,
9};
10use picker::{Picker, PickerDelegate};
11use project::git::Repository;
12use std::sync::Arc;
13use time::OffsetDateTime;
14use time_format::format_local_timestamp;
15use ui::{
16 prelude::*, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, PopoverMenuHandle, Tooltip,
17};
18use util::ResultExt;
19use workspace::notifications::DetachAndPromptErr;
20use workspace::{ModalView, Workspace};
21
22pub fn init(cx: &mut App) {
23 cx.observe_new(|workspace: &mut Workspace, _, _| {
24 workspace.register_action(open);
25 workspace.register_action(switch);
26 workspace.register_action(checkout_branch);
27 })
28 .detach();
29}
30
31pub fn checkout_branch(
32 workspace: &mut Workspace,
33 _: &zed_actions::git::CheckoutBranch,
34 window: &mut Window,
35 cx: &mut Context<Workspace>,
36) {
37 open(workspace, &zed_actions::git::Branch, window, cx);
38}
39
40pub fn switch(
41 workspace: &mut Workspace,
42 _: &zed_actions::git::Switch,
43 window: &mut Window,
44 cx: &mut Context<Workspace>,
45) {
46 open(workspace, &zed_actions::git::Branch, window, cx);
47}
48
49pub fn open(
50 workspace: &mut Workspace,
51 _: &zed_actions::git::Branch,
52 window: &mut Window,
53 cx: &mut Context<Workspace>,
54) {
55 let repository = workspace.project().read(cx).active_repository(cx).clone();
56 let style = BranchListStyle::Modal;
57 workspace.toggle_modal(window, cx, |window, cx| {
58 BranchList::new(repository, style, rems(34.), window, cx)
59 })
60}
61
62pub fn popover(
63 repository: Option<Entity<Repository>>,
64 window: &mut Window,
65 cx: &mut App,
66) -> Entity<BranchList> {
67 cx.new(|cx| {
68 let list = BranchList::new(repository, BranchListStyle::Popover, rems(20.), window, cx);
69 list.focus_handle(cx).focus(window);
70 list
71 })
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75enum BranchListStyle {
76 Modal,
77 Popover,
78}
79
80pub struct BranchList {
81 width: Rems,
82 pub popover_handle: PopoverMenuHandle<Self>,
83 pub picker: Entity<Picker<BranchListDelegate>>,
84 _subscription: Subscription,
85}
86
87impl BranchList {
88 fn new(
89 repository: Option<Entity<Repository>>,
90 style: BranchListStyle,
91 width: Rems,
92 window: &mut Window,
93 cx: &mut Context<Self>,
94 ) -> Self {
95 let popover_handle = PopoverMenuHandle::default();
96 let all_branches_request = repository
97 .clone()
98 .map(|repository| repository.read(cx).branches());
99
100 cx.spawn_in(window, |this, mut cx| async move {
101 let mut all_branches = all_branches_request
102 .context("No active repository")?
103 .await??;
104
105 all_branches.sort_by_key(|branch| {
106 branch
107 .most_recent_commit
108 .as_ref()
109 .map(|commit| 0 - commit.commit_timestamp)
110 });
111
112 this.update_in(&mut cx, |this, window, cx| {
113 this.picker.update(cx, |picker, cx| {
114 picker.delegate.all_branches = Some(all_branches);
115 picker.refresh(window, cx);
116 })
117 })?;
118
119 anyhow::Ok(())
120 })
121 .detach_and_log_err(cx);
122
123 let delegate = BranchListDelegate::new(repository.clone(), style);
124 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
125
126 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
127 cx.emit(DismissEvent);
128 });
129
130 Self {
131 picker,
132 width,
133 popover_handle,
134 _subscription,
135 }
136 }
137
138 fn handle_modifiers_changed(
139 &mut self,
140 ev: &ModifiersChangedEvent,
141 _: &mut Window,
142 cx: &mut Context<Self>,
143 ) {
144 self.picker
145 .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
146 }
147}
148impl ModalView for BranchList {}
149impl EventEmitter<DismissEvent> for BranchList {}
150
151impl Focusable for BranchList {
152 fn focus_handle(&self, cx: &App) -> FocusHandle {
153 self.picker.focus_handle(cx)
154 }
155}
156
157impl Render for BranchList {
158 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
159 v_flex()
160 .w(self.width)
161 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
162 .child(self.picker.clone())
163 .on_mouse_down_out({
164 cx.listener(move |this, _, window, cx| {
165 this.picker.update(cx, |this, cx| {
166 this.cancel(&Default::default(), window, cx);
167 })
168 })
169 })
170 }
171}
172
173#[derive(Debug, Clone)]
174struct BranchEntry {
175 branch: Branch,
176 positions: Vec<usize>,
177}
178
179pub struct BranchListDelegate {
180 matches: Vec<BranchEntry>,
181 all_branches: Option<Vec<Branch>>,
182 repo: Option<Entity<Repository>>,
183 style: BranchListStyle,
184 selected_index: usize,
185 last_query: String,
186 modifiers: Modifiers,
187}
188
189impl BranchListDelegate {
190 fn new(repo: Option<Entity<Repository>>, style: BranchListStyle) -> Self {
191 Self {
192 matches: vec![],
193 repo,
194 style,
195 all_branches: None,
196 selected_index: 0,
197 last_query: Default::default(),
198 modifiers: Default::default(),
199 }
200 }
201
202 fn has_exact_match(&self, target: &str) -> bool {
203 self.matches.iter().any(|entry| entry.branch.name == target)
204 }
205
206 fn create_branch(
207 &self,
208 new_branch_name: SharedString,
209 window: &mut Window,
210 cx: &mut Context<Picker<Self>>,
211 ) {
212 let Some(repo) = self.repo.clone() else {
213 return;
214 };
215 cx.spawn(|_, cx| async move {
216 cx.update(|cx| repo.read(cx).create_branch(&new_branch_name))?
217 .await??;
218 cx.update(|cx| repo.read(cx).change_branch(&new_branch_name))?
219 .await??;
220 Ok(())
221 })
222 .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
223 Some(e.to_string())
224 });
225 cx.emit(DismissEvent);
226 }
227}
228
229impl PickerDelegate for BranchListDelegate {
230 type ListItem = ListItem;
231
232 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
233 "Select branch...".into()
234 }
235
236 fn match_count(&self) -> usize {
237 self.matches.len()
238 }
239
240 fn selected_index(&self) -> usize {
241 self.selected_index
242 }
243
244 fn set_selected_index(
245 &mut self,
246 ix: usize,
247 _window: &mut Window,
248 _: &mut Context<Picker<Self>>,
249 ) {
250 self.selected_index = ix;
251 }
252
253 fn update_matches(
254 &mut self,
255 query: String,
256 window: &mut Window,
257 cx: &mut Context<Picker<Self>>,
258 ) -> Task<()> {
259 let Some(all_branches) = self.all_branches.clone() else {
260 return Task::ready(());
261 };
262
263 const RECENT_BRANCHES_COUNT: usize = 10;
264 cx.spawn_in(window, move |picker, mut cx| async move {
265 let matches = if query.is_empty() {
266 all_branches
267 .into_iter()
268 .take(RECENT_BRANCHES_COUNT)
269 .map(|branch| BranchEntry {
270 branch,
271 positions: Vec::new(),
272 })
273 .collect()
274 } else {
275 let candidates = all_branches
276 .iter()
277 .enumerate()
278 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name.clone()))
279 .collect::<Vec<StringMatchCandidate>>();
280 fuzzy::match_strings(
281 &candidates,
282 &query,
283 true,
284 10000,
285 &Default::default(),
286 cx.background_executor().clone(),
287 )
288 .await
289 .iter()
290 .cloned()
291 .map(|candidate| BranchEntry {
292 branch: all_branches[candidate.candidate_id].clone(),
293 positions: candidate.positions,
294 })
295 .collect()
296 };
297 picker
298 .update(&mut cx, |picker, _| {
299 let delegate = &mut picker.delegate;
300 delegate.matches = matches;
301 if delegate.matches.is_empty() {
302 delegate.selected_index = 0;
303 } else {
304 delegate.selected_index =
305 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
306 }
307 delegate.last_query = query;
308 })
309 .log_err();
310 })
311 }
312
313 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
314 let new_branch_name = SharedString::from(self.last_query.trim().replace(" ", "-"));
315 if !new_branch_name.is_empty()
316 && !self.has_exact_match(&new_branch_name)
317 && ((self.selected_index == 0 && self.matches.len() == 0) || secondary)
318 {
319 self.create_branch(new_branch_name, window, cx);
320 return;
321 }
322
323 let Some(entry) = self.matches.get(self.selected_index()) else {
324 return;
325 };
326
327 let current_branch = self.repo.as_ref().map(|repo| {
328 repo.update(cx, |repo, _| {
329 repo.current_branch().map(|branch| branch.name.clone())
330 })
331 });
332
333 if current_branch
334 .flatten()
335 .is_some_and(|current_branch| current_branch == entry.branch.name)
336 {
337 cx.emit(DismissEvent);
338 return;
339 }
340
341 cx.spawn_in(window, {
342 let branch = entry.branch.clone();
343 |picker, mut cx| async move {
344 let branch_change_task = picker.update(&mut cx, |this, cx| {
345 let repo = this
346 .delegate
347 .repo
348 .as_ref()
349 .ok_or_else(|| anyhow!("No active repository"))?
350 .clone();
351
352 let cx = cx.to_async();
353
354 anyhow::Ok(async move {
355 cx.update(|cx| repo.read(cx).change_branch(&branch.name))?
356 .await?
357 })
358 })??;
359
360 branch_change_task.await?;
361
362 picker.update(&mut cx, |_, cx| {
363 cx.emit(DismissEvent);
364
365 anyhow::Ok(())
366 })
367 }
368 })
369 .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
370 }
371
372 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
373 cx.emit(DismissEvent);
374 }
375
376 fn render_header(&self, _: &mut Window, _cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
377 None
378 }
379
380 fn render_match(
381 &self,
382 ix: usize,
383 selected: bool,
384 _window: &mut Window,
385 _cx: &mut Context<Picker<Self>>,
386 ) -> Option<Self::ListItem> {
387 let entry = &self.matches[ix];
388
389 let (commit_time, commit_message) = entry
390 .branch
391 .most_recent_commit
392 .as_ref()
393 .map(|commit| {
394 let commit_message = commit.subject.clone();
395 let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
396 .unwrap_or_else(|_| OffsetDateTime::now_utc());
397 let formatted_time = format_local_timestamp(
398 commit_time,
399 OffsetDateTime::now_utc(),
400 time_format::TimestampFormat::Relative,
401 );
402 (formatted_time, commit_message)
403 })
404 .unwrap_or_else(|| {
405 (
406 "Unknown Date".to_string(),
407 SharedString::from("No commit message available"),
408 )
409 });
410
411 Some(
412 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
413 .inset(true)
414 .spacing(match self.style {
415 BranchListStyle::Modal => ListItemSpacing::default(),
416 BranchListStyle::Popover => ListItemSpacing::ExtraDense,
417 })
418 .spacing(ListItemSpacing::Sparse)
419 .toggle_state(selected)
420 .child(
421 v_flex()
422 .w_full()
423 .child(
424 h_flex()
425 .w_full()
426 .flex_shrink()
427 .overflow_x_hidden()
428 .gap_2()
429 .justify_between()
430 .child(
431 div().flex_shrink().overflow_x_hidden().child(
432 HighlightedLabel::new(
433 entry.branch.name.clone(),
434 entry.positions.clone(),
435 )
436 .truncate(),
437 ),
438 )
439 .child(
440 Label::new(commit_time)
441 .size(LabelSize::Small)
442 .color(Color::Muted)
443 .into_element(),
444 ),
445 )
446 .when(self.style == BranchListStyle::Modal, |el| {
447 el.child(
448 div().max_w_96().child(
449 Label::new(
450 commit_message
451 .split('\n')
452 .next()
453 .unwrap_or_default()
454 .to_string(),
455 )
456 .size(LabelSize::Small)
457 .truncate()
458 .color(Color::Muted),
459 ),
460 )
461 }),
462 ),
463 )
464 }
465
466 fn render_footer(
467 &self,
468 window: &mut Window,
469 cx: &mut Context<Picker<Self>>,
470 ) -> Option<AnyElement> {
471 let new_branch_name = SharedString::from(self.last_query.trim().replace(" ", "-"));
472 let handle = cx.weak_entity();
473 Some(
474 h_flex()
475 .w_full()
476 .p_2()
477 .gap_2()
478 .border_t_1()
479 .border_color(cx.theme().colors().border_variant)
480 .when(
481 !new_branch_name.is_empty() && !self.has_exact_match(&new_branch_name),
482 |el| {
483 el.child(
484 Button::new(
485 "create-branch",
486 format!("Create branch '{new_branch_name}'",),
487 )
488 .key_binding(KeyBinding::for_action(
489 &menu::SecondaryConfirm,
490 window,
491 cx,
492 ))
493 .toggle_state(
494 self.modifiers.secondary()
495 || (self.selected_index == 0 && self.matches.len() == 0),
496 )
497 .tooltip(Tooltip::for_action_title(
498 "Create branch",
499 &menu::SecondaryConfirm,
500 ))
501 .on_click(move |_, window, cx| {
502 let new_branch_name = new_branch_name.clone();
503 if let Some(picker) = handle.upgrade() {
504 picker.update(cx, |picker, cx| {
505 picker.delegate.create_branch(new_branch_name, window, cx)
506 });
507 }
508 }),
509 )
510 },
511 )
512 .into_any(),
513 )
514 }
515
516 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
517 None
518 }
519}