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