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