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