1use editor::{
2 combine_syntax_and_fuzzy_match_highlights, scroll::autoscroll::Autoscroll,
3 styled_runs_for_code_label, Bias, Editor,
4};
5use fuzzy::{StringMatch, StringMatchCandidate};
6use gpui::{
7 actions, elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext, WeakViewHandle,
8};
9use ordered_float::OrderedFloat;
10use picker::{Picker, PickerDelegate, PickerEvent};
11use project::{Project, Symbol};
12use settings::Settings;
13use std::{borrow::Cow, cmp::Reverse, sync::Arc};
14use util::ResultExt;
15use workspace::Workspace;
16
17actions!(project_symbols, [Toggle]);
18
19pub fn init(cx: &mut AppContext) {
20 cx.add_action(toggle);
21 ProjectSymbols::init(cx);
22}
23
24fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
25 workspace.toggle_modal(cx, |workspace, cx| {
26 let project = workspace.project().clone();
27 let workspace = cx.weak_handle();
28 cx.add_view(|cx| ProjectSymbols::new(ProjectSymbolsDelegate::new(workspace, project), cx))
29 });
30}
31
32pub type ProjectSymbols = Picker<ProjectSymbolsDelegate>;
33
34pub struct ProjectSymbolsDelegate {
35 workspace: WeakViewHandle<Workspace>,
36 project: ModelHandle<Project>,
37 selected_match_index: usize,
38 symbols: Vec<Symbol>,
39 visible_match_candidates: Vec<StringMatchCandidate>,
40 external_match_candidates: Vec<StringMatchCandidate>,
41 show_worktree_root_name: bool,
42 matches: Vec<StringMatch>,
43}
44
45impl ProjectSymbolsDelegate {
46 fn new(workspace: WeakViewHandle<Workspace>, project: ModelHandle<Project>) -> Self {
47 Self {
48 workspace,
49 project,
50 selected_match_index: 0,
51 symbols: Default::default(),
52 visible_match_candidates: Default::default(),
53 external_match_candidates: Default::default(),
54 matches: Default::default(),
55 show_worktree_root_name: false,
56 }
57 }
58
59 fn filter(&mut self, query: &str, cx: &mut ViewContext<ProjectSymbols>) {
60 const MAX_MATCHES: usize = 100;
61 let mut visible_matches = cx.background_executor().block(fuzzy::match_strings(
62 &self.visible_match_candidates,
63 query,
64 false,
65 MAX_MATCHES,
66 &Default::default(),
67 cx.background().clone(),
68 ));
69 let mut external_matches = cx.background_executor().block(fuzzy::match_strings(
70 &self.external_match_candidates,
71 query,
72 false,
73 MAX_MATCHES - visible_matches.len(),
74 &Default::default(),
75 cx.background().clone(),
76 ));
77 let sort_key_for_match = |mat: &StringMatch| {
78 let symbol = &self.symbols[mat.candidate_id];
79 (
80 Reverse(OrderedFloat(mat.score)),
81 &symbol.label.text[symbol.label.filter_range.clone()],
82 )
83 };
84
85 visible_matches.sort_unstable_by_key(sort_key_for_match);
86 external_matches.sort_unstable_by_key(sort_key_for_match);
87 let mut matches = visible_matches;
88 matches.append(&mut external_matches);
89
90 for mat in &mut matches {
91 let symbol = &self.symbols[mat.candidate_id];
92 let filter_start = symbol.label.filter_range.start;
93 for position in &mut mat.positions {
94 *position += filter_start;
95 }
96 }
97
98 self.matches = matches;
99 self.set_selected_index(0, cx);
100 }
101}
102
103impl PickerDelegate for ProjectSymbolsDelegate {
104 fn placeholder_text(&self) -> Arc<str> {
105 "Search project symbols...".into()
106 }
107
108 fn confirm(&mut self, cx: &mut ViewContext<ProjectSymbols>) {
109 if let Some(symbol) = self
110 .matches
111 .get(self.selected_match_index)
112 .map(|mat| self.symbols[mat.candidate_id].clone())
113 {
114 let buffer = self.project.update(cx, |project, cx| {
115 project.open_buffer_for_symbol(&symbol, cx)
116 });
117 let symbol = symbol.clone();
118 let workspace = self.workspace.clone();
119 cx.spawn(|_, mut cx| async move {
120 let buffer = buffer.await?;
121 workspace.update(&mut cx, |workspace, cx| {
122 let position = buffer
123 .read(cx)
124 .clip_point_utf16(symbol.range.start, Bias::Left);
125
126 let editor = workspace.open_project_item::<Editor>(buffer, cx);
127 editor.update(cx, |editor, cx| {
128 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
129 s.select_ranges([position..position])
130 });
131 });
132 })?;
133 Ok::<_, anyhow::Error>(())
134 })
135 .detach_and_log_err(cx);
136 cx.emit(PickerEvent::Dismiss);
137 }
138 }
139
140 fn dismissed(&mut self, _cx: &mut ViewContext<ProjectSymbols>) {}
141
142 fn match_count(&self) -> usize {
143 self.matches.len()
144 }
145
146 fn selected_index(&self) -> usize {
147 self.selected_match_index
148 }
149
150 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<ProjectSymbols>) {
151 self.selected_match_index = ix;
152 }
153
154 fn update_matches(&mut self, query: String, cx: &mut ViewContext<ProjectSymbols>) -> Task<()> {
155 self.filter(&query, cx);
156 self.show_worktree_root_name = self.project.read(cx).visible_worktrees(cx).count() > 1;
157 let symbols = self
158 .project
159 .update(cx, |project, cx| project.symbols(&query, cx));
160 cx.spawn(|this, mut cx| async move {
161 let symbols = symbols.await.log_err();
162 if let Some(symbols) = symbols {
163 this.update(&mut cx, |this, cx| {
164 let delegate = this.delegate_mut();
165 let project = delegate.project.read(cx);
166 let (visible_match_candidates, external_match_candidates) = symbols
167 .iter()
168 .enumerate()
169 .map(|(id, symbol)| {
170 StringMatchCandidate::new(
171 id,
172 symbol.label.text[symbol.label.filter_range.clone()].to_string(),
173 )
174 })
175 .partition(|candidate| {
176 project
177 .entry_for_path(&symbols[candidate.id].path, cx)
178 .map_or(false, |e| !e.is_ignored)
179 });
180
181 delegate.visible_match_candidates = visible_match_candidates;
182 delegate.external_match_candidates = external_match_candidates;
183 delegate.symbols = symbols;
184 delegate.filter(&query, cx);
185 })
186 .log_err();
187 }
188 })
189 }
190
191 fn render_match(
192 &self,
193 ix: usize,
194 mouse_state: &mut MouseState,
195 selected: bool,
196 cx: &AppContext,
197 ) -> AnyElement<Picker<Self>> {
198 let string_match = &self.matches[ix];
199 let settings = cx.global::<Settings>();
200 let style = &settings.theme.picker.item;
201 let current_style = style.style_for(mouse_state, selected);
202 let symbol = &self.symbols[string_match.candidate_id];
203 let syntax_runs = styled_runs_for_code_label(&symbol.label, &settings.theme.editor.syntax);
204
205 let mut path = symbol.path.path.to_string_lossy();
206 if self.show_worktree_root_name {
207 let project = self.project.read(cx);
208 if let Some(worktree) = project.worktree_for_id(symbol.path.worktree_id, cx) {
209 path = Cow::Owned(format!(
210 "{}{}{}",
211 worktree.read(cx).root_name(),
212 std::path::MAIN_SEPARATOR,
213 path.as_ref()
214 ));
215 }
216 }
217
218 Flex::column()
219 .with_child(
220 Text::new(symbol.label.text.clone(), current_style.label.text.clone())
221 .with_soft_wrap(false)
222 .with_highlights(combine_syntax_and_fuzzy_match_highlights(
223 &symbol.label.text,
224 current_style.label.text.clone().into(),
225 syntax_runs,
226 &string_match.positions,
227 )),
228 )
229 .with_child(
230 // Avoid styling the path differently when it is selected, since
231 // the symbol's syntax highlighting doesn't change when selected.
232 Label::new(path.to_string(), style.default.label.clone()),
233 )
234 .contained()
235 .with_style(current_style.container)
236 .into_any()
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use futures::StreamExt;
244 use gpui::{serde_json::json, TestAppContext};
245 use language::{FakeLspAdapter, Language, LanguageConfig};
246 use project::FakeFs;
247 use std::{path::Path, sync::Arc};
248
249 #[gpui::test]
250 async fn test_project_symbols(cx: &mut TestAppContext) {
251 cx.foreground().forbid_parking();
252 cx.update(|cx| cx.set_global(Settings::test(cx)));
253
254 let mut language = Language::new(
255 LanguageConfig {
256 name: "Rust".into(),
257 path_suffixes: vec!["rs".to_string()],
258 ..Default::default()
259 },
260 None,
261 );
262 let mut fake_servers = language
263 .set_fake_lsp_adapter(Arc::<FakeLspAdapter>::default())
264 .await;
265
266 let fs = FakeFs::new(cx.background());
267 fs.insert_tree("/dir", json!({ "test.rs": "" })).await;
268
269 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
270 project.update(cx, |project, _| project.languages().add(Arc::new(language)));
271
272 let _buffer = project
273 .update(cx, |project, cx| {
274 project.open_local_buffer("/dir/test.rs", cx)
275 })
276 .await
277 .unwrap();
278
279 // Set up fake langauge server to return fuzzy matches against
280 // a fixed set of symbol names.
281 let fake_symbols = [
282 symbol("one", "/external"),
283 symbol("ton", "/dir/test.rs"),
284 symbol("uno", "/dir/test.rs"),
285 ];
286 let fake_server = fake_servers.next().await.unwrap();
287 fake_server.handle_request::<lsp::request::WorkspaceSymbol, _, _>(
288 move |params: lsp::WorkspaceSymbolParams, cx| {
289 let executor = cx.background();
290 let fake_symbols = fake_symbols.clone();
291 async move {
292 let candidates = fake_symbols
293 .iter()
294 .enumerate()
295 .map(|(id, symbol)| StringMatchCandidate::new(id, symbol.name.clone()))
296 .collect::<Vec<_>>();
297 let matches = if params.query.is_empty() {
298 Vec::new()
299 } else {
300 fuzzy::match_strings(
301 &candidates,
302 ¶ms.query,
303 true,
304 100,
305 &Default::default(),
306 executor.clone(),
307 )
308 .await
309 };
310
311 Ok(Some(
312 matches
313 .into_iter()
314 .map(|mat| fake_symbols[mat.candidate_id].clone())
315 .collect(),
316 ))
317 }
318 },
319 );
320
321 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
322
323 // Create the project symbols view.
324 let symbols = cx.add_view(window_id, |cx| {
325 ProjectSymbols::new(
326 ProjectSymbolsDelegate::new(workspace.downgrade(), project.clone()),
327 cx,
328 )
329 });
330
331 // Spawn multiples updates before the first update completes,
332 // such that in the end, there are no matches. Testing for regression:
333 // https://github.com/zed-industries/zed/issues/861
334 symbols.update(cx, |p, cx| {
335 p.update_matches("o".to_string(), cx);
336 p.update_matches("on".to_string(), cx);
337 p.update_matches("onex".to_string(), cx);
338 });
339
340 cx.foreground().run_until_parked();
341 symbols.read_with(cx, |symbols, _| {
342 assert_eq!(symbols.delegate().matches.len(), 0);
343 });
344
345 // Spawn more updates such that in the end, there are matches.
346 symbols.update(cx, |p, cx| {
347 p.update_matches("one".to_string(), cx);
348 p.update_matches("on".to_string(), cx);
349 });
350
351 cx.foreground().run_until_parked();
352 symbols.read_with(cx, |symbols, _| {
353 let delegate = symbols.delegate();
354 assert_eq!(delegate.matches.len(), 2);
355 assert_eq!(delegate.matches[0].string, "ton");
356 assert_eq!(delegate.matches[1].string, "one");
357 });
358
359 // Spawn more updates such that in the end, there are again no matches.
360 symbols.update(cx, |p, cx| {
361 p.update_matches("o".to_string(), cx);
362 p.update_matches("".to_string(), cx);
363 });
364
365 cx.foreground().run_until_parked();
366 symbols.read_with(cx, |symbols, _| {
367 assert_eq!(symbols.delegate().matches.len(), 0);
368 });
369 }
370
371 fn symbol(name: &str, path: impl AsRef<Path>) -> lsp::SymbolInformation {
372 #[allow(deprecated)]
373 lsp::SymbolInformation {
374 name: name.to_string(),
375 kind: lsp::SymbolKind::FUNCTION,
376 tags: None,
377 deprecated: None,
378 container_name: None,
379 location: lsp::Location::new(
380 lsp::Url::from_file_path(path.as_ref()).unwrap(),
381 lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)),
382 ),
383 }
384 }
385}