mirror of
https://github.com/encounter/zed.git
synced 2026-07-11 06:19:04 -07:00
project search: Stream search results to improve TTFB (#16923)
This is a prototype change to improve latency of local project searches. It refactors the matcher to keep paths "in-order" so that we don't need to wait for all matching files to display the first result. On a test (searching for `<` in zed.dev) it changes the time until first result from about 2s to about 50ms. The tail latency seems to increase slightly (from 5s to 7s) so we may want to do more tuning before hitting merge. Release Notes: - reduces latency for first project search result --------- Co-authored-by: Thorsten Ball <mrnugget@gmail.com> Co-authored-by: Antonio <antonio@zed.dev> Co-authored-by: Thorsten <thorsten@zed.dev>
This commit is contained in:
co-authored by
Thorsten Ball
Antonio
Thorsten
parent
dc889ca7f2
commit
b2f3f760ab
@@ -6,7 +6,7 @@ use crate::{
|
||||
use anyhow::{anyhow, Context as _, Result};
|
||||
use collections::{hash_map, HashMap, HashSet};
|
||||
use fs::Fs;
|
||||
use futures::{channel::oneshot, stream::FuturesUnordered, StreamExt as _};
|
||||
use futures::{channel::oneshot, stream::FuturesUnordered, StreamExt};
|
||||
use git::blame::Blame;
|
||||
use gpui::{
|
||||
AppContext, AsyncAppContext, Context as _, EventEmitter, Model, ModelContext, Task, WeakModel,
|
||||
@@ -784,95 +784,57 @@ impl BufferStore {
|
||||
pub fn find_search_candidates(
|
||||
&mut self,
|
||||
query: &SearchQuery,
|
||||
limit: usize,
|
||||
mut limit: usize,
|
||||
fs: Arc<dyn Fs>,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Receiver<Model<Buffer>> {
|
||||
let (tx, rx) = smol::channel::unbounded();
|
||||
let open_buffers = self.find_open_search_candidates(query, cx);
|
||||
let skip_entries: HashSet<_> = open_buffers
|
||||
.iter()
|
||||
.filter_map(|buffer| buffer.read(cx).entry_id(cx))
|
||||
.collect();
|
||||
|
||||
let limit = limit.saturating_sub(open_buffers.len());
|
||||
for open_buffer in open_buffers {
|
||||
tx.send_blocking(open_buffer).ok();
|
||||
let mut open_buffers = HashSet::default();
|
||||
let mut unnamed_buffers = Vec::new();
|
||||
for handle in self.buffers() {
|
||||
let buffer = handle.read(cx);
|
||||
if let Some(entry_id) = buffer.entry_id(cx) {
|
||||
open_buffers.insert(entry_id);
|
||||
} else {
|
||||
limit = limit.saturating_sub(1);
|
||||
unnamed_buffers.push(handle)
|
||||
};
|
||||
}
|
||||
|
||||
let match_rx = self.worktree_store.update(cx, |worktree_store, cx| {
|
||||
worktree_store.find_search_candidates(query.clone(), limit, skip_entries, fs, cx)
|
||||
});
|
||||
const MAX_CONCURRENT_BUFFER_OPENS: usize = 64;
|
||||
let mut project_paths_rx = self
|
||||
.worktree_store
|
||||
.update(cx, |worktree_store, cx| {
|
||||
worktree_store.find_search_candidates(query.clone(), limit, open_buffers, fs, cx)
|
||||
})
|
||||
.chunks(MAX_CONCURRENT_BUFFER_OPENS);
|
||||
|
||||
const MAX_CONCURRENT_BUFFER_OPENS: usize = 8;
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
for buffer in unnamed_buffers {
|
||||
tx.send(buffer).await.ok();
|
||||
}
|
||||
|
||||
for _ in 0..MAX_CONCURRENT_BUFFER_OPENS {
|
||||
let mut match_rx = match_rx.clone();
|
||||
let tx = tx.clone();
|
||||
cx.spawn(|this, mut cx| async move {
|
||||
while let Some(project_path) = match_rx.next().await {
|
||||
let buffer = this
|
||||
.update(&mut cx, |this, cx| this.open_buffer(project_path, cx))?
|
||||
.await
|
||||
.log_err();
|
||||
if let Some(buffer) = buffer {
|
||||
tx.send_blocking(buffer).ok();
|
||||
while let Some(project_paths) = project_paths_rx.next().await {
|
||||
let buffers = this.update(&mut cx, |this, cx| {
|
||||
project_paths
|
||||
.into_iter()
|
||||
.map(|project_path| this.open_buffer(project_path, cx))
|
||||
.collect::<Vec<_>>()
|
||||
})?;
|
||||
for buffer_task in buffers {
|
||||
if let Some(buffer) = buffer_task.await.log_err() {
|
||||
if tx.send(buffer).await.is_err() {
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach();
|
||||
rx
|
||||
}
|
||||
|
||||
/// Returns open buffers filtered by filename
|
||||
/// Does *not* check the buffer content, the caller must do that
|
||||
fn find_open_search_candidates(
|
||||
&self,
|
||||
query: &SearchQuery,
|
||||
cx: &ModelContext<Self>,
|
||||
) -> Vec<Model<Buffer>> {
|
||||
let include_root = self
|
||||
.worktree_store
|
||||
.read(cx)
|
||||
.visible_worktrees(cx)
|
||||
.collect::<Vec<_>>()
|
||||
.len()
|
||||
> 1;
|
||||
self.buffers()
|
||||
.filter_map(|buffer| {
|
||||
let handle = buffer.clone();
|
||||
buffer.read_with(cx, |buffer, cx| {
|
||||
let worktree_store = self.worktree_store.read(cx);
|
||||
let entry_id = buffer.entry_id(cx);
|
||||
let is_ignored = entry_id
|
||||
.and_then(|entry_id| worktree_store.entry_for_id(entry_id, cx))
|
||||
.map_or(false, |entry| entry.is_ignored);
|
||||
|
||||
if is_ignored && !query.include_ignored() {
|
||||
return None;
|
||||
}
|
||||
if let Some(file) = buffer.file() {
|
||||
let matched_path = if include_root {
|
||||
query.file_matches(Some(&file.full_path(cx)))
|
||||
} else {
|
||||
query.file_matches(Some(file.path()))
|
||||
};
|
||||
|
||||
if matched_path {
|
||||
Some(handle)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(handle)
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn on_buffer_event(
|
||||
&mut self,
|
||||
buffer: Model<Buffer>,
|
||||
|
||||
@@ -93,7 +93,7 @@ use snippet_provider::SnippetProvider;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
cell::RefCell,
|
||||
cmp::{self, Ordering},
|
||||
cmp::Ordering,
|
||||
convert::TryInto,
|
||||
env,
|
||||
ffi::OsStr,
|
||||
@@ -7275,51 +7275,38 @@ impl Project {
|
||||
query: SearchQuery,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> Receiver<SearchResult> {
|
||||
let (result_tx, result_rx) = smol::channel::bounded(1024);
|
||||
let (result_tx, result_rx) = smol::channel::unbounded();
|
||||
|
||||
let matching_buffers_rx =
|
||||
self.search_for_candidate_buffers(&query, MAX_SEARCH_RESULT_FILES + 1, cx);
|
||||
|
||||
cx.spawn(|_, cx| async move {
|
||||
let mut matching_buffers = matching_buffers_rx.collect::<Vec<_>>().await;
|
||||
let mut limit_reached = if matching_buffers.len() > MAX_SEARCH_RESULT_FILES {
|
||||
matching_buffers.truncate(MAX_SEARCH_RESULT_FILES);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
cx.update(|cx| {
|
||||
sort_search_matches(&mut matching_buffers, cx);
|
||||
})?;
|
||||
|
||||
let mut range_count = 0;
|
||||
let mut buffer_count = 0;
|
||||
let mut limit_reached = false;
|
||||
let query = Arc::new(query);
|
||||
let mut chunks = matching_buffers_rx.ready_chunks(64);
|
||||
|
||||
// Now that we know what paths match the query, we will load at most
|
||||
// 64 buffers at a time to avoid overwhelming the main thread. For each
|
||||
// opened buffer, we will spawn a background task that retrieves all the
|
||||
// ranges in the buffer matched by the query.
|
||||
'outer: for matching_buffer_chunk in matching_buffers.chunks(64) {
|
||||
'outer: while let Some(matching_buffer_chunk) = chunks.next().await {
|
||||
let mut chunk_results = Vec::new();
|
||||
for buffer in matching_buffer_chunk {
|
||||
let buffer = buffer.clone();
|
||||
let query = query.clone();
|
||||
chunk_results.push(cx.spawn(|cx| async move {
|
||||
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
|
||||
let ranges = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
query
|
||||
.search(&snapshot, None)
|
||||
.await
|
||||
.iter()
|
||||
.map(|range| {
|
||||
snapshot.anchor_before(range.start)
|
||||
..snapshot.anchor_after(range.end)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
|
||||
chunk_results.push(cx.background_executor().spawn(async move {
|
||||
let ranges = query
|
||||
.search(&snapshot, None)
|
||||
.await
|
||||
.iter()
|
||||
.map(|range| {
|
||||
snapshot.anchor_before(range.start)
|
||||
..snapshot.anchor_after(range.end)
|
||||
})
|
||||
.await;
|
||||
.collect::<Vec<_>>();
|
||||
anyhow::Ok((buffer, ranges))
|
||||
}));
|
||||
}
|
||||
@@ -7328,10 +7315,13 @@ impl Project {
|
||||
for result in chunk_results {
|
||||
if let Some((buffer, ranges)) = result.log_err() {
|
||||
range_count += ranges.len();
|
||||
buffer_count += 1;
|
||||
result_tx
|
||||
.send(SearchResult::Buffer { buffer, ranges })
|
||||
.await?;
|
||||
if range_count > MAX_SEARCH_RESULT_RANGES {
|
||||
if buffer_count > MAX_SEARCH_RESULT_FILES
|
||||
|| range_count > MAX_SEARCH_RESULT_RANGES
|
||||
{
|
||||
limit_reached = true;
|
||||
break 'outer;
|
||||
}
|
||||
@@ -11369,19 +11359,3 @@ pub fn sort_worktree_entries(entries: &mut Vec<Entry>) {
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn sort_search_matches(search_matches: &mut Vec<Model<Buffer>>, cx: &AppContext) {
|
||||
search_matches.sort_by(|buffer_a, buffer_b| {
|
||||
let path_a = buffer_a.read(cx).file().map(|file| file.path());
|
||||
let path_b = buffer_b.read(cx).file().map(|file| file.path());
|
||||
|
||||
match (path_a, path_b) {
|
||||
(None, None) => cmp::Ordering::Equal,
|
||||
(None, Some(_)) => cmp::Ordering::Less,
|
||||
(Some(_), None) => cmp::Ordering::Greater,
|
||||
(Some(path_a), Some(path_b)) => {
|
||||
compare_paths((path_a.as_ref(), true), (path_b.as_ref(), true))
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -420,6 +420,11 @@ impl SearchQuery {
|
||||
self.as_inner().files_to_exclude()
|
||||
}
|
||||
|
||||
pub fn filters_path(&self) -> bool {
|
||||
!(self.files_to_exclude().sources().is_empty()
|
||||
&& self.files_to_include().sources().is_empty())
|
||||
}
|
||||
|
||||
pub fn file_matches(&self, file_path: Option<&Path>) -> bool {
|
||||
match file_path {
|
||||
Some(file_path) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ use editor::{
|
||||
Anchor, Editor, EditorElement, EditorEvent, EditorSettings, EditorStyle, MultiBuffer,
|
||||
MAX_TAB_TITLE_LEN,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
actions, div, Action, AnyElement, AnyView, AppContext, Context as _, EntityId, EventEmitter,
|
||||
FocusHandle, FocusableView, Global, Hsla, InteractiveElement, IntoElement, KeyContext, Model,
|
||||
@@ -20,7 +21,6 @@ use gpui::{
|
||||
use menu::Confirm;
|
||||
use project::{search::SearchQuery, search_history::SearchHistoryCursor, Project, ProjectPath};
|
||||
use settings::Settings;
|
||||
use smol::stream::StreamExt;
|
||||
use std::{
|
||||
any::{Any, TypeId},
|
||||
mem,
|
||||
@@ -209,7 +209,7 @@ impl ProjectSearch {
|
||||
self.active_query = Some(query);
|
||||
self.match_ranges.clear();
|
||||
self.pending_search = Some(cx.spawn(|this, mut cx| async move {
|
||||
let mut matches = search;
|
||||
let mut matches = search.ready_chunks(1024);
|
||||
let this = this.upgrade()?;
|
||||
this.update(&mut cx, |this, cx| {
|
||||
this.match_ranges.clear();
|
||||
@@ -220,33 +220,35 @@ impl ProjectSearch {
|
||||
.ok()?;
|
||||
|
||||
let mut limit_reached = false;
|
||||
while let Some(result) = matches.next().await {
|
||||
match result {
|
||||
project::search::SearchResult::Buffer { buffer, ranges } => {
|
||||
let mut match_ranges = this
|
||||
.update(&mut cx, |this, cx| {
|
||||
this.excerpts.update(cx, |excerpts, cx| {
|
||||
excerpts.stream_excerpts_with_context_lines(
|
||||
buffer,
|
||||
ranges,
|
||||
editor::DEFAULT_MULTIBUFFER_CONTEXT,
|
||||
cx,
|
||||
)
|
||||
while let Some(results) = matches.next().await {
|
||||
for result in results {
|
||||
match result {
|
||||
project::search::SearchResult::Buffer { buffer, ranges } => {
|
||||
let mut match_ranges = this
|
||||
.update(&mut cx, |this, cx| {
|
||||
this.excerpts.update(cx, |excerpts, cx| {
|
||||
excerpts.stream_excerpts_with_context_lines(
|
||||
buffer,
|
||||
ranges,
|
||||
editor::DEFAULT_MULTIBUFFER_CONTEXT,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
.ok()?;
|
||||
.ok()?;
|
||||
|
||||
while let Some(range) = match_ranges.next().await {
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.no_results = Some(false);
|
||||
this.match_ranges.push(range)
|
||||
})
|
||||
.ok()?;
|
||||
while let Some(range) = match_ranges.next().await {
|
||||
this.update(&mut cx, |this, _| {
|
||||
this.no_results = Some(false);
|
||||
this.match_ranges.push(range)
|
||||
})
|
||||
.ok()?;
|
||||
}
|
||||
this.update(&mut cx, |_, cx| cx.notify()).ok()?;
|
||||
}
|
||||
project::search::SearchResult::LimitReached => {
|
||||
limit_reached = true;
|
||||
}
|
||||
this.update(&mut cx, |_, cx| cx.notify()).ok()?;
|
||||
}
|
||||
project::search::SearchResult::LimitReached => {
|
||||
limit_reached = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user