Fix outline panel hangs during massive project searches (#57814)

Fixes:
*
69d5da20f7
extracts excerpts' data instead of re-iterating them per each search
result entry
*
83fe2e4e88
instead of cancelling and restarting debounced tasks, coalesce new
tasks' submissions during pending debounces
*
d90ecd8820
removes redundant autoscrolls that happen anyway due to match
invalidation event processing — this prevented outline panel from being
scrolled a few seconds after the large project search is over
*
b9e00a3660
tidy up, less allocations


Before:


https://github.com/user-attachments/assets/8bedff61-d57e-4c72-8c8a-7c8127b315f3

After:


https://github.com/user-attachments/assets/bbe87992-3885-46b5-b187-92fc5b539e4a


Release Notes:

- Fixed outline panel hangs during massive project searches
This commit is contained in:
Kirill Bulatov 2026-05-27 15:43:06 +03:00 committed by GitHub
parent c171bbac44
commit fe48ef424c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -131,7 +131,9 @@ pub struct OutlinePanel {
_subscriptions: Vec<Subscription>,
new_entries_for_fs_update: HashSet<BufferId>,
fs_entries_update_task: Task<()>,
fs_entries_update_pending: bool,
cached_entries_update_task: Task<()>,
cached_entries_update_pending: bool,
reveal_selection_task: Task<anyhow::Result<()>>,
outline_fetch_tasks: HashMap<BufferId, Task<()>>,
buffers: HashMap<BufferId, BufferOutlines>,
@ -415,6 +417,12 @@ struct SearchData {
highlights_data: HighlightStyleData,
}
struct SearchPrecomputed {
multi_buffer_snapshot: MultiBufferSnapshot,
matches_by_buffer: HashMap<BufferId, Vec<(Range<editor::Anchor>, Arc<OnceLock<SearchData>>)>>,
folded_buffers: HashSet<BufferId>,
}
impl PartialEq for PanelEntry {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
@ -872,7 +880,9 @@ impl OutlinePanel {
preserve_selection_on_buffer_fold_toggles: HashSet::default(),
pending_default_expansion_depth: None,
fs_entries_update_task: Task::ready(()),
fs_entries_update_pending: false,
cached_entries_update_task: Task::ready(()),
cached_entries_update_pending: false,
reveal_selection_task: Task::ready(Ok(())),
outline_fetch_tasks: HashMap::default(),
buffers: HashMap::default(),
@ -2716,12 +2726,11 @@ impl OutlinePanel {
return;
}
let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
let active_multi_buffer = active_editor.read(cx).buffer().clone();
let new_entries = self.new_entries_for_fs_update.clone();
let repo_snapshots = self.project.update(cx, |project, cx| {
project.git_store().read(cx).repo_snapshots(cx)
});
if debounce.is_some() && self.fs_entries_update_pending {
return;
}
self.fs_entries_update_pending = true;
self.fs_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| {
if let Some(debounce) = debounce {
cx.background_executor().timer(debounce).await;
@ -2731,13 +2740,21 @@ impl OutlinePanel {
let mut new_unfolded_dirs = HashMap::default();
let mut root_entries = HashSet::default();
let mut new_buffers = HashMap::<BufferId, BufferOutlines>::default();
let Ok(buffer_excerpts) = outline_panel.update(cx, |outline_panel, cx| {
let Ok((buffer_excerpts, auto_fold_dirs, repo_snapshots)) =
outline_panel.update(cx, |outline_panel, cx| {
outline_panel.fs_entries_update_pending = false;
let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs;
let active_multi_buffer = active_editor.read(cx).buffer().clone();
let new_entries = outline_panel.new_entries_for_fs_update.clone();
let repo_snapshots = outline_panel.project.update(cx, |project, cx| {
project.git_store().read(cx).repo_snapshots(cx)
});
let git_store = outline_panel.project.read(cx).git_store().clone();
new_collapsed_entries = outline_panel.collapsed_entries.clone();
new_unfolded_dirs = outline_panel.unfolded_dirs.clone();
let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx);
multi_buffer_snapshot.excerpts().fold(
let buffer_excerpts = multi_buffer_snapshot.excerpts().fold(
HashMap::default(),
|mut buffer_excerpts, excerpt_range| {
let Some(buffer_snapshot) = multi_buffer_snapshot
@ -2774,7 +2791,9 @@ impl OutlinePanel {
OutlineState::Outlines(outlines) => {
OutlineState::Outlines(outlines.clone())
}
OutlineState::Invalidated(_) => OutlineState::NotFetched,
OutlineState::Invalidated(_) => {
OutlineState::NotFetched
}
OutlineState::NotFetched => OutlineState::NotFetched,
},
None => OutlineState::NotFetched,
@ -2788,8 +2807,10 @@ impl OutlinePanel {
.push(excerpt_range);
buffer_excerpts
},
)
}) else {
);
(buffer_excerpts, auto_fold_dirs, repo_snapshots)
})
else {
return;
};
@ -3126,14 +3147,12 @@ impl OutlinePanel {
e: &SearchEvent,
window: &mut Window,
cx: &mut Context<Self>| {
if matches!(e, SearchEvent::MatchesInvalidated) {
let update_cached_items = outline_panel.update_search_matches(window, cx);
if update_cached_items {
if matches!(e, SearchEvent::MatchesInvalidated)
&& outline_panel.update_search_matches(window, cx)
{
outline_panel.selected_entry.invalidate();
outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx);
}
};
outline_panel.autoscroll(cx);
},
);
self.active_item = Some(ActiveItem {
@ -3157,8 +3176,10 @@ impl OutlinePanel {
fn clear_previous(&mut self, window: &mut Window, cx: &mut App) {
self.fs_entries_update_task = Task::ready(());
self.fs_entries_update_pending = false;
self.outline_fetch_tasks.clear();
self.cached_entries_update_task = Task::ready(());
self.cached_entries_update_pending = false;
self.reveal_selection_task = Task::ready(Ok(()));
self.filter_editor
.update(cx, |editor, cx| editor.clear(window, cx));
@ -3585,14 +3606,23 @@ impl OutlinePanel {
return;
}
let is_singleton = self.is_singleton_active(cx);
let query = self.query(cx);
// A pending debounced update will read the latest state when it fires,
// so we don't need to reschedule. Constantly rescheduling under a steady stream
// of events (e.g. project search streaming results) would starve the task forever.
if debounce.is_some() && self.cached_entries_update_pending {
return;
}
self.cached_entries_update_pending = true;
self.cached_entries_update_task = cx.spawn_in(window, async move |outline_panel, cx| {
if let Some(debounce) = debounce {
cx.background_executor().timer(debounce).await;
}
let Some(new_cached_entries) = outline_panel
.update_in(cx, |outline_panel, window, cx| {
outline_panel.cached_entries_update_pending = false;
let is_singleton = outline_panel.is_singleton_active(cx);
let query = outline_panel.query(cx);
outline_panel.generate_cached_entries(is_singleton, query, window, cx)
})
.ok()
@ -3618,7 +3648,6 @@ impl OutlinePanel {
outline_panel.select_entry(new_selected_entry, false, window, cx);
}
outline_panel.autoscroll(cx);
cx.notify();
})
.ok();
@ -3651,6 +3680,60 @@ impl OutlinePanel {
expanded: bool,
depth: usize,
}
let search_precomputed =
if let ItemsDisplayMode::Search(search_state) = &outline_panel.mode {
let multi_buffer_snapshot =
active_editor.read(cx).buffer().read(cx).snapshot(cx);
let mut folded_buffers = HashSet::default();
let mut not_folded_buffers = HashSet::default();
let mut matches_by_buffer = HashMap::default();
for (match_range, search_data) in &search_state.matches {
let Some((start_anchor, _)) =
multi_buffer_snapshot.anchor_to_buffer_anchor(match_range.start)
else {
continue;
};
let start_buffer_id = start_anchor.buffer_id;
let end_buffer_id = multi_buffer_snapshot
.anchor_to_buffer_anchor(match_range.end)
.map(|(anchor, _)| anchor.buffer_id);
let mut any_folded = false;
for buffer_id in
[Some(start_buffer_id), end_buffer_id].into_iter().flatten()
{
if folded_buffers.contains(&buffer_id) {
any_folded = true;
} else if !not_folded_buffers.contains(&buffer_id) {
if active_editor.read(cx).is_buffer_folded(buffer_id, cx) {
folded_buffers.insert(buffer_id);
any_folded = true;
} else {
not_folded_buffers.insert(buffer_id);
}
}
}
if any_folded {
continue;
}
matches_by_buffer
.entry(start_buffer_id)
.or_insert_with(Vec::new)
.push((match_range.clone(), Arc::clone(search_data)));
}
Some(SearchPrecomputed {
multi_buffer_snapshot,
matches_by_buffer,
folded_buffers,
})
} else {
None
};
let mut parent_dirs = Vec::<ParentStats>::new();
for entry in outline_panel.fs_entries.clone() {
let is_expanded = outline_panel.is_expanded(&entry);
@ -3880,13 +3963,15 @@ impl OutlinePanel {
match outline_panel.mode {
ItemsDisplayMode::Search(_) => {
if is_singleton || query.is_some() || (should_add && is_expanded) {
if (is_singleton || query.is_some() || (should_add && is_expanded))
&& let Some(search) = &search_precomputed
{
outline_panel.add_search_entries(
&mut generation_state,
&active_editor,
entry.clone(),
search,
&entry,
depth,
query.clone(),
query.is_some(),
is_singleton,
cx,
);
@ -4202,22 +4287,26 @@ impl OutlinePanel {
)
};
let mut previous_matches = HashMap::default();
update_cached_entries = match &mut self.mode {
ItemsDisplayMode::Search(current_search_state) => {
let update = current_search_state.query != new_search_query
|| current_search_state.kind != kind
|| current_search_state.matches.is_empty()
|| current_search_state.matches.iter().enumerate().any(
|(i, (match_range, _))| new_search_matches.get(i) != Some(match_range),
);
if current_search_state.kind == kind {
previous_matches.extend(current_search_state.matches.drain(..));
}
update
let changed = match &self.mode {
ItemsDisplayMode::Search(current) => {
current.query != new_search_query
|| current.kind != kind
|| current.matches.len() != new_search_matches.len()
|| current
.matches
.iter()
.zip(&new_search_matches)
.any(|((existing, _), incoming)| existing != incoming)
}
ItemsDisplayMode::Outline => true,
};
if changed {
let previous_matches = match &mut self.mode {
ItemsDisplayMode::Search(current) if current.kind == kind => {
current.matches.drain(..).collect()
}
_ => HashMap::default(),
};
self.mode = ItemsDisplayMode::Search(SearchState::new(
kind,
new_search_query,
@ -4227,6 +4316,8 @@ impl OutlinePanel {
window,
cx,
));
update_cached_entries = true;
}
}
update_cached_entries
}
@ -4350,68 +4441,58 @@ impl OutlinePanel {
fn add_search_entries(
&mut self,
state: &mut GenerationState,
active_editor: &Entity<Editor>,
parent_entry: FsEntry,
search: &SearchPrecomputed,
parent_entry: &FsEntry,
parent_depth: usize,
filter_query: Option<String>,
track_matches: bool,
is_singleton: bool,
cx: &mut Context<Self>,
) {
let ItemsDisplayMode::Search(search_state) = &mut self.mode else {
let ItemsDisplayMode::Search(search_state) = &self.mode else {
return;
};
let kind = search_state.kind;
let (buffer_id, excerpts) = match parent_entry {
FsEntry::Directory(_) => return,
FsEntry::ExternalFile(external) => (external.buffer_id, &external.excerpts),
FsEntry::File(file) => (file.buffer_id, &file.excerpts),
};
if search.folded_buffers.contains(&buffer_id) {
return;
}
let Some(buffer_matches) = search.matches_by_buffer.get(&buffer_id) else {
return;
};
let kind = search_state.kind;
let related_excerpts = match &parent_entry {
FsEntry::Directory(_) => return,
FsEntry::ExternalFile(external) => &external.excerpts,
FsEntry::File(file) => &file.excerpts,
}
let excerpt_ranges = excerpts
.iter()
.cloned()
.collect::<HashSet<_>>();
.filter_map(|excerpt| {
let start = search
.multi_buffer_snapshot
.anchor_in_buffer(excerpt.context.start)?;
let end = search
.multi_buffer_snapshot
.anchor_in_buffer(excerpt.context.end)?;
Some(start..end)
})
.collect::<Vec<_>>();
let depth = if is_singleton { 0 } else { parent_depth + 1 };
let new_search_matches = search_state.matches.iter().filter(|(match_range, _)| {
let editor = active_editor.read(cx);
let snapshot = editor.buffer().read(cx).snapshot(cx);
if !related_excerpts.iter().any(|excerpt| {
let (Some(start), Some(end)) = (
snapshot.anchor_in_buffer(excerpt.context.start),
snapshot.anchor_in_buffer(excerpt.context.end),
) else {
return false;
};
let excerpt_range = start..end;
excerpt_range.overlaps(match_range, &snapshot)
for (match_range, search_data) in buffer_matches.iter().filter(|(match_range, _)| {
excerpt_ranges.iter().any(|excerpt_range| {
excerpt_range.overlaps(match_range, &search.multi_buffer_snapshot)
})
}) {
return false;
};
if let Some((buffer_anchor, _)) = snapshot.anchor_to_buffer_anchor(match_range.start)
&& editor.is_buffer_folded(buffer_anchor.buffer_id, cx)
{
return false;
}
if let Some((buffer_anchor, _)) = snapshot.anchor_to_buffer_anchor(match_range.end)
&& editor.is_buffer_folded(buffer_anchor.buffer_id, cx)
{
return false;
}
true
});
let new_search_entries = new_search_matches
.map(|(match_range, search_data)| SearchEntry {
self.push_entry(
state,
track_matches,
PanelEntry::Search(SearchEntry {
match_range: match_range.clone(),
kind,
render_data: Arc::clone(search_data),
})
.collect::<Vec<_>>();
for new_search_entry in new_search_entries {
self.push_entry(
state,
filter_query.is_some(),
PanelEntry::Search(new_search_entry),
}),
depth,
cx,
);