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