From 6184b2457c796efe19d4df2dc7286310db7797f3 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:38:07 -0400 Subject: [PATCH] Fix project symbol picker UTF-8 highlight panic (#53485) This panic was caused because we incorrectly assumed that each character was one byte when converting character indices to highlight range byte indices. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #53479 Release Notes: - Fix a panic that could occur in the project symbol search picker --------- Co-authored-by: Lukas Wirth --- crates/project_symbols/src/project_symbols.rs | 111 +++++++++++++++++- 1 file changed, 107 insertions(+), 4 deletions(-) diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index 931e332d93d869bc31909643190d5b35f32409dc..8edcd9a80d1759d965dc38ecb1c88f0ea76056ad 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -288,7 +288,7 @@ impl PickerDelegate for ProjectSymbolsDelegate { let custom_highlights = string_match .positions .iter() - .map(|pos| (*pos..pos + 1, highlight_style)); + .map(|pos| (*pos..label.ceil_char_boundary(pos + 1), highlight_style)); let highlights = gpui::combine_highlights(custom_highlights, syntax_runs); @@ -299,9 +299,12 @@ impl PickerDelegate for ProjectSymbolsDelegate { .toggle_state(selected) .child( v_flex() - .child(LabelLike::new().child( - StyledText::new(label).with_default_highlights(&text_style, highlights), - )) + .child( + LabelLike::new().child( + StyledText::new(&label) + .with_default_highlights(&text_style, highlights), + ), + ) .child( h_flex() .child(Label::new(path).size(LabelSize::Small).color(Color::Muted)) @@ -483,6 +486,106 @@ mod tests { }); } + #[gpui::test] + async fn test_project_symbols_renders_utf8_match(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/dir"), json!({ "test.rs": "" })) + .await; + + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(Arc::new(Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + ..Default::default() + }, + None, + ))); + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + workspace_symbol_provider: Some(OneOf::Left(true)), + ..Default::default() + }, + ..Default::default() + }, + ); + + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer_with_lsp(path!("/dir/test.rs"), cx) + }) + .await + .unwrap(); + + let fake_symbols = [symbol("안녕", path!("/dir/test.rs"))]; + let fake_server = fake_servers.next().await.unwrap(); + fake_server.set_request_handler::( + move |params: lsp::WorkspaceSymbolParams, cx| { + let executor = cx.background_executor().clone(); + let fake_symbols = fake_symbols.clone(); + async move { + let candidates = fake_symbols + .iter() + .enumerate() + .map(|(id, symbol)| StringMatchCandidate::new(id, &symbol.name)) + .collect::>(); + let matches = fuzzy::match_strings( + &candidates, + ¶ms.query, + true, + true, + 100, + &Default::default(), + executor, + ) + .await; + + Ok(Some(lsp::WorkspaceSymbolResponse::Flat( + matches + .into_iter() + .map(|mat| fake_symbols[mat.candidate_id].clone()) + .collect(), + ))) + } + }, + ); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let symbols = cx.new_window_entity(|window, cx| { + Picker::uniform_list( + ProjectSymbolsDelegate::new(workspace.downgrade(), project.clone()), + window, + cx, + ) + }); + + symbols.update_in(cx, |p, window, cx| { + p.update_matches("안".to_string(), window, cx); + }); + + cx.run_until_parked(); + symbols.read_with(cx, |symbols, _| { + assert_eq!(symbols.delegate.matches.len(), 1); + assert_eq!(symbols.delegate.matches[0].string, "안녕"); + }); + + symbols.update_in(cx, |p, window, cx| { + assert!(p.delegate.render_match(0, false, window, cx).is_some()); + }); + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let store = SettingsStore::test(cx);