diff --git a/crates/project/src/project_search.rs b/crates/project/src/project_search.rs index 971581dcf1abbd..dd1d4575fe8901 100644 --- a/crates/project/src/project_search.rs +++ b/crates/project/src/project_search.rs @@ -152,7 +152,7 @@ impl Search { } pub(crate) const MAX_SEARCH_RESULT_FILES: usize = 5_000; - pub(crate) const MAX_SEARCH_RESULT_RANGES: usize = 10_000; + pub const MAX_SEARCH_RESULT_RANGES: usize = 10_000; /// Prepares a project search run. The resulting [`SearchResultsHandle`] has to be used to specify whether you're interested in matching buffers /// or full search results. pub fn into_handle(mut self, query: SearchQuery, cx: &mut App) -> SearchResultsHandle { diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs index 500f8e4fe9e1ef..d8973ffb40656e 100644 --- a/crates/search/src/text_finder.rs +++ b/crates/search/src/text_finder.rs @@ -474,7 +474,6 @@ pub struct SearchMatch { pub buffer: Entity, pub anchor_range: Range, pub range: Range, - pub relative_range: Range, - pub line_text: String, + pub match_start_byte_column: u32, pub line_number: u32, } diff --git a/crates/search/src/text_finder/delegate.rs b/crates/search/src/text_finder/delegate.rs index 4d4b07174793a8..63068b1a9edcd8 100644 --- a/crates/search/src/text_finder/delegate.rs +++ b/crates/search/src/text_finder/delegate.rs @@ -38,7 +38,7 @@ use gpui::{ use gpui::{Entity, FocusHandle}; use language::{Buffer, LanguageAwareStyling}; use picker::{Picker, PickerDelegate}; -use project::{Project, ProjectPath}; +use project::{Project, ProjectPath, Search}; use project::{SearchResults, search::SearchQuery, search::SearchResult}; use settings::Settings; use smol::future::yield_now; @@ -130,27 +130,15 @@ fn multibuffer_ranges_to_search_matches<'a>( let start_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.start); let end_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.end); - let line_number = buffer_snapshot.offset_to_point(start_offset).row + 1; - - let text = buffer_snapshot.text(); - let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0); - let line_end = text[start_offset..] - .find('\n') - .map(|i| start_offset + i) - .unwrap_or(text.len()); - let line_text = text[line_start..line_end].to_string(); - - let relative_start = start_offset - line_start; - let relative_end = end_offset - line_start; + let point = buffer_snapshot.offset_to_point(start_offset); Some(SearchMatch { path, buffer, anchor_range: text_range, range: start_offset..end_offset, - relative_range: relative_start..relative_end, - line_text, - line_number, + match_start_byte_column: point.column, + line_number: point.row + 1, }) }) } @@ -476,7 +464,7 @@ impl Delegate { }; let path = selected_match.path.clone(); let line_number = selected_match.line_number; - let column = selected_match.relative_range.start as u32; + let column = selected_match.match_start_byte_column; let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else { return; }; @@ -594,6 +582,7 @@ const SEARCH_DEBOUNCE_MS: u64 = 100; const CLICK_THRESHOLD_MS: u128 = 50; const DOUBLE_CLICK_THRESHOLD_MS: u128 = 300; const SEARCH_RESULTS_BATCH_SIZE: usize = 256; +const MAX_MATCH_CONTEXT_BYTES: usize = 512; impl PickerDelegate for Delegate { type ListItem = AnyElement; @@ -826,7 +815,7 @@ impl PickerDelegate for Delegate { let path = selected_match.path.clone(); let line_number = selected_match.line_number; - let column = selected_match.relative_range.start as u32; + let column = selected_match.match_start_byte_column; let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else { return; @@ -1052,6 +1041,11 @@ async fn stream_results_to_picker( .ready_chunks(SEARCH_RESULTS_BATCH_SIZE) ); + // Project search enforces its ranges cap per file, + // so one minified line slips through uncapped; cap it here. + let cap = Search::MAX_SEARCH_RESULT_RANGES; + let mut total_matches = 0; + let mut clear_existing = matches!(imported_matches, ImportedMatches::No); while let Some(results) = results_stream.next().await { if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) { @@ -1064,8 +1058,14 @@ async fn stream_results_to_picker( for result in results { match result { SearchResult::Buffer { buffer, ranges } => { - let matches = Delegate::process_search_result(&buffer, &ranges, cx); + let remaining = cap.saturating_sub(total_matches + batch_matches.len()); + let capped = ranges.len().min(remaining); + let matches = Delegate::process_search_result(&buffer, &ranges[..capped], cx); batch_matches.extend(matches); + if capped < ranges.len() { + limit_reached = true; + break; + } } SearchResult::LimitReached => { limit_reached = true; @@ -1074,6 +1074,8 @@ async fn stream_results_to_picker( } } + total_matches += batch_matches.len(); + picker .update(cx, |picker, cx| { let delegate = &mut picker.delegate; @@ -1115,6 +1117,29 @@ async fn stream_results_to_picker( None } +/// Byte range around the match to render: a bounded slice of the matched line so rendering never scales with line length. +fn matched_line_window( + snapshot: &language::BufferSnapshot, + match_range: &Range, + column: u32, +) -> Range { + let line_start = match_range.start.saturating_sub(column as usize); + let row = snapshot.offset_to_point(match_range.start).row; + let line_end = snapshot.point_to_offset(text::Point::new(row, snapshot.line_len(row))); + let start = snapshot.clip_offset( + match_range + .start + .saturating_sub(MAX_MATCH_CONTEXT_BYTES) + .max(line_start), + text::Bias::Left, + ); + let end = snapshot.clip_offset( + (match_range.end + MAX_MATCH_CONTEXT_BYTES).min(line_end), + text::Bias::Right, + ); + start..end +} + /// Renders the matched source line with syntax highlighting, overlaying the /// search match with a highlighted background and bold weight. fn render_matched_line(search_match: &SearchMatch, cx: &App) -> StyledText { @@ -1129,23 +1154,40 @@ fn render_matched_line(search_match: &SearchMatch, cx: &App) -> StyledText { line_height: relative(1.), ..Default::default() }; - let original_line = &search_match.line_text; - let line_text = original_line.trim_start(); - let trim_offset = original_line.len() - line_text.len(); - let search_match_style = HighlightStyle { background_color: Some(cx.theme().colors().search_match_background), font_weight: Some(gpui::FontWeight::BOLD), ..Default::default() }; - let line_start_abs = search_match.range.start - search_match.relative_range.start; - let visible_start_abs = line_start_abs + trim_offset; - let visible_end_abs = line_start_abs + original_line.len(); + let snapshot = search_match.buffer.read(cx).snapshot(); + + // Render a bounded window around the match, not the whole line, + // so a minified single-line file stays cheap. + let line_start_abs = search_match + .range + .start + .saturating_sub(search_match.match_start_byte_column as usize); + let window = matched_line_window( + &snapshot, + &search_match.range, + search_match.match_start_byte_column, + ); + let window_text: String = snapshot.text_for_range(window.clone()).collect(); + + // Trim leading indentation only when the window starts at the line start; + // a mid-line window already begins on content. + let trim_offset = if window.start == line_start_abs { + window_text.len() - window_text.trim_start().len() + } else { + 0 + }; + let visible_start_abs = window.start + trim_offset; + let visible_end_abs = window.end; + let line_text = &window_text[trim_offset..]; // Syntax highlights for the visible (trimmed) portion of the line, with // ranges relative to the start of the rendered text. - let snapshot = search_match.buffer.read(cx).snapshot(); let syntax_theme = cx.theme().syntax(); let mut syntax_highlights: Vec<(Range, HighlightStyle)> = Vec::new(); let mut current_offset = 0; @@ -1235,23 +1277,11 @@ impl Delegate { worktree_id: f.worktree_id(cx), path: f.path().clone(), }); - let text = buf.text(); - let mut matches = Vec::new(); for anchor_range in ranges { let start_offset: usize = buf.summary_for_anchor(&anchor_range.start); let end_offset: usize = buf.summary_for_anchor(&anchor_range.end); - let match_row = buf.offset_to_point(start_offset).row; - let line_number = match_row + 1; - let line_start = text[..start_offset].rfind('\n').map(|i| i + 1).unwrap_or(0); - let line_end = text[start_offset..] - .find('\n') - .map(|i| start_offset + i) - .unwrap_or(text.len()); - let line_text = text[line_start..line_end].to_string(); - - let relative_start = start_offset - line_start; - let relative_end = end_offset - line_start; + let point = buf.offset_to_point(start_offset); if let Some(path) = &path { matches.push(SearchMatch { @@ -1259,9 +1289,8 @@ impl Delegate { buffer: buffer.clone(), anchor_range: anchor_range.clone(), range: start_offset..end_offset, - relative_range: relative_start..relative_end, - line_text, - line_number, + match_start_byte_column: point.column, + line_number: point.row + 1, }); } } @@ -1269,3 +1298,137 @@ impl Delegate { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{AppContext, TestAppContext}; + use project::search::{SearchQuery, SearchResult}; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use util::path; + use util::paths::PathMatcher; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + crate::init(cx); + }); + } + + async fn project_with_file(cx: &mut TestAppContext, contents: String) -> Entity { + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/dir"), json!({ "sample.js": contents })) + .await; + Project::test(fs, [path!("/dir").as_ref()], cx).await + } + + #[gpui::test] + async fn test_finder_caps_matches_on_long_line(cx: &mut TestAppContext) { + use workspace::MultiWorkspace; + + init_test(cx); + + let line = "return ".repeat(Search::MAX_SEARCH_RESULT_RANGES * 2); + let project = project_with_file(cx, line).await; + + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let delegate = window + .update(cx, |_mw, window, cx| { + workspace.update(cx, |workspace, cx| Delegate::new(workspace, window, cx)) + }) + .unwrap() + .await; + let picker = window + .update(cx, |_mw, window, cx| { + cx.new(|cx| Picker::list(delegate, window, cx)) + }) + .unwrap(); + + window + .update(cx, |_mw, window, cx| { + picker.update(cx, |picker, cx| picker.set_query("return", window, cx)) + }) + .unwrap(); + + // Search is debounced; advance past the debounce and let results stream in. + cx.executor() + .advance_clock(std::time::Duration::from_millis(SEARCH_DEBOUNCE_MS + 50)); + cx.run_until_parked(); + + picker.read_with(cx, |picker, _| { + assert_eq!( + picker.delegate.matches.len(), + Search::MAX_SEARCH_RESULT_RANGES + ); + }); + } + + #[gpui::test] + async fn test_builds_one_match_per_occurrence(cx: &mut TestAppContext) { + init_test(cx); + + let line = "return ".repeat(2_000); + let expected_matches = line.matches("return").count(); + let project = project_with_file(cx, line).await; + + let query = SearchQuery::text( + "return", + false, + false, + false, + PathMatcher::default(), + PathMatcher::default(), + false, + None, + ) + .unwrap(); + + let search = project.update(cx, |project, cx| project.search(query, cx)); + let async_cx = cx.to_async(); + let mut matches = Vec::new(); + while let Ok(SearchResult::Buffer { buffer, ranges }) = search.rx.recv().await { + matches.extend(Delegate::process_search_result(&buffer, &ranges, &async_cx)); + } + + assert_eq!(matches.len(), expected_matches); + assert!(matches.iter().all(|m| m.line_number == 1)); + assert!( + matches + .windows(2) + .all(|pair| pair[0].match_start_byte_column < pair[1].match_start_byte_column) + ); + } + + #[gpui::test] + fn test_matched_line_window_is_bounded(cx: &mut gpui::TestAppContext) { + let long_line = "abcdefghij".repeat(1_000_000); + let buffer = cx.new(|cx| language::Buffer::local(long_line, cx)); + buffer.read_with(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + let match_range = 5_000_000..5_000_003; + let column = match_range.start as u32; // single line, so column equals the offset + let window = matched_line_window(&snapshot, &match_range, column); + + assert!(window.start <= match_range.start && window.end >= match_range.end); + assert!(window.len() <= 2 * MAX_MATCH_CONTEXT_BYTES + match_range.len()); + }); + + let buffer = cx.new(|cx| language::Buffer::local(" let foo = bar;", cx)); + buffer.read_with(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + let match_range = 8..11; // "foo" + let window = matched_line_window(&snapshot, &match_range, match_range.start as u32); + assert_eq!(window, 0..snapshot.len()); + }); + } +}