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