lib.rs

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