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