1use editor::{Bias, Editor, SelectionEffects, scroll::Autoscroll, styled_runs_for_code_label};
2use fuzzy::{StringMatch, StringMatchCandidate};
3use gpui::{
4 App, Context, DismissEvent, Entity, FontWeight, ParentElement, StyledText, Task, WeakEntity,
5 Window, rems,
6};
7use ordered_float::OrderedFloat;
8use picker::{Picker, PickerDelegate};
9use project::{Project, Symbol};
10use std::{borrow::Cow, cmp::Reverse, sync::Arc};
11use theme::ActiveTheme;
12use util::ResultExt;
13use workspace::{
14 Workspace,
15 ui::{Color, Label, LabelCommon, LabelLike, ListItem, ListItemSpacing, Toggleable, v_flex},
16};
17
18pub fn init(cx: &mut App) {
19 cx.observe_new(
20 |workspace: &mut Workspace, _window, _: &mut Context<Workspace>| {
21 workspace.register_action(
22 |workspace, _: &workspace::ToggleProjectSymbols, window, cx| {
23 let project = workspace.project().clone();
24 let handle = cx.entity().downgrade();
25 workspace.toggle_modal(window, cx, move |window, cx| {
26 let delegate = ProjectSymbolsDelegate::new(handle, project);
27 Picker::uniform_list(delegate, window, cx).width(rems(34.))
28 })
29 },
30 );
31 },
32 )
33 .detach();
34}
35
36pub type ProjectSymbols = Entity<Picker<ProjectSymbolsDelegate>>;
37
38pub struct ProjectSymbolsDelegate {
39 workspace: WeakEntity<Workspace>,
40 project: Entity<Project>,
41 selected_match_index: usize,
42 symbols: Vec<Symbol>,
43 visible_match_candidates: Vec<StringMatchCandidate>,
44 external_match_candidates: Vec<StringMatchCandidate>,
45 show_worktree_root_name: bool,
46 matches: Vec<StringMatch>,
47}
48
49impl ProjectSymbolsDelegate {
50 fn new(workspace: WeakEntity<Workspace>, project: Entity<Project>) -> Self {
51 Self {
52 workspace,
53 project,
54 selected_match_index: 0,
55 symbols: Default::default(),
56 visible_match_candidates: Default::default(),
57 external_match_candidates: Default::default(),
58 matches: Default::default(),
59 show_worktree_root_name: false,
60 }
61 }
62
63 fn filter(&mut self, query: &str, window: &mut Window, cx: &mut Context<Picker<Self>>) {
64 const MAX_MATCHES: usize = 100;
65 let mut visible_matches = cx.background_executor().block(fuzzy::match_strings(
66 &self.visible_match_candidates,
67 query,
68 false,
69 true,
70 MAX_MATCHES,
71 &Default::default(),
72 cx.background_executor().clone(),
73 ));
74 let mut external_matches = cx.background_executor().block(fuzzy::match_strings(
75 &self.external_match_candidates,
76 query,
77 false,
78 true,
79 MAX_MATCHES - visible_matches.len().min(MAX_MATCHES),
80 &Default::default(),
81 cx.background_executor().clone(),
82 ));
83 let sort_key_for_match = |mat: &StringMatch| {
84 let symbol = &self.symbols[mat.candidate_id];
85 (Reverse(OrderedFloat(mat.score)), symbol.label.filter_text())
86 };
87
88 visible_matches.sort_unstable_by_key(sort_key_for_match);
89 external_matches.sort_unstable_by_key(sort_key_for_match);
90 let mut matches = visible_matches;
91 matches.append(&mut external_matches);
92
93 for mat in &mut matches {
94 let symbol = &self.symbols[mat.candidate_id];
95 let filter_start = symbol.label.filter_range.start;
96 for position in &mut mat.positions {
97 *position += filter_start;
98 }
99 }
100
101 self.matches = matches;
102 self.set_selected_index(0, window, cx);
103 }
104}
105
106impl PickerDelegate for ProjectSymbolsDelegate {
107 type ListItem = ListItem;
108 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
109 "Search project symbols...".into()
110 }
111
112 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
113 if let Some(symbol) = self
114 .matches
115 .get(self.selected_match_index)
116 .map(|mat| self.symbols[mat.candidate_id].clone())
117 {
118 let buffer = self.project.update(cx, |project, cx| {
119 project.open_buffer_for_symbol(&symbol, cx)
120 });
121 let symbol = symbol.clone();
122 let workspace = self.workspace.clone();
123 cx.spawn_in(window, async move |_, cx| {
124 let buffer = buffer.await?;
125 workspace.update_in(cx, |workspace, window, cx| {
126 let position = buffer
127 .read(cx)
128 .clip_point_utf16(symbol.range.start, Bias::Left);
129 let pane = if secondary {
130 workspace.adjacent_pane(window, cx)
131 } else {
132 workspace.active_pane().clone()
133 };
134
135 let editor =
136 workspace.open_project_item::<Editor>(pane, buffer, true, true, window, cx);
137
138 editor.update(cx, |editor, cx| {
139 editor.change_selections(
140 SelectionEffects::scroll(Autoscroll::center()),
141 window,
142 cx,
143 |s| s.select_ranges([position..position]),
144 );
145 });
146 })?;
147 anyhow::Ok(())
148 })
149 .detach_and_log_err(cx);
150 cx.emit(DismissEvent);
151 }
152 }
153
154 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
155
156 fn match_count(&self) -> usize {
157 self.matches.len()
158 }
159
160 fn selected_index(&self) -> usize {
161 self.selected_match_index
162 }
163
164 fn set_selected_index(
165 &mut self,
166 ix: usize,
167 _window: &mut Window,
168 _cx: &mut Context<Picker<Self>>,
169 ) {
170 self.selected_match_index = ix;
171 }
172
173 fn update_matches(
174 &mut self,
175 query: String,
176 window: &mut Window,
177 cx: &mut Context<Picker<Self>>,
178 ) -> Task<()> {
179 self.filter(&query, window, cx);
180 self.show_worktree_root_name = self.project.read(cx).visible_worktrees(cx).count() > 1;
181 let symbols = self
182 .project
183 .update(cx, |project, cx| project.symbols(&query, cx));
184 cx.spawn_in(window, async move |this, cx| {
185 let symbols = symbols.await.log_err();
186 if let Some(symbols) = symbols {
187 this.update_in(cx, |this, window, cx| {
188 let delegate = &mut this.delegate;
189 let project = delegate.project.read(cx);
190 let (visible_match_candidates, external_match_candidates) = symbols
191 .iter()
192 .enumerate()
193 .map(|(id, symbol)| {
194 StringMatchCandidate::new(id, symbol.label.filter_text())
195 })
196 .partition(|candidate| {
197 project
198 .entry_for_path(&symbols[candidate.id].path, cx)
199 .is_some_and(|e| !e.is_ignored)
200 });
201
202 delegate.visible_match_candidates = visible_match_candidates;
203 delegate.external_match_candidates = external_match_candidates;
204 delegate.symbols = symbols;
205 delegate.filter(&query, window, cx);
206 })
207 .log_err();
208 }
209 })
210 }
211
212 fn render_match(
213 &self,
214 ix: usize,
215 selected: bool,
216 window: &mut Window,
217 cx: &mut Context<Picker<Self>>,
218 ) -> Option<Self::ListItem> {
219 let string_match = &self.matches[ix];
220 let symbol = &self.symbols[string_match.candidate_id];
221 let syntax_runs = styled_runs_for_code_label(&symbol.label, cx.theme().syntax());
222
223 let mut path = symbol.path.path.to_string_lossy();
224 if self.show_worktree_root_name {
225 let project = self.project.read(cx);
226 if let Some(worktree) = project.worktree_for_id(symbol.path.worktree_id, cx) {
227 path = Cow::Owned(format!(
228 "{}{}{}",
229 worktree.read(cx).root_name(),
230 std::path::MAIN_SEPARATOR,
231 path.as_ref()
232 ));
233 }
234 }
235 let label = symbol.label.text.clone();
236 let path = path.to_string();
237
238 let highlights = gpui::combine_highlights(
239 string_match
240 .positions
241 .iter()
242 .map(|pos| (*pos..pos + 1, FontWeight::BOLD.into())),
243 syntax_runs.map(|(range, mut highlight)| {
244 // Ignore font weight for syntax highlighting, as we'll use it
245 // for fuzzy matches.
246 highlight.font_weight = None;
247 (range, highlight)
248 }),
249 );
250
251 Some(
252 ListItem::new(ix)
253 .inset(true)
254 .spacing(ListItemSpacing::Sparse)
255 .toggle_state(selected)
256 .child(
257 v_flex()
258 .child(
259 LabelLike::new().child(
260 StyledText::new(label)
261 .with_default_highlights(&window.text_style(), highlights),
262 ),
263 )
264 .child(Label::new(path).color(Color::Muted)),
265 ),
266 )
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use futures::StreamExt;
274 use gpui::{SemanticVersion, TestAppContext, VisualContext};
275 use language::{FakeLspAdapter, Language, LanguageConfig, LanguageMatcher};
276 use lsp::OneOf;
277 use project::FakeFs;
278 use serde_json::json;
279 use settings::SettingsStore;
280 use std::{path::Path, sync::Arc};
281 use util::path;
282
283 #[gpui::test]
284 async fn test_project_symbols(cx: &mut TestAppContext) {
285 init_test(cx);
286
287 let fs = FakeFs::new(cx.executor());
288 fs.insert_tree(path!("/dir"), json!({ "test.rs": "" }))
289 .await;
290
291 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
292
293 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
294 language_registry.add(Arc::new(Language::new(
295 LanguageConfig {
296 name: "Rust".into(),
297 matcher: LanguageMatcher {
298 path_suffixes: vec!["rs".to_string()],
299 ..Default::default()
300 },
301 ..Default::default()
302 },
303 None,
304 )));
305 let mut fake_servers = language_registry.register_fake_lsp(
306 "Rust",
307 FakeLspAdapter {
308 capabilities: lsp::ServerCapabilities {
309 workspace_symbol_provider: Some(OneOf::Left(true)),
310 ..Default::default()
311 },
312 ..Default::default()
313 },
314 );
315
316 let _buffer = project
317 .update(cx, |project, cx| {
318 project.open_local_buffer_with_lsp(path!("/dir/test.rs"), cx)
319 })
320 .await
321 .unwrap();
322
323 // Set up fake language server to return fuzzy matches against
324 // a fixed set of symbol names.
325 let fake_symbols = [
326 symbol("one", path!("/external")),
327 symbol("ton", path!("/dir/test.rs")),
328 symbol("uno", path!("/dir/test.rs")),
329 ];
330 let fake_server = fake_servers.next().await.unwrap();
331 fake_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
332 move |params: lsp::WorkspaceSymbolParams, cx| {
333 let executor = cx.background_executor().clone();
334 let fake_symbols = fake_symbols.clone();
335 async move {
336 let candidates = fake_symbols
337 .iter()
338 .enumerate()
339 .map(|(id, symbol)| StringMatchCandidate::new(id, &symbol.name))
340 .collect::<Vec<_>>();
341 let matches = if params.query.is_empty() {
342 Vec::new()
343 } else {
344 fuzzy::match_strings(
345 &candidates,
346 ¶ms.query,
347 true,
348 true,
349 100,
350 &Default::default(),
351 executor.clone(),
352 )
353 .await
354 };
355
356 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(
357 matches
358 .into_iter()
359 .map(|mat| fake_symbols[mat.candidate_id].clone())
360 .collect(),
361 )))
362 }
363 },
364 );
365
366 let (workspace, cx) =
367 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
368
369 // Create the project symbols view.
370 let symbols = cx.new_window_entity(|window, cx| {
371 Picker::uniform_list(
372 ProjectSymbolsDelegate::new(workspace.downgrade(), project.clone()),
373 window,
374 cx,
375 )
376 });
377
378 // Spawn multiples updates before the first update completes,
379 // such that in the end, there are no matches. Testing for regression:
380 // https://github.com/zed-industries/zed/issues/861
381 symbols.update_in(cx, |p, window, cx| {
382 p.update_matches("o".to_string(), window, cx);
383 p.update_matches("on".to_string(), window, cx);
384 p.update_matches("onex".to_string(), window, cx);
385 });
386
387 cx.run_until_parked();
388 symbols.read_with(cx, |symbols, _| {
389 assert_eq!(symbols.delegate.matches.len(), 0);
390 });
391
392 // Spawn more updates such that in the end, there are matches.
393 symbols.update_in(cx, |p, window, cx| {
394 p.update_matches("one".to_string(), window, cx);
395 p.update_matches("on".to_string(), window, cx);
396 });
397
398 cx.run_until_parked();
399 symbols.read_with(cx, |symbols, _| {
400 let delegate = &symbols.delegate;
401 assert_eq!(delegate.matches.len(), 2);
402 assert_eq!(delegate.matches[0].string, "ton");
403 assert_eq!(delegate.matches[1].string, "one");
404 });
405
406 // Spawn more updates such that in the end, there are again no matches.
407 symbols.update_in(cx, |p, window, cx| {
408 p.update_matches("o".to_string(), window, cx);
409 p.update_matches("".to_string(), window, cx);
410 });
411
412 cx.run_until_parked();
413 symbols.read_with(cx, |symbols, _| {
414 assert_eq!(symbols.delegate.matches.len(), 0);
415 });
416 }
417
418 fn init_test(cx: &mut TestAppContext) {
419 cx.update(|cx| {
420 let store = SettingsStore::test(cx);
421 cx.set_global(store);
422 theme::init(theme::LoadThemes::JustBase, cx);
423 release_channel::init(SemanticVersion::default(), cx);
424 language::init(cx);
425 Project::init_settings(cx);
426 workspace::init_settings(cx);
427 editor::init(cx);
428 });
429 }
430
431 fn symbol(name: &str, path: impl AsRef<Path>) -> lsp::SymbolInformation {
432 #[allow(deprecated)]
433 lsp::SymbolInformation {
434 name: name.to_string(),
435 kind: lsp::SymbolKind::FUNCTION,
436 tags: None,
437 deprecated: None,
438 container_name: None,
439 location: lsp::Location::new(
440 lsp::Url::from_file_path(path.as_ref()).unwrap(),
441 lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)),
442 ),
443 }
444 }
445}