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(¤t_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}