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, secondary: bool, 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 = if secondary {
126 workspace.split_project_item::<Editor>(buffer, cx)
127 } else {
128 workspace.open_project_item::<Editor>(buffer, cx)
129 };
130
131 editor.update(cx, |editor, cx| {
132 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
133 s.select_ranges([position..position])
134 });
135 });
136 })?;
137 Ok::<_, anyhow::Error>(())
138 })
139 .detach_and_log_err(cx);
140 cx.emit(PickerEvent::Dismiss);
141 }
142 }
143
144 fn dismissed(&mut self, _cx: &mut ViewContext<ProjectSymbols>) {}
145
146 fn match_count(&self) -> usize {
147 self.matches.len()
148 }
149
150 fn selected_index(&self) -> usize {
151 self.selected_match_index
152 }
153
154 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<ProjectSymbols>) {
155 self.selected_match_index = ix;
156 }
157
158 fn update_matches(&mut self, query: String, cx: &mut ViewContext<ProjectSymbols>) -> Task<()> {
159 self.filter(&query, cx);
160 self.show_worktree_root_name = self.project.read(cx).visible_worktrees(cx).count() > 1;
161 let symbols = self
162 .project
163 .update(cx, |project, cx| project.symbols(&query, cx));
164 cx.spawn(|this, mut cx| async move {
165 let symbols = symbols.await.log_err();
166 if let Some(symbols) = symbols {
167 this.update(&mut cx, |this, cx| {
168 let delegate = this.delegate_mut();
169 let project = delegate.project.read(cx);
170 let (visible_match_candidates, external_match_candidates) = symbols
171 .iter()
172 .enumerate()
173 .map(|(id, symbol)| {
174 StringMatchCandidate::new(
175 id,
176 symbol.label.text[symbol.label.filter_range.clone()].to_string(),
177 )
178 })
179 .partition(|candidate| {
180 project
181 .entry_for_path(&symbols[candidate.id].path, cx)
182 .map_or(false, |e| !e.is_ignored)
183 });
184
185 delegate.visible_match_candidates = visible_match_candidates;
186 delegate.external_match_candidates = external_match_candidates;
187 delegate.symbols = symbols;
188 delegate.filter(&query, cx);
189 })
190 .log_err();
191 }
192 })
193 }
194
195 fn render_match(
196 &self,
197 ix: usize,
198 mouse_state: &mut MouseState,
199 selected: bool,
200 cx: &AppContext,
201 ) -> AnyElement<Picker<Self>> {
202 let theme = theme::current(cx);
203 let style = &theme.picker.item;
204 let current_style = style.in_state(selected).style_for(mouse_state);
205
206 let string_match = &self.matches[ix];
207 let symbol = &self.symbols[string_match.candidate_id];
208 let syntax_runs = styled_runs_for_code_label(&symbol.label, &theme.editor.syntax);
209
210 let mut path = symbol.path.path.to_string_lossy();
211 if self.show_worktree_root_name {
212 let project = self.project.read(cx);
213 if let Some(worktree) = project.worktree_for_id(symbol.path.worktree_id, cx) {
214 path = Cow::Owned(format!(
215 "{}{}{}",
216 worktree.read(cx).root_name(),
217 std::path::MAIN_SEPARATOR,
218 path.as_ref()
219 ));
220 }
221 }
222
223 Flex::column()
224 .with_child(
225 Text::new(symbol.label.text.clone(), current_style.label.text.clone())
226 .with_soft_wrap(false)
227 .with_highlights(combine_syntax_and_fuzzy_match_highlights(
228 &symbol.label.text,
229 current_style.label.text.clone().into(),
230 syntax_runs,
231 &string_match.positions,
232 )),
233 )
234 .with_child(
235 // Avoid styling the path differently when it is selected, since
236 // the symbol's syntax highlighting doesn't change when selected.
237 Label::new(
238 path.to_string(),
239 style.inactive_state().default.label.clone(),
240 ),
241 )
242 .contained()
243 .with_style(current_style.container)
244 .into_any()
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use futures::StreamExt;
252 use gpui::{serde_json::json, TestAppContext};
253 use language::{FakeLspAdapter, Language, LanguageConfig};
254 use project::FakeFs;
255 use settings::SettingsStore;
256 use std::{path::Path, sync::Arc};
257
258 #[gpui::test]
259 async fn test_project_symbols(cx: &mut TestAppContext) {
260 init_test(cx);
261
262 let mut language = Language::new(
263 LanguageConfig {
264 name: "Rust".into(),
265 path_suffixes: vec!["rs".to_string()],
266 ..Default::default()
267 },
268 None,
269 );
270 let mut fake_servers = language
271 .set_fake_lsp_adapter(Arc::<FakeLspAdapter>::default())
272 .await;
273
274 let fs = FakeFs::new(cx.background());
275 fs.insert_tree("/dir", json!({ "test.rs": "" })).await;
276
277 let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
278 project.update(cx, |project, _| project.languages().add(Arc::new(language)));
279
280 let _buffer = project
281 .update(cx, |project, cx| {
282 project.open_local_buffer("/dir/test.rs", cx)
283 })
284 .await
285 .unwrap();
286
287 // Set up fake language server to return fuzzy matches against
288 // a fixed set of symbol names.
289 let fake_symbols = [
290 symbol("one", "/external"),
291 symbol("ton", "/dir/test.rs"),
292 symbol("uno", "/dir/test.rs"),
293 ];
294 let fake_server = fake_servers.next().await.unwrap();
295 fake_server.handle_request::<lsp::WorkspaceSymbolRequest, _, _>(
296 move |params: lsp::WorkspaceSymbolParams, cx| {
297 let executor = cx.background();
298 let fake_symbols = fake_symbols.clone();
299 async move {
300 let candidates = fake_symbols
301 .iter()
302 .enumerate()
303 .map(|(id, symbol)| StringMatchCandidate::new(id, symbol.name.clone()))
304 .collect::<Vec<_>>();
305 let matches = if params.query.is_empty() {
306 Vec::new()
307 } else {
308 fuzzy::match_strings(
309 &candidates,
310 ¶ms.query,
311 true,
312 100,
313 &Default::default(),
314 executor.clone(),
315 )
316 .await
317 };
318
319 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(
320 matches
321 .into_iter()
322 .map(|mat| fake_symbols[mat.candidate_id].clone())
323 .collect(),
324 )))
325 }
326 },
327 );
328
329 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
330
331 // Create the project symbols view.
332 let symbols = cx.add_view(window_id, |cx| {
333 ProjectSymbols::new(
334 ProjectSymbolsDelegate::new(workspace.downgrade(), project.clone()),
335 cx,
336 )
337 });
338
339 // Spawn multiples updates before the first update completes,
340 // such that in the end, there are no matches. Testing for regression:
341 // https://github.com/zed-industries/zed/issues/861
342 symbols.update(cx, |p, cx| {
343 p.update_matches("o".to_string(), cx);
344 p.update_matches("on".to_string(), cx);
345 p.update_matches("onex".to_string(), cx);
346 });
347
348 cx.foreground().run_until_parked();
349 symbols.read_with(cx, |symbols, _| {
350 assert_eq!(symbols.delegate().matches.len(), 0);
351 });
352
353 // Spawn more updates such that in the end, there are matches.
354 symbols.update(cx, |p, cx| {
355 p.update_matches("one".to_string(), cx);
356 p.update_matches("on".to_string(), cx);
357 });
358
359 cx.foreground().run_until_parked();
360 symbols.read_with(cx, |symbols, _| {
361 let delegate = symbols.delegate();
362 assert_eq!(delegate.matches.len(), 2);
363 assert_eq!(delegate.matches[0].string, "ton");
364 assert_eq!(delegate.matches[1].string, "one");
365 });
366
367 // Spawn more updates such that in the end, there are again no matches.
368 symbols.update(cx, |p, cx| {
369 p.update_matches("o".to_string(), cx);
370 p.update_matches("".to_string(), cx);
371 });
372
373 cx.foreground().run_until_parked();
374 symbols.read_with(cx, |symbols, _| {
375 assert_eq!(symbols.delegate().matches.len(), 0);
376 });
377 }
378
379 fn init_test(cx: &mut TestAppContext) {
380 cx.foreground().forbid_parking();
381 cx.update(|cx| {
382 cx.set_global(SettingsStore::test(cx));
383 theme::init((), cx);
384 language::init(cx);
385 Project::init_settings(cx);
386 workspace::init_settings(cx);
387 });
388 }
389
390 fn symbol(name: &str, path: impl AsRef<Path>) -> lsp::SymbolInformation {
391 #[allow(deprecated)]
392 lsp::SymbolInformation {
393 name: name.to_string(),
394 kind: lsp::SymbolKind::FUNCTION,
395 tags: None,
396 deprecated: None,
397 container_name: None,
398 location: lsp::Location::new(
399 lsp::Url::from_file_path(path.as_ref()).unwrap(),
400 lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)),
401 ),
402 }
403 }
404}