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