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 ParentElement, Render, SharedString, Styled, Task, View, ViewContext, VisualContext,
7 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 ModalBranchList::toggle_modal(workspace, action, cx).log_err();
22 });
23 })
24 .detach();
25}
26pub type BranchList = Picker<BranchListDelegate>;
27
28pub struct ModalBranchList {
29 pub picker: View<Picker<BranchListDelegate>>,
30}
31
32impl ModalView for ModalBranchList {}
33impl EventEmitter<DismissEvent> for ModalBranchList {}
34
35impl FocusableView for ModalBranchList {
36 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
37 self.picker.focus_handle(cx)
38 }
39}
40
41impl Render for ModalBranchList {
42 type Element = Div;
43
44 fn render(&mut self, _cx: &mut ViewContext<Self>) -> Self::Element {
45 v_stack().w(rems(34.)).child(self.picker.clone())
46 }
47}
48
49pub fn build_branch_list(
50 workspace: View<Workspace>,
51 cx: &mut WindowContext<'_>,
52) -> Result<View<BranchList>> {
53 let delegate = workspace.update(cx, |workspace, cx| {
54 BranchListDelegate::new(workspace, cx.view().clone(), 29, cx)
55 })?;
56
57 Ok(cx.build_view(|cx| Picker::new(delegate, cx)))
58}
59
60impl ModalBranchList {
61 fn toggle_modal(
62 workspace: &mut Workspace,
63 _: &OpenRecent,
64 cx: &mut ViewContext<Workspace>,
65 ) -> Result<()> {
66 // Modal branch picker has a longer trailoff than a popover one.
67 let delegate = BranchListDelegate::new(workspace, cx.view().clone(), 70, cx)?;
68 workspace.toggle_modal(cx, |cx| {
69 let modal = ModalBranchList {
70 picker: cx.build_view(|cx| Picker::new(delegate, cx)),
71 };
72 cx.subscribe(&modal.picker, |_, _, _, cx| cx.emit(DismissEvent))
73 .detach();
74 modal
75 });
76
77 Ok(())
78 }
79}
80
81pub struct BranchListDelegate {
82 matches: Vec<StringMatch>,
83 all_branches: Vec<Branch>,
84 workspace: View<Workspace>,
85 selected_index: usize,
86 last_query: String,
87 /// Max length of branch name before we truncate it and add a trailing `...`.
88 branch_name_trailoff_after: usize,
89}
90
91impl BranchListDelegate {
92 fn new(
93 workspace: &Workspace,
94 handle: View<Workspace>,
95 branch_name_trailoff_after: usize,
96 cx: &AppContext,
97 ) -> Result<Self> {
98 let project = workspace.project().read(&cx);
99 let Some(worktree) = project.visible_worktrees(cx).next() else {
100 bail!("Cannot update branch list as there are no visible worktrees")
101 };
102
103 let mut cwd = worktree.read(cx).abs_path().to_path_buf();
104 cwd.push(".git");
105 let Some(repo) = project.fs().open_repo(&cwd) else {
106 bail!("Project does not have associated git repository.")
107 };
108 let all_branches = repo.lock().branches()?;
109 Ok(Self {
110 matches: vec![],
111 workspace: handle,
112 all_branches,
113 selected_index: 0,
114 last_query: Default::default(),
115 branch_name_trailoff_after,
116 })
117 }
118
119 fn display_error_toast(&self, message: String, cx: &mut ViewContext<BranchList>) {
120 const GIT_CHECKOUT_FAILURE_ID: usize = 2048;
121 self.workspace.update(cx, |model, ctx| {
122 model.show_toast(Toast::new(GIT_CHECKOUT_FAILURE_ID, message), ctx)
123 });
124 }
125}
126
127impl PickerDelegate for BranchListDelegate {
128 type ListItem = ListItem;
129 fn placeholder_text(&self) -> Arc<str> {
130 "Select branch...".into()
131 }
132
133 fn match_count(&self) -> usize {
134 self.matches.len()
135 }
136
137 fn selected_index(&self) -> usize {
138 self.selected_index
139 }
140
141 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
142 self.selected_index = ix;
143 }
144
145 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
146 cx.spawn(move |picker, mut cx| async move {
147 let candidates = picker.update(&mut cx, |view, _| {
148 const RECENT_BRANCHES_COUNT: usize = 10;
149 let mut branches = view.delegate.all_branches.clone();
150 if query.is_empty() && branches.len() > RECENT_BRANCHES_COUNT {
151 // Truncate list of recent branches
152 // Do a partial sort to show recent-ish branches first.
153 branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
154 rhs.unix_timestamp.cmp(&lhs.unix_timestamp)
155 });
156 branches.truncate(RECENT_BRANCHES_COUNT);
157 branches.sort_unstable_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
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 mut cwd = project
224 .visible_worktrees(cx)
225 .next()
226 .ok_or_else(|| anyhow!("There are no visisible worktrees."))?
227 .read(cx)
228 .abs_path()
229 .to_path_buf();
230 cwd.push(".git");
231 let status = project
232 .fs()
233 .open_repo(&cwd)
234 .ok_or_else(|| {
235 anyhow!(
236 "Could not open repository at path `{}`",
237 cwd.as_os_str().to_string_lossy()
238 )
239 })?
240 .lock()
241 .change_branch(¤t_pick);
242 if status.is_err() {
243 this.delegate.display_error_toast(format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"), cx);
244 status?;
245 }
246 cx.emit(DismissEvent);
247
248 Ok::<(), anyhow::Error>(())
249 })
250 .log_err();
251 })
252 .detach();
253 }
254
255 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
256 cx.emit(DismissEvent);
257 }
258
259 fn render_match(
260 &self,
261 ix: usize,
262 selected: bool,
263 _cx: &mut ViewContext<Picker<Self>>,
264 ) -> Option<Self::ListItem> {
265 let hit = &self.matches[ix];
266 let shortened_branch_name =
267 util::truncate_and_trailoff(&hit.string, self.branch_name_trailoff_after);
268 let highlights: Vec<_> = hit
269 .positions
270 .iter()
271 .filter(|index| index < &&self.branch_name_trailoff_after)
272 .copied()
273 .collect();
274 Some(
275 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
276 .start_slot(HighlightedLabel::new(shortened_branch_name, highlights))
277 .selected(selected),
278 )
279 }
280 // fn render_header(
281 // &self,
282 // cx: &mut ViewContext<Picker<Self>>,
283 // ) -> Option<AnyElement<Picker<Self>>> {
284 // let theme = &theme::current(cx);
285 // let style = theme.picker.header.clone();
286 // let label = if self.last_query.is_empty() {
287 // Flex::row()
288 // .with_child(Label::new("Recent branches", style.label.clone()))
289 // .contained()
290 // .with_style(style.container)
291 // } else {
292 // Flex::row()
293 // .with_child(Label::new("Branches", style.label.clone()))
294 // .with_children(self.matches.is_empty().not().then(|| {
295 // let suffix = if self.matches.len() == 1 { "" } else { "es" };
296 // Label::new(
297 // format!("{} match{}", self.matches.len(), suffix),
298 // style.label,
299 // )
300 // .flex_float()
301 // }))
302 // .contained()
303 // .with_style(style.container)
304 // };
305 // Some(label.into_any())
306 // }
307 // fn render_footer(
308 // &self,
309 // cx: &mut ViewContext<Picker<Self>>,
310 // ) -> Option<AnyElement<Picker<Self>>> {
311 // if !self.last_query.is_empty() {
312 // let theme = &theme::current(cx);
313 // let style = theme.picker.footer.clone();
314 // enum BranchCreateButton {}
315 // Some(
316 // Flex::row().with_child(MouseEventHandler::new::<BranchCreateButton, _>(0, cx, |state, _| {
317 // let style = style.style_for(state);
318 // Label::new("Create branch", style.label.clone())
319 // .contained()
320 // .with_style(style.container)
321 // })
322 // .with_cursor_style(CursorStyle::PointingHand)
323 // .on_down(MouseButton::Left, |_, _, cx| {
324 // cx.spawn(|picker, mut cx| async move {
325 // picker.update(&mut cx, |this, cx| {
326 // let project = this.delegate().workspace.read(cx).project().read(cx);
327 // let current_pick = &this.delegate().last_query;
328 // let mut cwd = project
329 // .visible_worktrees(cx)
330 // .next()
331 // .ok_or_else(|| anyhow!("There are no visisible worktrees."))?
332 // .read(cx)
333 // .abs_path()
334 // .to_path_buf();
335 // cwd.push(".git");
336 // let repo = project
337 // .fs()
338 // .open_repo(&cwd)
339 // .ok_or_else(|| anyhow!("Could not open repository at path `{}`", cwd.as_os_str().to_string_lossy()))?;
340 // let repo = repo
341 // .lock();
342 // let status = repo
343 // .create_branch(¤t_pick);
344 // if status.is_err() {
345 // this.delegate().display_error_toast(format!("Failed to create branch '{current_pick}', check for conflicts or unstashed files"), cx);
346 // status?;
347 // }
348 // let status = repo.change_branch(¤t_pick);
349 // if status.is_err() {
350 // this.delegate().display_error_toast(format!("Failed to chec branch '{current_pick}', check for conflicts or unstashed files"), cx);
351 // status?;
352 // }
353 // cx.emit(PickerEvent::Dismiss);
354 // Ok::<(), anyhow::Error>(())
355 // })
356 // }).detach();
357 // })).aligned().right()
358 // .into_any(),
359 // )
360 // } else {
361 // None
362 // }
363 // }
364}