Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/project/src/project_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 1 addition & 2 deletions crates/search/src/text_finder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,6 @@ pub struct SearchMatch {
pub buffer: Entity<Buffer>,
pub anchor_range: Range<Anchor>,
pub range: Range<usize>,
pub relative_range: Range<usize>,
pub line_text: String,
pub match_start_byte_column: u32,
pub line_number: u32,
}
249 changes: 206 additions & 43 deletions crates/search/src/text_finder/delegate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
})
})
}
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<usize>,
column: u32,
) -> Range<usize> {
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 {
Expand All @@ -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<usize>, HighlightStyle)> = Vec::new();
let mut current_offset = 0;
Expand Down Expand Up @@ -1235,37 +1277,158 @@ 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 {
path: path.clone(),
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,
});
}
}
matches
})
}
}

#[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<Project> {
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());
});
}
}
Loading