lib.rs

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