1use anyhow::{Context as _, Result};
2use fuzzy::{StringMatch, StringMatchCandidate};
3
4use git::repository::Branch;
5use gpui::{
6 rems, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
7 InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
8 Task, Window,
9};
10use picker::{Picker, PickerDelegate};
11use project::{Project, ProjectPath};
12use std::sync::Arc;
13use ui::{
14 prelude::*, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenuHandle, TriggerablePopover,
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 })
24 .detach();
25}
26
27pub fn open(
28 workspace: &mut Workspace,
29 _: &zed_actions::git::Branch,
30 window: &mut Window,
31 cx: &mut Context<Workspace>,
32) {
33 let project = workspace.project().clone();
34 let this = cx.entity();
35 let style = BranchListStyle::Modal;
36 cx.spawn_in(window, |_, mut cx| async move {
37 // Modal branch picker has a longer trailoff than a popover one.
38 let delegate = BranchListDelegate::new(project.clone(), style, 70, &cx).await?;
39
40 this.update_in(&mut cx, move |workspace, window, cx| {
41 workspace.toggle_modal(window, cx, |window, cx| {
42 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
43 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
44 cx.emit(DismissEvent);
45 });
46
47 let mut list = BranchList::new(project, style, 34., cx);
48 list._subscription = Some(_subscription);
49 list.picker = Some(picker);
50 list
51 })
52 })?;
53
54 Ok(())
55 })
56 .detach_and_prompt_err("Failed to read branches", window, cx, |_, _, _| None)
57}
58
59pub fn popover(project: Entity<Project>, window: &mut Window, cx: &mut App) -> Entity<BranchList> {
60 cx.new(|cx| {
61 let mut list = BranchList::new(project, BranchListStyle::Popover, 15., cx);
62 list.reload_branches(window, cx);
63 list
64 })
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68enum BranchListStyle {
69 Modal,
70 Popover,
71}
72
73pub struct BranchList {
74 rem_width: f32,
75 popover_handle: PopoverMenuHandle<Self>,
76 default_focus_handle: FocusHandle,
77 project: Entity<Project>,
78 style: BranchListStyle,
79 pub picker: Option<Entity<Picker<BranchListDelegate>>>,
80 _subscription: Option<Subscription>,
81}
82
83impl TriggerablePopover for BranchList {
84 fn menu_handle(
85 &mut self,
86 _window: &mut Window,
87 _cx: &mut gpui::Context<Self>,
88 ) -> PopoverMenuHandle<Self> {
89 self.popover_handle.clone()
90 }
91}
92
93impl BranchList {
94 fn new(project: Entity<Project>, style: BranchListStyle, rem_width: f32, cx: &mut App) -> Self {
95 let popover_handle = PopoverMenuHandle::default();
96 Self {
97 project,
98 picker: None,
99 rem_width,
100 popover_handle,
101 default_focus_handle: cx.focus_handle(),
102 style,
103 _subscription: None,
104 }
105 }
106
107 fn reload_branches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
108 let project = self.project.clone();
109 let style = self.style;
110 cx.spawn_in(window, |this, mut cx| async move {
111 let delegate = BranchListDelegate::new(project, style, 20, &cx).await?;
112 let picker =
113 cx.new_window_entity(|window, cx| Picker::uniform_list(delegate, window, cx))?;
114
115 this.update(&mut cx, |branch_list, cx| {
116 let subscription =
117 cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| cx.emit(DismissEvent));
118
119 branch_list.picker = Some(picker);
120 branch_list._subscription = Some(subscription);
121
122 cx.notify();
123 })?;
124
125 anyhow::Ok(())
126 })
127 .detach_and_log_err(cx);
128 }
129}
130impl ModalView for BranchList {}
131impl EventEmitter<DismissEvent> for BranchList {}
132
133impl Focusable for BranchList {
134 fn focus_handle(&self, cx: &App) -> FocusHandle {
135 self.picker
136 .as_ref()
137 .map(|picker| picker.focus_handle(cx))
138 .unwrap_or_else(|| self.default_focus_handle.clone())
139 }
140}
141
142impl Render for BranchList {
143 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
144 v_flex()
145 .w(rems(self.rem_width))
146 .map(|parent| match self.picker.as_ref() {
147 Some(picker) => parent.child(picker.clone()).on_mouse_down_out({
148 let picker = picker.clone();
149 cx.listener(move |_, _, window, cx| {
150 picker.update(cx, |this, cx| {
151 this.cancel(&Default::default(), window, cx);
152 })
153 })
154 }),
155 None => parent.child(
156 h_flex()
157 .id("branch-picker-error")
158 .on_click(
159 cx.listener(|this, _, window, cx| this.reload_branches(window, cx)),
160 )
161 .child("Could not load branches.")
162 .child("Click to retry"),
163 ),
164 })
165 }
166}
167
168#[derive(Debug, Clone)]
169enum BranchEntry {
170 Branch(StringMatch),
171 History(String),
172 NewBranch { name: String },
173}
174
175impl BranchEntry {
176 fn name(&self) -> &str {
177 match self {
178 Self::Branch(branch) => &branch.string,
179 Self::History(branch) => &branch,
180 Self::NewBranch { name } => &name,
181 }
182 }
183}
184
185pub struct BranchListDelegate {
186 matches: Vec<BranchEntry>,
187 all_branches: Vec<Branch>,
188 project: Entity<Project>,
189 style: BranchListStyle,
190 selected_index: usize,
191 last_query: String,
192 /// Max length of branch name before we truncate it and add a trailing `...`.
193 branch_name_trailoff_after: usize,
194}
195
196impl BranchListDelegate {
197 async fn new(
198 project: Entity<Project>,
199 style: BranchListStyle,
200 branch_name_trailoff_after: usize,
201 cx: &AsyncApp,
202 ) -> Result<Self> {
203 let all_branches_request = cx.update(|cx| {
204 let project = project.read(cx);
205 let first_worktree = project
206 .visible_worktrees(cx)
207 .next()
208 .context("No worktrees found")?;
209 let project_path = ProjectPath::root_path(first_worktree.read(cx).id());
210 anyhow::Ok(project.branches(project_path, cx))
211 })??;
212
213 let all_branches = all_branches_request.await?;
214
215 Ok(Self {
216 matches: vec![],
217 project,
218 style,
219 all_branches,
220 selected_index: 0,
221 last_query: Default::default(),
222 branch_name_trailoff_after,
223 })
224 }
225
226 pub fn branch_count(&self) -> usize {
227 self.matches
228 .iter()
229 .filter(|item| matches!(item, BranchEntry::Branch(_)))
230 .count()
231 }
232}
233
234impl PickerDelegate for BranchListDelegate {
235 type ListItem = ListItem;
236
237 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
238 "Select branch...".into()
239 }
240
241 fn match_count(&self) -> usize {
242 self.matches.len()
243 }
244
245 fn selected_index(&self) -> usize {
246 self.selected_index
247 }
248
249 fn set_selected_index(
250 &mut self,
251 ix: usize,
252 _window: &mut Window,
253 _: &mut Context<Picker<Self>>,
254 ) {
255 self.selected_index = ix;
256 }
257
258 fn update_matches(
259 &mut self,
260 query: String,
261 window: &mut Window,
262 cx: &mut Context<Picker<Self>>,
263 ) -> Task<()> {
264 cx.spawn_in(window, move |picker, mut cx| async move {
265 let candidates = picker.update(&mut cx, |picker, _| {
266 const RECENT_BRANCHES_COUNT: usize = 10;
267 let mut branches = picker.delegate.all_branches.clone();
268 if query.is_empty() {
269 if branches.len() > RECENT_BRANCHES_COUNT {
270 // Truncate list of recent branches
271 // Do a partial sort to show recent-ish branches first.
272 branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
273 rhs.priority_key().cmp(&lhs.priority_key())
274 });
275 branches.truncate(RECENT_BRANCHES_COUNT);
276 }
277 branches.sort_unstable_by(|lhs, rhs| {
278 rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
279 });
280 }
281 branches
282 .into_iter()
283 .enumerate()
284 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
285 .collect::<Vec<StringMatchCandidate>>()
286 });
287 let Some(candidates) = candidates.log_err() else {
288 return;
289 };
290 let matches: Vec<BranchEntry> = if query.is_empty() {
291 candidates
292 .into_iter()
293 .map(|candidate| BranchEntry::History(candidate.string))
294 .collect()
295 } else {
296 fuzzy::match_strings(
297 &candidates,
298 &query,
299 true,
300 10000,
301 &Default::default(),
302 cx.background_executor().clone(),
303 )
304 .await
305 .iter()
306 .cloned()
307 .map(BranchEntry::Branch)
308 .collect()
309 };
310 picker
311 .update(&mut cx, |picker, _| {
312 let delegate = &mut picker.delegate;
313 delegate.matches = matches;
314 if delegate.matches.is_empty() {
315 if !query.is_empty() {
316 delegate.matches.push(BranchEntry::NewBranch {
317 name: query.trim().replace(' ', "-"),
318 });
319 }
320
321 delegate.selected_index = 0;
322 } else {
323 delegate.selected_index =
324 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
325 }
326 delegate.last_query = query;
327 })
328 .log_err();
329 })
330 }
331
332 fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
333 let Some(branch) = self.matches.get(self.selected_index()) else {
334 return;
335 };
336
337 let current_branch = self.project.update(cx, |project, cx| {
338 project
339 .active_repository(cx)
340 .and_then(|repo| repo.read(cx).current_branch())
341 .map(|branch| branch.name.to_string())
342 });
343
344 if current_branch == Some(branch.name().to_string()) {
345 cx.emit(DismissEvent);
346 return;
347 }
348
349 cx.spawn_in(window, {
350 let branch = branch.clone();
351 |picker, mut cx| async move {
352 let branch_change_task = picker.update(&mut cx, |this, cx| {
353 let project = this.delegate.project.read(cx);
354 let branch_to_checkout = match branch {
355 BranchEntry::Branch(branch) => branch.string,
356 BranchEntry::History(string) => string,
357 BranchEntry::NewBranch { name: branch_name } => branch_name,
358 };
359 let worktree = project
360 .visible_worktrees(cx)
361 .next()
362 .context("worktree disappeared")?;
363 let repository = ProjectPath::root_path(worktree.read(cx).id());
364
365 anyhow::Ok(project.update_or_create_branch(repository, branch_to_checkout, cx))
366 })??;
367
368 branch_change_task.await?;
369
370 picker.update(&mut cx, |_, cx| {
371 cx.emit(DismissEvent);
372
373 Ok::<(), anyhow::Error>(())
374 })
375 }
376 })
377 .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
378 }
379
380 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
381 cx.emit(DismissEvent);
382 }
383
384 fn render_match(
385 &self,
386 ix: usize,
387 selected: bool,
388 _window: &mut Window,
389 _cx: &mut Context<Picker<Self>>,
390 ) -> Option<Self::ListItem> {
391 let hit = &self.matches[ix];
392 let shortened_branch_name =
393 util::truncate_and_trailoff(&hit.name(), self.branch_name_trailoff_after);
394
395 Some(
396 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
397 .inset(true)
398 .spacing(match self.style {
399 BranchListStyle::Modal => ListItemSpacing::default(),
400 BranchListStyle::Popover => ListItemSpacing::ExtraDense,
401 })
402 .spacing(ListItemSpacing::Sparse)
403 .toggle_state(selected)
404 .when(matches!(hit, BranchEntry::History(_)), |el| {
405 el.end_slot(
406 Icon::new(IconName::HistoryRerun)
407 .color(Color::Muted)
408 .size(IconSize::Small),
409 )
410 })
411 .map(|el| match hit {
412 BranchEntry::Branch(branch) => {
413 let highlights: Vec<_> = branch
414 .positions
415 .iter()
416 .filter(|index| index < &&self.branch_name_trailoff_after)
417 .copied()
418 .collect();
419
420 el.child(HighlightedLabel::new(shortened_branch_name, highlights))
421 }
422 BranchEntry::History(_) => el.child(Label::new(shortened_branch_name)),
423 BranchEntry::NewBranch { name } => {
424 el.child(Label::new(format!("Create branch '{name}'")))
425 }
426 }),
427 )
428 }
429}