1use anyhow::{Context, 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::open(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 pub fn open(
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 struct BranchListDelegate {
81 matches: Vec<StringMatch>,
82 all_branches: Vec<Branch>,
83 workspace: View<Workspace>,
84 selected_index: usize,
85 last_query: String,
86 /// Max length of branch name before we truncate it and add a trailing `...`.
87 branch_name_trailoff_after: usize,
88}
89
90impl BranchListDelegate {
91 fn new(
92 workspace: &Workspace,
93 handle: View<Workspace>,
94 branch_name_trailoff_after: usize,
95 cx: &AppContext,
96 ) -> Result<Self> {
97 let project = workspace.project().read(&cx);
98 let repo = project
99 .get_first_worktree_root_repo(cx)
100 .context("failed to get root repository for first worktree")?;
101
102 let all_branches = repo.branches()?;
103 Ok(Self {
104 matches: vec![],
105 workspace: handle,
106 all_branches,
107 selected_index: 0,
108 last_query: Default::default(),
109 branch_name_trailoff_after,
110 })
111 }
112
113 fn display_error_toast(&self, message: String, cx: &mut WindowContext<'_>) {
114 self.workspace.update(cx, |model, ctx| {
115 struct GitCheckoutFailure;
116 let id = NotificationId::unique::<GitCheckoutFailure>();
117
118 model.show_toast(Toast::new(id, message), ctx)
119 });
120 }
121}
122
123impl PickerDelegate for BranchListDelegate {
124 type ListItem = ListItem;
125
126 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
127 "Select branch...".into()
128 }
129
130 fn match_count(&self) -> usize {
131 self.matches.len()
132 }
133
134 fn selected_index(&self) -> usize {
135 self.selected_index
136 }
137
138 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
139 self.selected_index = ix;
140 }
141
142 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
143 cx.spawn(move |picker, mut cx| async move {
144 let candidates = picker.update(&mut cx, |view, _| {
145 const RECENT_BRANCHES_COUNT: usize = 10;
146 let mut branches = view.delegate.all_branches.clone();
147 if query.is_empty() {
148 if branches.len() > RECENT_BRANCHES_COUNT {
149 // Truncate list of recent branches
150 // Do a partial sort to show recent-ish branches first.
151 branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
152 rhs.is_head
153 .cmp(&lhs.is_head)
154 .then(rhs.unix_timestamp.cmp(&lhs.unix_timestamp))
155 });
156 branches.truncate(RECENT_BRANCHES_COUNT);
157 }
158 branches.sort_unstable_by(|lhs, rhs| {
159 rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
160 });
161 }
162 branches
163 .into_iter()
164 .enumerate()
165 .map(|(ix, command)| StringMatchCandidate {
166 id: ix,
167 char_bag: command.name.chars().collect(),
168 string: command.name.into(),
169 })
170 .collect::<Vec<StringMatchCandidate>>()
171 });
172 let Some(candidates) = candidates.log_err() else {
173 return;
174 };
175 let matches = if query.is_empty() {
176 candidates
177 .into_iter()
178 .enumerate()
179 .map(|(index, candidate)| StringMatch {
180 candidate_id: index,
181 string: candidate.string,
182 positions: Vec::new(),
183 score: 0.0,
184 })
185 .collect()
186 } else {
187 fuzzy::match_strings(
188 &candidates,
189 &query,
190 true,
191 10000,
192 &Default::default(),
193 cx.background_executor().clone(),
194 )
195 .await
196 };
197 picker
198 .update(&mut cx, |picker, _| {
199 let delegate = &mut picker.delegate;
200 delegate.matches = matches;
201 if delegate.matches.is_empty() {
202 delegate.selected_index = 0;
203 } else {
204 delegate.selected_index =
205 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
206 }
207 delegate.last_query = query;
208 })
209 .log_err();
210 })
211 }
212
213 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
214 let current_pick = self.selected_index();
215 let Some(current_pick) = self
216 .matches
217 .get(current_pick)
218 .map(|pick| pick.string.clone())
219 else {
220 return;
221 };
222 cx.spawn(|picker, mut cx| async move {
223 picker
224 .update(&mut cx, |this, cx| {
225 let project = this.delegate.workspace.read(cx).project().read(cx);
226 let repo = project
227 .get_first_worktree_root_repo(cx)
228 .context("failed to get root repository for first worktree")?;
229 let status = repo
230 .change_branch(¤t_pick);
231 if status.is_err() {
232 this.delegate.display_error_toast(format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"), cx);
233 status?;
234 }
235 cx.emit(DismissEvent);
236
237 Ok::<(), anyhow::Error>(())
238 })
239 .log_err();
240 })
241 .detach();
242 }
243
244 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
245 cx.emit(DismissEvent);
246 }
247
248 fn render_match(
249 &self,
250 ix: usize,
251 selected: bool,
252 _cx: &mut ViewContext<Picker<Self>>,
253 ) -> Option<Self::ListItem> {
254 let hit = &self.matches[ix];
255 let shortened_branch_name =
256 util::truncate_and_trailoff(&hit.string, self.branch_name_trailoff_after);
257 let highlights: Vec<_> = hit
258 .positions
259 .iter()
260 .filter(|index| index < &&self.branch_name_trailoff_after)
261 .copied()
262 .collect();
263 Some(
264 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
265 .inset(true)
266 .spacing(ListItemSpacing::Sparse)
267 .selected(selected)
268 .start_slot(HighlightedLabel::new(shortened_branch_name, highlights)),
269 )
270 }
271 fn render_header(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
272 let label = if self.last_query.is_empty() {
273 h_flex()
274 .ml_3()
275 .child(Label::new("Recent Branches").size(LabelSize::Small))
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 };
289 Some(label.mt_1().into_any())
290 }
291 fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
292 if self.last_query.is_empty() {
293 return None;
294 }
295
296 Some(
297 h_flex().mr_3().pb_2().child(h_flex().w_full()).child(
298 Button::new("branch-picker-create-branch-button", "Create branch").on_click(
299 cx.listener(|_, _, cx| {
300 cx.spawn(|picker, mut cx| async move {
301 picker.update(&mut cx, |this, cx| {
302 let project = this.delegate.workspace.read(cx).project().read(cx);
303 let current_pick = &this.delegate.last_query;
304 let repo = project
305 .get_first_worktree_root_repo(cx)
306 .context("failed to get root repository for first worktree")?;
307 let status = repo
308 .create_branch(¤t_pick);
309 if status.is_err() {
310 this.delegate.display_error_toast(format!("Failed to create branch '{current_pick}', check for conflicts or unstashed files"), cx);
311 status?;
312 }
313 let status = repo.change_branch(¤t_pick);
314 if status.is_err() {
315 this.delegate.display_error_toast(format!("Failed to check branch '{current_pick}', check for conflicts or unstashed files"), cx);
316 status?;
317 }
318 this.cancel(&Default::default(), cx);
319 Ok::<(), anyhow::Error>(())
320 })
321
322 }).detach_and_log_err(cx);
323 }),
324 ).style(ui::ButtonStyle::Filled)).into_any_element(),
325 )
326 }
327}