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