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