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