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