lib.rs

  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(&current_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(&current_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(&current_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}