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