1use anyhow::{Context, Result};
2use fuzzy::{StringMatch, StringMatchCandidate};
3use git::repository::Branch;
4use gpui::{
5 actions, rems, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
6 InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
7 Task, View, ViewContext, VisualContext, WindowContext,
8};
9use picker::{Picker, PickerDelegate};
10use std::{ops::Not, sync::Arc};
11use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
12use util::ResultExt;
13use workspace::notifications::NotificationId;
14use workspace::{ModalView, Toast, Workspace};
15
16actions!(branches, [OpenRecent]);
17
18pub fn init(cx: &mut AppContext) {
19 cx.observe_new_views(|workspace: &mut Workspace, _| {
20 workspace.register_action(|workspace, action, cx| {
21 BranchList::open(workspace, action, cx).log_err();
22 });
23 })
24 .detach();
25}
26
27pub struct BranchList {
28 pub picker: View<Picker<BranchListDelegate>>,
29 rem_width: f32,
30 _subscription: Subscription,
31}
32
33impl BranchList {
34 fn new(delegate: BranchListDelegate, rem_width: f32, cx: &mut ViewContext<Self>) -> Self {
35 let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
36 let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
37 Self {
38 picker,
39 rem_width,
40 _subscription,
41 }
42 }
43 pub fn open(
44 workspace: &mut Workspace,
45 _: &OpenRecent,
46 cx: &mut ViewContext<Workspace>,
47 ) -> Result<()> {
48 // Modal branch picker has a longer trailoff than a popover one.
49 let delegate = BranchListDelegate::new(workspace, cx.view().clone(), 70, cx)?;
50 workspace.toggle_modal(cx, |cx| BranchList::new(delegate, 34., cx));
51
52 Ok(())
53 }
54}
55impl ModalView for BranchList {}
56impl EventEmitter<DismissEvent> for BranchList {}
57
58impl FocusableView for BranchList {
59 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
60 self.picker.focus_handle(cx)
61 }
62}
63
64impl Render for BranchList {
65 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
66 v_flex()
67 .w(rems(self.rem_width))
68 .child(self.picker.clone())
69 .on_mouse_down_out(cx.listener(|this, _, cx| {
70 this.picker.update(cx, |this, cx| {
71 this.cancel(&Default::default(), cx);
72 })
73 }))
74 }
75}
76
77pub struct BranchListDelegate {
78 matches: Vec<StringMatch>,
79 all_branches: Vec<Branch>,
80 workspace: View<Workspace>,
81 selected_index: usize,
82 last_query: String,
83 /// Max length of branch name before we truncate it and add a trailing `...`.
84 branch_name_trailoff_after: usize,
85}
86
87impl BranchListDelegate {
88 fn new(
89 workspace: &Workspace,
90 handle: View<Workspace>,
91 branch_name_trailoff_after: usize,
92 cx: &AppContext,
93 ) -> Result<Self> {
94 let project = workspace.project().read(cx);
95 let repo = project
96 .get_first_worktree_root_repo(cx)
97 .context("failed to get root repository for first worktree")?;
98
99 let all_branches = repo.branches()?;
100 Ok(Self {
101 matches: vec![],
102 workspace: handle,
103 all_branches,
104 selected_index: 0,
105 last_query: Default::default(),
106 branch_name_trailoff_after,
107 })
108 }
109
110 fn display_error_toast(&self, message: String, cx: &mut WindowContext<'_>) {
111 self.workspace.update(cx, |model, ctx| {
112 struct GitCheckoutFailure;
113 let id = NotificationId::unique::<GitCheckoutFailure>();
114
115 model.show_toast(Toast::new(id, message), ctx)
116 });
117 }
118}
119
120impl PickerDelegate for BranchListDelegate {
121 type ListItem = ListItem;
122
123 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
124 "Select branch...".into()
125 }
126
127 fn match_count(&self) -> usize {
128 self.matches.len()
129 }
130
131 fn selected_index(&self) -> usize {
132 self.selected_index
133 }
134
135 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
136 self.selected_index = ix;
137 }
138
139 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
140 cx.spawn(move |picker, mut cx| async move {
141 let candidates = picker.update(&mut cx, |view, _| {
142 const RECENT_BRANCHES_COUNT: usize = 10;
143 let mut branches = view.delegate.all_branches.clone();
144 if query.is_empty() {
145 if branches.len() > RECENT_BRANCHES_COUNT {
146 // Truncate list of recent branches
147 // Do a partial sort to show recent-ish branches first.
148 branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
149 rhs.is_head
150 .cmp(&lhs.is_head)
151 .then(rhs.unix_timestamp.cmp(&lhs.unix_timestamp))
152 });
153 branches.truncate(RECENT_BRANCHES_COUNT);
154 }
155 branches.sort_unstable_by(|lhs, rhs| {
156 rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
157 });
158 }
159 branches
160 .into_iter()
161 .enumerate()
162 .map(|(ix, command)| StringMatchCandidate {
163 id: ix,
164 char_bag: command.name.chars().collect(),
165 string: command.name.into(),
166 })
167 .collect::<Vec<StringMatchCandidate>>()
168 });
169 let Some(candidates) = candidates.log_err() else {
170 return;
171 };
172 let matches = if query.is_empty() {
173 candidates
174 .into_iter()
175 .enumerate()
176 .map(|(index, candidate)| StringMatch {
177 candidate_id: index,
178 string: candidate.string,
179 positions: Vec::new(),
180 score: 0.0,
181 })
182 .collect()
183 } else {
184 fuzzy::match_strings(
185 &candidates,
186 &query,
187 true,
188 10000,
189 &Default::default(),
190 cx.background_executor().clone(),
191 )
192 .await
193 };
194 picker
195 .update(&mut cx, |picker, _| {
196 let delegate = &mut picker.delegate;
197 delegate.matches = matches;
198 if delegate.matches.is_empty() {
199 delegate.selected_index = 0;
200 } else {
201 delegate.selected_index =
202 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
203 }
204 delegate.last_query = query;
205 })
206 .log_err();
207 })
208 }
209
210 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
211 let current_pick = self.selected_index();
212 let Some(current_pick) = self
213 .matches
214 .get(current_pick)
215 .map(|pick| pick.string.clone())
216 else {
217 return;
218 };
219 cx.spawn(|picker, mut cx| async move {
220 picker
221 .update(&mut cx, |this, cx| {
222 let project = this.delegate.workspace.read(cx).project().read(cx);
223 let repo = project
224 .get_first_worktree_root_repo(cx)
225 .context("failed to get root repository for first worktree")?;
226 let status = repo
227 .change_branch(¤t_pick);
228 if status.is_err() {
229 this.delegate.display_error_toast(format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"), cx);
230 status?;
231 }
232 cx.emit(DismissEvent);
233
234 Ok::<(), anyhow::Error>(())
235 })
236 .log_err();
237 })
238 .detach();
239 }
240
241 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
242 cx.emit(DismissEvent);
243 }
244
245 fn render_match(
246 &self,
247 ix: usize,
248 selected: bool,
249 _cx: &mut ViewContext<Picker<Self>>,
250 ) -> Option<Self::ListItem> {
251 let hit = &self.matches[ix];
252 let shortened_branch_name =
253 util::truncate_and_trailoff(&hit.string, self.branch_name_trailoff_after);
254 let highlights: Vec<_> = hit
255 .positions
256 .iter()
257 .filter(|index| index < &&self.branch_name_trailoff_after)
258 .copied()
259 .collect();
260 Some(
261 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
262 .inset(true)
263 .spacing(ListItemSpacing::Sparse)
264 .selected(selected)
265 .start_slot(HighlightedLabel::new(shortened_branch_name, highlights)),
266 )
267 }
268
269 fn render_header(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
270 let label = if self.last_query.is_empty() {
271 Label::new("Recent Branches")
272 .size(LabelSize::Small)
273 .mt_1()
274 .ml_3()
275 .into_any_element()
276 } else {
277 let match_label = self.matches.is_empty().not().then(|| {
278 let suffix = if self.matches.len() == 1 { "" } else { "es" };
279 Label::new(format!("{} match{}", self.matches.len(), suffix))
280 .color(Color::Muted)
281 .size(LabelSize::Small)
282 });
283 h_flex()
284 .px_3()
285 .justify_between()
286 .child(Label::new("Branches").size(LabelSize::Small))
287 .children(match_label)
288 .into_any_element()
289 };
290 Some(v_flex().mt_1().child(label).into_any_element())
291 }
292
293 fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
294 if self.last_query.is_empty() {
295 return None;
296 }
297
298 Some(
299 h_flex()
300 .p_2()
301 .border_t_1()
302 .border_color(cx.theme().colors().border_variant)
303 .justify_end()
304 .child(h_flex().w_full())
305 .child(
306 Button::new("branch-picker-create-branch-button", "Create Branch")
307 .icon(IconName::Plus)
308 .icon_size(IconSize::Small)
309 .icon_color(Color::Muted)
310 .icon_position(IconPosition::Start)
311 .on_click(cx.listener(|_, _, cx| {
312 cx.spawn(|picker, mut cx| async move {
313 picker.update(&mut cx, |this, cx| {
314 let project =
315 this.delegate.workspace.read(cx).project().read(cx);
316 let current_pick = &this.delegate.last_query;
317 let repo = project.get_first_worktree_root_repo(cx).context(
318 "failed to get root repository for first worktree",
319 )?;
320 let status = repo.create_branch(current_pick);
321 if status.is_err() {
322 this.delegate.display_error_toast(format!("Failed to create branch '{current_pick}', check for conflicts or unstashed files"), cx);
323 status?;
324 }
325 let status = repo.change_branch(current_pick);
326 if status.is_err() {
327 this.delegate.display_error_toast(format!("Failed to check branch '{current_pick}', check for conflicts or unstashed files"), cx);
328 status?;
329 }
330 this.cancel(&Default::default(), cx);
331 Ok::<(), anyhow::Error>(())
332 })
333 })
334 .detach_and_log_err(cx);
335 }))
336 )
337 .into_any_element(),
338 )
339 }
340}