Fix incorrect search ranges when rendering search matches in the outline panel (#41859)

Closes https://github.com/zed-industries/zed/issues/41792

Release Notes:

- Fixed outline panel panicking when rendering certain search matches
This commit is contained in:
Kirill Bulatov
2025-11-03 22:01:52 +00:00
committed by GitHub
parent 2eeb02305c
commit 9ecf257502
+201 -93
View File
@@ -465,14 +465,8 @@ impl SearchData {
let match_offset_range = match_range.to_offset(multi_buffer_snapshot);
let mut search_match_indices = vec![
multi_buffer_snapshot.clip_offset(
match_offset_range.start - context_offset_range.start,
Bias::Left,
)
..multi_buffer_snapshot.clip_offset(
match_offset_range.end - context_offset_range.start,
Bias::Right,
),
match_offset_range.start - context_offset_range.start
..match_offset_range.end - context_offset_range.start,
];
let entire_context_text = multi_buffer_snapshot
@@ -509,14 +503,8 @@ impl SearchData {
.next()
.is_some_and(|c| !c.is_whitespace());
search_match_indices.iter_mut().for_each(|range| {
range.start = multi_buffer_snapshot.clip_offset(
range.start.saturating_sub(left_whitespaces_offset),
Bias::Left,
);
range.end = multi_buffer_snapshot.clip_offset(
range.end.saturating_sub(left_whitespaces_offset),
Bias::Right,
);
range.start = range.start.saturating_sub(left_whitespaces_offset);
range.end = range.end.saturating_sub(left_whitespaces_offset);
});
let trimmed_row_offset_range =
@@ -5256,10 +5244,13 @@ mod tests {
use language::{Language, LanguageConfig, LanguageMatcher, tree_sitter_rust};
use pretty_assertions::assert_eq;
use project::FakeFs;
use search::project_search::{self, perform_project_search};
use search::{
buffer_search,
project_search::{self, perform_project_search},
};
use serde_json::json;
use util::path;
use workspace::{OpenOptions, OpenVisible};
use workspace::{OpenOptions, OpenVisible, ToolbarItemView};
use super::*;
@@ -5322,25 +5313,28 @@ mod tests {
ide/src/
inlay_hints/
fn_lifetime_fn.rs
search: match config.param_names_for_lifetime_elision_hints {
search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
search: Some(it) if config.param_names_for_lifetime_elision_hints => {
search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
search: match config.«param_names_for_lifetime_elision_hints» {
search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
inlay_hints.rs
search: pub param_names_for_lifetime_elision_hints: bool,
search: param_names_for_lifetime_elision_hints: self
search: pub «param_names_for_lifetime_elision_hints»: bool,
search: «param_names_for_lifetime_elision_hints»: self
static_index.rs
search: param_names_for_lifetime_elision_hints: false,
search: «param_names_for_lifetime_elision_hints»: false,
rust-analyzer/src/
cli/
analysis_stats.rs
search: param_names_for_lifetime_elision_hints: true,
search: «param_names_for_lifetime_elision_hints»: true,
config.rs
search: param_names_for_lifetime_elision_hints: self"#
search: «param_names_for_lifetime_elision_hints»: self"#
.to_string();
let select_first_in_all_matches = |line_to_select: &str| {
assert!(all_matches.contains(line_to_select));
assert!(
all_matches.contains(line_to_select),
"`{line_to_select}` was not found in all matches `{all_matches}`"
);
all_matches.replacen(
line_to_select,
&format!("{line_to_select}{SELECTED_MARKER}"),
@@ -5361,7 +5355,7 @@ mod tests {
cx,
),
select_first_in_all_matches(
"search: match config.param_names_for_lifetime_elision_hints {"
"search: match config.«param_names_for_lifetime_elision_hints» {"
)
);
});
@@ -5401,16 +5395,16 @@ mod tests {
inlay_hints/
fn_lifetime_fn.rs{SELECTED_MARKER}
inlay_hints.rs
search: pub param_names_for_lifetime_elision_hints: bool,
search: param_names_for_lifetime_elision_hints: self
search: pub «param_names_for_lifetime_elision_hints»: bool,
search: «param_names_for_lifetime_elision_hints»: self
static_index.rs
search: param_names_for_lifetime_elision_hints: false,
search: «param_names_for_lifetime_elision_hints»: false,
rust-analyzer/src/
cli/
analysis_stats.rs
search: param_names_for_lifetime_elision_hints: true,
search: «param_names_for_lifetime_elision_hints»: true,
config.rs
search: param_names_for_lifetime_elision_hints: self"#,
search: «param_names_for_lifetime_elision_hints»: self"#,
)
);
});
@@ -5471,9 +5465,9 @@ mod tests {
rust-analyzer/src/
cli/
analysis_stats.rs
search: param_names_for_lifetime_elision_hints: true,
search: «param_names_for_lifetime_elision_hints»: true,
config.rs
search: param_names_for_lifetime_elision_hints: self"#,
search: «param_names_for_lifetime_elision_hints»: self"#,
)
);
});
@@ -5553,21 +5547,21 @@ mod tests {
ide/src/
inlay_hints/
fn_lifetime_fn.rs
search: match config.param_names_for_lifetime_elision_hints {
search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
search: Some(it) if config.param_names_for_lifetime_elision_hints => {
search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
search: match config.«param_names_for_lifetime_elision_hints» {
search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
inlay_hints.rs
search: pub param_names_for_lifetime_elision_hints: bool,
search: param_names_for_lifetime_elision_hints: self
search: pub «param_names_for_lifetime_elision_hints»: bool,
search: «param_names_for_lifetime_elision_hints»: self
static_index.rs
search: param_names_for_lifetime_elision_hints: false,
search: «param_names_for_lifetime_elision_hints»: false,
rust-analyzer/src/
cli/
analysis_stats.rs
search: param_names_for_lifetime_elision_hints: true,
search: «param_names_for_lifetime_elision_hints»: true,
config.rs
search: param_names_for_lifetime_elision_hints: self"#
search: «param_names_for_lifetime_elision_hints»: self"#
.to_string();
cx.executor()
@@ -5692,30 +5686,40 @@ mod tests {
ide/src/
inlay_hints/
fn_lifetime_fn.rs
search: match config.param_names_for_lifetime_elision_hints {
search: allocated_lifetimes.push(if config.param_names_for_lifetime_elision_hints {
search: Some(it) if config.param_names_for_lifetime_elision_hints => {
search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },
search: match config.«param_names_for_lifetime_elision_hints» {
search: allocated_lifetimes.push(if config.«param_names_for_lifetime_elision_hints» {
search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {
search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },
inlay_hints.rs
search: pub param_names_for_lifetime_elision_hints: bool,
search: param_names_for_lifetime_elision_hints: self
search: pub «param_names_for_lifetime_elision_hints»: bool,
search: «param_names_for_lifetime_elision_hints»: self
static_index.rs
search: param_names_for_lifetime_elision_hints: false,
search: «param_names_for_lifetime_elision_hints»: false,
rust-analyzer/src/
cli/
analysis_stats.rs
search: param_names_for_lifetime_elision_hints: true,
search: «param_names_for_lifetime_elision_hints»: true,
config.rs
search: param_names_for_lifetime_elision_hints: self"#
search: «param_names_for_lifetime_elision_hints»: self"#
.to_string();
let select_first_in_all_matches = |line_to_select: &str| {
assert!(all_matches.contains(line_to_select));
assert!(
all_matches.contains(line_to_select),
"`{line_to_select}` was not found in all matches `{all_matches}`"
);
all_matches.replacen(
line_to_select,
&format!("{line_to_select}{SELECTED_MARKER}"),
1,
)
};
let clear_outline_metadata = |input: &str| {
input
.replace("search: ", "")
.replace("«", "")
.replace("»", "")
};
cx.executor()
.advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100));
cx.run_until_parked();
@@ -5726,7 +5730,7 @@ mod tests {
.expect("should have an active editor open")
});
let initial_outline_selection =
"search: match config.param_names_for_lifetime_elision_hints {";
"search: match config.«param_names_for_lifetime_elision_hints» {";
outline_panel.update_in(cx, |outline_panel, window, cx| {
assert_eq!(
display_entries(
@@ -5740,7 +5744,7 @@ mod tests {
);
assert_eq!(
selected_row_text(&active_editor, cx),
initial_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
clear_outline_metadata(initial_outline_selection),
"Should place the initial editor selection on the corresponding search result"
);
@@ -5749,7 +5753,7 @@ mod tests {
});
let navigated_outline_selection =
"search: Some(it) if config.param_names_for_lifetime_elision_hints => {";
"search: Some(it) if config.«param_names_for_lifetime_elision_hints» => {";
outline_panel.update(cx, |outline_panel, cx| {
assert_eq!(
display_entries(
@@ -5767,7 +5771,7 @@ mod tests {
outline_panel.update(cx, |_, cx| {
assert_eq!(
selected_row_text(&active_editor, cx),
navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
clear_outline_metadata(navigated_outline_selection),
"Should still have the initial caret position after SelectNext calls"
);
});
@@ -5778,7 +5782,7 @@ mod tests {
outline_panel.update(cx, |_outline_panel, cx| {
assert_eq!(
selected_row_text(&active_editor, cx),
navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
clear_outline_metadata(navigated_outline_selection),
"After opening, should move the caret to the opened outline entry's position"
);
});
@@ -5786,7 +5790,7 @@ mod tests {
outline_panel.update_in(cx, |outline_panel, window, cx| {
outline_panel.select_next(&SelectNext, window, cx);
});
let next_navigated_outline_selection = "search: InlayHintsConfig { param_names_for_lifetime_elision_hints: true, ..TEST_CONFIG },";
let next_navigated_outline_selection = "search: InlayHintsConfig { «param_names_for_lifetime_elision_hints»: true, ..TEST_CONFIG },";
outline_panel.update(cx, |outline_panel, cx| {
assert_eq!(
display_entries(
@@ -5804,7 +5808,7 @@ mod tests {
outline_panel.update(cx, |_outline_panel, cx| {
assert_eq!(
selected_row_text(&active_editor, cx),
next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
clear_outline_metadata(next_navigated_outline_selection),
"Should again preserve the selection after another SelectNext call"
);
});
@@ -5837,7 +5841,7 @@ mod tests {
);
assert_eq!(
selected_row_text(&new_active_editor, cx),
next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes
clear_outline_metadata(next_navigated_outline_selection),
"When opening the excerpt, should navigate to the place corresponding the outline entry"
);
});
@@ -5939,11 +5943,11 @@ mod tests {
format!(
r#"one/
a.txt
search: aaa aaa <==== selected
search: aaa aaa
search: «aaa» aaa <==== selected
search: aaa «aaa»
two/
b.txt
search: a aaa"#,
search: a «aaa»"#,
),
);
});
@@ -5969,7 +5973,7 @@ two/
a.txt <==== selected
two/
b.txt
search: a aaa"#,
search: a «aaa»"#,
),
);
});
@@ -6018,7 +6022,7 @@ two/ <==== selected"#,
a.txt
two/ <==== selected
b.txt
search: a aaa"#,
search: a «aaa»"#,
)
);
});
@@ -6483,18 +6487,18 @@ outline: struct OutlineEntryExcerpt
r#"frontend-project/
public/lottie/
syntax-tree.json
search: {{ "something": "static" }} <==== selected
search: {{ "something": "«static»" }} <==== selected
src/
app/(site)/
(about)/jobs/[slug]/
page.tsx
search: static
search: «static»
(blog)/post/[slug]/
page.tsx
search: static
search: «static»
components/
ErrorBoundary.tsx
search: static"#
search: «static»"#
)
);
});
@@ -6522,12 +6526,12 @@ outline: struct OutlineEntryExcerpt
r#"frontend-project/
public/lottie/
syntax-tree.json
search: {{ "something": "static" }}
search: {{ "something": "«static»" }}
src/
app/(site)/ <==== selected
components/
ErrorBoundary.tsx
search: static"#
search: «static»"#
)
);
});
@@ -6552,12 +6556,12 @@ outline: struct OutlineEntryExcerpt
r#"frontend-project/
public/lottie/
syntax-tree.json
search: {{ "something": "static" }}
search: {{ "something": "«static»" }}
src/
app/(site)/
components/
ErrorBoundary.tsx
search: static <==== selected"#
search: «static» <==== selected"#
)
);
});
@@ -6586,7 +6590,7 @@ outline: struct OutlineEntryExcerpt
r#"frontend-project/
public/lottie/
syntax-tree.json
search: {{ "something": "static" }}
search: {{ "something": "«static»" }}
src/
app/(site)/
components/
@@ -6619,12 +6623,12 @@ outline: struct OutlineEntryExcerpt
r#"frontend-project/
public/lottie/
syntax-tree.json
search: {{ "something": "static" }}
search: {{ "something": "«static»" }}
src/
app/(site)/
components/
ErrorBoundary.tsx <==== selected
search: static"#
search: «static»"#
)
);
});
@@ -6667,18 +6671,18 @@ outline: struct OutlineEntryExcerpt
r#"frontend-project/
public/lottie/
syntax-tree.json
search: {{ "something": "static" }}
search: {{ "something": "«static»" }}
src/
app/(site)/
(about)/jobs/[slug]/
page.tsx
search: static
search: «static»
(blog)/post/[slug]/
page.tsx
search: static
search: «static»
components/
ErrorBoundary.tsx <==== selected
search: static"#
search: «static»"#
)
);
});
@@ -6784,16 +6788,21 @@ outline: struct OutlineEntryExcerpt
}
},
PanelEntry::Search(search_entry) => {
format!(
"search: {}",
search_entry
.render_data
.get_or_init(|| SearchData::new(
&search_entry.match_range,
multi_buffer_snapshot
))
.context_text
)
let search_data = search_entry.render_data.get_or_init(|| {
SearchData::new(&search_entry.match_range, multi_buffer_snapshot)
});
let mut search_result = String::new();
let mut last_end = 0;
for range in &search_data.search_match_indices {
search_result.push_str(&search_data.context_text[last_end..range.start]);
search_result.push('«');
search_result.push_str(&search_data.context_text[range.start..range.end]);
search_result.push('»');
last_end = range.end;
}
search_result.push_str(&search_data.context_text[last_end..]);
format!("search: {search_result}")
}
};
@@ -6816,6 +6825,7 @@ outline: struct OutlineEntryExcerpt
workspace::init_settings(cx);
Project::init_settings(cx);
project_search::init(cx);
buffer_search::init(cx);
super::init(cx);
});
}
@@ -7827,4 +7837,102 @@ outline: fn main()"
};
});
}
#[gpui::test]
async fn test_buffer_search(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/test",
json!({
"foo.txt": r#"<_constitution>
</_constitution>
## 📊 Output
| Field | Meaning |
"#
}),
)
.await;
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
let workspace = add_outline_panel(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let editor = workspace
.update(cx, |workspace, window, cx| {
workspace.open_abs_path(
PathBuf::from("/test/foo.txt"),
OpenOptions {
visible: Some(OpenVisible::All),
..OpenOptions::default()
},
window,
cx,
)
})
.unwrap()
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let search_bar = workspace
.update(cx, |_, window, cx| {
cx.new(|cx| {
let mut search_bar = BufferSearchBar::new(None, window, cx);
search_bar.set_active_pane_item(Some(&editor), window, cx);
search_bar.show(window, cx);
search_bar
})
})
.unwrap();
let outline_panel = outline_panel(&workspace, cx);
outline_panel.update_in(cx, |outline_panel, window, cx| {
outline_panel.set_active(true, window, cx)
});
search_bar
.update_in(cx, |search_bar, window, cx| {
search_bar.search(" ", None, true, window, cx)
})
.await
.unwrap();
cx.executor()
.advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(500));
cx.run_until_parked();
outline_panel.update(cx, |outline_panel, cx| {
assert_eq!(
display_entries(
&project,
&snapshot(outline_panel, cx),
&outline_panel.cached_entries,
outline_panel.selected_entry(),
cx,
),
"search: | Field« » | Meaning | <==== selected
search: | Field « » | Meaning |
search: | Field « » | Meaning |
search: | Field « » | Meaning |
search: | Field « »| Meaning |
search: | Field | Meaning« » |
search: | Field | Meaning « » |
search: | Field | Meaning « » |
search: | Field | Meaning « » |
search: | Field | Meaning « » |
search: | Field | Meaning « » |
search: | Field | Meaning « » |
search: | Field | Meaning « »|"
);
});
}
}