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