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, ListItem, ListItemSpacing};
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 is_new: bool,
173}
174
175pub struct BranchListDelegate {
176 matches: Vec<BranchEntry>,
177 all_branches: Option<Vec<Branch>>,
178 repo: Option<Entity<Repository>>,
179 style: BranchListStyle,
180 selected_index: usize,
181 last_query: String,
182 modifiers: Modifiers,
183}
184
185impl BranchListDelegate {
186 fn new(repo: Option<Entity<Repository>>, style: BranchListStyle) -> Self {
187 Self {
188 matches: vec![],
189 repo,
190 style,
191 all_branches: None,
192 selected_index: 0,
193 last_query: Default::default(),
194 modifiers: Default::default(),
195 }
196 }
197
198 fn create_branch(
199 &self,
200 new_branch_name: SharedString,
201 window: &mut Window,
202 cx: &mut Context<Picker<Self>>,
203 ) {
204 let Some(repo) = self.repo.clone() else {
205 return;
206 };
207 cx.spawn(|_, cx| async move {
208 cx.update(|cx| repo.read(cx).create_branch(new_branch_name.to_string()))?
209 .await??;
210 cx.update(|cx| repo.read(cx).change_branch(new_branch_name.to_string()))?
211 .await??;
212 Ok(())
213 })
214 .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
215 Some(e.to_string())
216 });
217 cx.emit(DismissEvent);
218 }
219}
220
221impl PickerDelegate for BranchListDelegate {
222 type ListItem = ListItem;
223
224 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
225 "Select branch...".into()
226 }
227
228 fn match_count(&self) -> usize {
229 self.matches.len()
230 }
231
232 fn selected_index(&self) -> usize {
233 self.selected_index
234 }
235
236 fn set_selected_index(
237 &mut self,
238 ix: usize,
239 _window: &mut Window,
240 _: &mut Context<Picker<Self>>,
241 ) {
242 self.selected_index = ix;
243 }
244
245 fn update_matches(
246 &mut self,
247 query: String,
248 window: &mut Window,
249 cx: &mut Context<Picker<Self>>,
250 ) -> Task<()> {
251 let Some(all_branches) = self.all_branches.clone() else {
252 return Task::ready(());
253 };
254
255 const RECENT_BRANCHES_COUNT: usize = 10;
256 cx.spawn_in(window, move |picker, mut cx| async move {
257 let mut matches: Vec<BranchEntry> = if query.is_empty() {
258 all_branches
259 .into_iter()
260 .take(RECENT_BRANCHES_COUNT)
261 .map(|branch| BranchEntry {
262 branch,
263 positions: Vec::new(),
264 is_new: false,
265 })
266 .collect()
267 } else {
268 let candidates = all_branches
269 .iter()
270 .enumerate()
271 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name.clone()))
272 .collect::<Vec<StringMatchCandidate>>();
273 fuzzy::match_strings(
274 &candidates,
275 &query,
276 true,
277 10000,
278 &Default::default(),
279 cx.background_executor().clone(),
280 )
281 .await
282 .iter()
283 .cloned()
284 .map(|candidate| BranchEntry {
285 branch: all_branches[candidate.candidate_id].clone(),
286 positions: candidate.positions,
287 is_new: false,
288 })
289 .collect()
290 };
291 picker
292 .update(&mut cx, |picker, _| {
293 #[allow(clippy::nonminimal_bool)]
294 if !query.is_empty()
295 && !matches
296 .first()
297 .is_some_and(|entry| entry.branch.name == query)
298 {
299 matches.push(BranchEntry {
300 branch: Branch {
301 name: query.clone().into(),
302 is_head: false,
303 upstream: None,
304 most_recent_commit: None,
305 },
306 positions: Vec::new(),
307 is_new: true,
308 })
309 }
310 let delegate = &mut picker.delegate;
311 delegate.matches = matches;
312 if delegate.matches.is_empty() {
313 delegate.selected_index = 0;
314 } else {
315 delegate.selected_index =
316 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
317 }
318 delegate.last_query = query;
319 })
320 .log_err();
321 })
322 }
323
324 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
325 let Some(entry) = self.matches.get(self.selected_index()) else {
326 return;
327 };
328 if entry.is_new {
329 self.create_branch(entry.branch.name.clone(), window, cx);
330 return;
331 }
332
333 let current_branch = self.repo.as_ref().map(|repo| {
334 repo.update(cx, |repo, _| {
335 repo.current_branch().map(|branch| branch.name.clone())
336 })
337 });
338
339 if current_branch
340 .flatten()
341 .is_some_and(|current_branch| current_branch == entry.branch.name)
342 {
343 cx.emit(DismissEvent);
344 return;
345 }
346
347 cx.spawn_in(window, {
348 let branch = entry.branch.clone();
349 |picker, mut cx| async move {
350 let branch_change_task = picker.update(&mut cx, |this, cx| {
351 let repo = this
352 .delegate
353 .repo
354 .as_ref()
355 .ok_or_else(|| anyhow!("No active repository"))?
356 .clone();
357
358 let cx = cx.to_async();
359
360 anyhow::Ok(async move {
361 cx.update(|cx| repo.read(cx).change_branch(branch.name.to_string()))?
362 .await?
363 })
364 })??;
365
366 branch_change_task.await?;
367
368 picker.update(&mut cx, |_, cx| {
369 cx.emit(DismissEvent);
370
371 anyhow::Ok(())
372 })
373 }
374 })
375 .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
376 }
377
378 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
379 cx.emit(DismissEvent);
380 }
381
382 fn render_header(&self, _: &mut Window, _cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
383 None
384 }
385
386 fn render_match(
387 &self,
388 ix: usize,
389 selected: bool,
390 _window: &mut Window,
391 cx: &mut Context<Picker<Self>>,
392 ) -> Option<Self::ListItem> {
393 let entry = &self.matches[ix];
394
395 let (commit_time, subject) = entry
396 .branch
397 .most_recent_commit
398 .as_ref()
399 .map(|commit| {
400 let subject = commit.subject.clone();
401 let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
402 .unwrap_or_else(|_| OffsetDateTime::now_utc());
403 let formatted_time = format_local_timestamp(
404 commit_time,
405 OffsetDateTime::now_utc(),
406 time_format::TimestampFormat::Relative,
407 );
408 (Some(formatted_time), Some(subject))
409 })
410 .unwrap_or_else(|| (None, None));
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 .child(
422 v_flex()
423 .w_full()
424 .child(
425 h_flex()
426 .w_full()
427 .flex_shrink()
428 .overflow_x_hidden()
429 .gap_2()
430 .justify_between()
431 .child(div().flex_shrink().overflow_x_hidden().child(
432 if entry.is_new {
433 Label::new(format!(
434 "Create branch \"{}\"…",
435 entry.branch.name
436 ))
437 .into_any_element()
438 } else {
439 HighlightedLabel::new(
440 entry.branch.name.clone(),
441 entry.positions.clone(),
442 )
443 .truncate()
444 .into_any_element()
445 },
446 ))
447 .when_some(commit_time, |el, commit_time| {
448 el.child(
449 Label::new(commit_time)
450 .size(LabelSize::Small)
451 .color(Color::Muted)
452 .into_element(),
453 )
454 }),
455 )
456 .when(self.style == BranchListStyle::Modal, |el| {
457 el.child(div().max_w_96().child({
458 let message = if entry.is_new {
459 if let Some(current_branch) =
460 self.repo.as_ref().and_then(|repo| {
461 repo.read(cx).current_branch().map(|b| b.name.clone())
462 })
463 {
464 format!("based off {}", current_branch)
465 } else {
466 "based off the current branch".to_string()
467 }
468 } else {
469 subject.unwrap_or("no commits found".into()).to_string()
470 };
471 Label::new(message)
472 .size(LabelSize::Small)
473 .truncate()
474 .color(Color::Muted)
475 }))
476 }),
477 ),
478 )
479 }
480
481 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
482 None
483 }
484}