Enable project search on type by default (#62506)

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

Most of the diff is tests.

Notable behavior changes during this mode, compared to current mode:

* unsaved changes in multi buffer won't spawn any "save or not" pop-ups
during typing — submitting the query still does it, as before.
Unsaved files will stay so after being sorted out on typing: we cannot
save them by default as file-less buffers need a save modal; Zed should
already be able to persist and restore such changes on restart.

* gutter may jump during search, if new excerpts with large line numbers
appear during query input — yet it would not shrink back to avoid
excessive flickering and rewraps

Release Notes:

- Enabled project search on type by default (use `{ "search": {
"search_on_type": false } }` to restore the previous behavior)
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-08-26 14:33:03 +00:00
1 parent 4ccbcabfee
commit 1475887faf
21 files changed
+4459 -212

No files matched your search

+1 -1
View File
@@ -3131,7 +3131,7 @@ impl MultiBuffer {
}
}
fn build_excerpt_ranges(
pub fn build_excerpt_ranges(
ranges: impl IntoIterator<Item = Range<Point>>,
context_line_count: u32,
buffer_snapshot: &BufferSnapshot,
@@ -856,6 +856,315 @@ fn test_excerpt_events(cx: &mut App) {
assert_eq!(*follower_edit_event_count.read(), 5);
}
#[gpui::test]
fn test_set_excerpts_for_path_reuses_excerpts_when_only_primary_changes(cx: &mut App) {
let buffer = cx.new(|cx| Buffer::local(sample_text(10, 6, 'a'), cx));
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let path = PathKey::for_buffer(&buffer, cx);
let edited_event_count = Arc::new(RwLock::new(0));
let ranges_updated_event_count = Arc::new(RwLock::new(0));
multibuffer.update(cx, |_, cx| {
cx.subscribe(&multibuffer, {
let edited_event_count = edited_event_count.clone();
let ranges_updated_event_count = ranges_updated_event_count.clone();
move |_, _, event, _| match event {
Event::Edited { .. } => *edited_event_count.write() += 1,
Event::BufferRangesUpdated { .. } => *ranges_updated_event_count.write() += 1,
_ => {}
}
})
.detach();
});
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path.clone(),
buffer.clone(),
vec![Point::new(3, 0)..Point::new(3, 1)],
2,
cx,
);
});
let text = multibuffer.read(cx).snapshot(cx).text();
assert_eq!(*edited_event_count.read(), 1);
assert_eq!(*ranges_updated_event_count.read(), 1);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path.clone(),
buffer.clone(),
vec![Point::new(3, 0)..Point::new(3, 2)],
2,
cx,
);
});
assert_eq!(multibuffer.read(cx).snapshot(cx).text(), text);
assert_eq!(*edited_event_count.read(), 1);
assert_eq!(*ranges_updated_event_count.read(), 2);
let buffer_snapshot = buffer.read(cx).snapshot();
let primary_ranges = multibuffer
.read(cx)
.snapshot(cx)
.excerpts()
.map(|range| range.primary.to_point(&buffer_snapshot))
.collect::<Vec<_>>();
assert_eq!(primary_ranges, vec![Point::new(3, 0)..Point::new(3, 2)]);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path.clone(),
buffer.clone(),
vec![Point::new(3, 0)..Point::new(3, 2)],
2,
cx,
);
});
assert_eq!(*edited_event_count.read(), 1);
assert_eq!(*ranges_updated_event_count.read(), 2);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path,
buffer,
vec![Point::new(7, 0)..Point::new(7, 1)],
2,
cx,
);
});
assert_ne!(multibuffer.read(cx).snapshot(cx).text(), text);
assert_eq!(*edited_event_count.read(), 2);
assert_eq!(*ranges_updated_event_count.read(), 3);
}
#[gpui::test]
fn test_set_excerpts_for_path_reuses_excerpts_after_edits_shift_anchors(cx: &mut App) {
let buffer = cx.new(|cx| Buffer::local(sample_text(20, 6, 'a'), cx));
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let path = PathKey::for_buffer(&buffer, cx);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path.clone(),
buffer.clone(),
vec![Point::new(10, 0)..Point::new(10, 1)],
2,
cx,
);
});
let text = multibuffer.read(cx).snapshot(cx).text();
let excerpt_edit_count = Arc::new(RwLock::new(0));
let ranges_updated_event_count = Arc::new(RwLock::new(0));
multibuffer.update(cx, |_, cx| {
cx.subscribe(&multibuffer, {
let excerpt_edit_count = excerpt_edit_count.clone();
let ranges_updated_event_count = ranges_updated_event_count.clone();
move |_, _, event, _| match event {
Event::Edited {
edited_buffer: None,
..
} => *excerpt_edit_count.write() += 1,
Event::BufferRangesUpdated { .. } => *ranges_updated_event_count.write() += 1,
_ => {}
}
})
.detach();
});
buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(0, 0)..Point::new(0, 0), "zzzzzz\n")], None, cx)
});
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path,
buffer,
vec![Point::new(11, 0)..Point::new(11, 1)],
2,
cx,
);
});
assert_eq!(
multibuffer.read(cx).snapshot(cx).text(),
text,
"an edit above the excerpt shifts the match but not its content, so the shown text \
must stay the same"
);
assert_eq!(
*excerpt_edit_count.read(),
0,
"the shifted excerpt covers the same buffer content, so it must be reused as is \
instead of being replaced"
);
assert_eq!(
*ranges_updated_event_count.read(),
0,
"the shifted primary range covers the same buffer content, so no range update \
must be reported"
);
}
#[gpui::test]
fn test_ranges_grouped_by_excerpt_path_skips_stale_ranges(cx: &mut App) {
let buffer_a = cx.new(|cx| Buffer::local(sample_text(6, 6, 'a'), cx));
let buffer_b = cx.new(|cx| Buffer::local(sample_text(6, 6, 'g'), cx));
let buffer_c = cx.new(|cx| Buffer::local(sample_text(6, 6, 'm'), cx));
let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let path_a = PathKey::sorted(0);
let path_b = PathKey::sorted(1);
let path_c = PathKey::sorted(2);
multibuffer.update(cx, |multibuffer, cx| {
for (path, buffer) in [
(path_a.clone(), &buffer_a),
(path_b.clone(), &buffer_b),
(path_c.clone(), &buffer_c),
] {
multibuffer.set_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(2, 0)..Point::new(2, 3)],
1,
cx,
);
}
});
let snapshot = multibuffer.read(cx).snapshot(cx);
let ranges = [&buffer_a, &buffer_b, &buffer_c]
.into_iter()
.map(|buffer| {
let buffer_snapshot = buffer.read(cx).snapshot();
let start = buffer_snapshot.anchor_before(Point::new(2, 0));
let end = buffer_snapshot.anchor_after(Point::new(2, 3));
snapshot
.anchor_range_in_buffer(start..end)
.expect("the buffer has excerpts")
})
.collect::<Vec<_>>();
assert_eq!(
multibuffer.read(cx).ranges_grouped_by_excerpt_path(&ranges),
vec![
(path_a.clone(), 0..1),
(path_b.clone(), 1..2),
(path_c.clone(), 2..3),
]
);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.remove_excerpts(path_b, cx);
});
assert_eq!(
multibuffer.read(cx).ranges_grouped_by_excerpt_path(&ranges),
vec![(path_a, 0..1), (path_c, 2..3)],
"a range whose path lost its excerpts must be skipped instead of blocking the \
consumption of every range that follows it"
);
}
#[gpui::test]
fn test_remove_excerpts_for_paths(cx: &mut App) {
let buffers = (0u64..4)
.map(|index| {
cx.new(|cx| Buffer::local(sample_text(4, 4, (b'a' + index as u8 * 4) as char), cx))
})
.collect::<Vec<_>>();
for paths_to_remove in [
vec![1, 2, 9],
vec![2, 3],
vec![0, 1, 2, 3],
vec![0, 2],
vec![3],
vec![9],
] {
let batched = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
let sequential = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
for multibuffer in [&batched, &sequential] {
multibuffer.update(cx, |multibuffer, cx| {
for (index, buffer) in buffers.iter().enumerate() {
multibuffer.set_excerpts_for_path(
PathKey::sorted(index as u64),
buffer.clone(),
vec![Point::new(1, 0)..Point::new(1, 3)],
1,
cx,
);
}
});
}
let edited_event_count = Arc::new(RwLock::new(0));
let removed_buffer_events = Arc::new(RwLock::new(Vec::new()));
batched.update(cx, |_, cx| {
cx.subscribe(&batched, {
let edited_event_count = edited_event_count.clone();
let removed_buffer_events = removed_buffer_events.clone();
move |_, _, event, _| match event {
Event::Edited { .. } => *edited_event_count.write() += 1,
Event::BuffersRemoved { removed_buffer_ids } => removed_buffer_events
.write()
.push(removed_buffer_ids.clone()),
_ => {}
}
})
.detach();
});
let old_snapshot = batched.read(cx).snapshot(cx);
let subscription = batched.update(cx, |multibuffer, _| multibuffer.subscribe());
batched.update(cx, |multibuffer, cx| {
multibuffer.remove_excerpts_for_paths(
paths_to_remove
.iter()
.map(|index| PathKey::sorted(*index))
.collect(),
cx,
);
});
sequential.update(cx, |multibuffer, cx| {
for index in &paths_to_remove {
multibuffer.remove_excerpts(PathKey::sorted(*index), cx);
}
});
let batched_snapshot = batched.read(cx).snapshot(cx);
assert_eq!(
batched_snapshot.text(),
sequential.read(cx).snapshot(cx).text(),
"removing {paths_to_remove:?} in one batch must match sequential removals"
);
check_multibuffer_edits(&batched_snapshot, &old_snapshot, subscription);
assert_eq!(
batched_snapshot.trailing_excerpt_update_count()
- old_snapshot.trailing_excerpt_update_count(),
usize::from(paths_to_remove.contains(&3)),
"removing {paths_to_remove:?} must bump the trailing excerpt update count only \
when the trailing excerpt was actually removed"
);
let expected_removed_ids = paths_to_remove
.iter()
.filter_map(|index| buffers.get(*index as usize))
.map(|buffer| buffer.read(cx).remote_id())
.collect::<Vec<_>>();
if expected_removed_ids.is_empty() {
assert_eq!(*edited_event_count.read(), 0);
assert_eq!(*removed_buffer_events.read(), Vec::<Vec<BufferId>>::new());
} else {
assert_eq!(
*edited_event_count.read(),
1,
"removing {paths_to_remove:?} must edit the multibuffer exactly once"
);
assert_eq!(*removed_buffer_events.read(), vec![expected_removed_ids]);
}
}
}
#[gpui::test]
fn test_expand_excerpts(cx: &mut App) {
let buffer = cx.new(|cx| Buffer::local(sample_text(20, 3, 'a'), cx));
+190 -4
View File
@@ -44,12 +44,13 @@ impl PathKey {
}
pub fn for_buffer(buffer: &Entity<Buffer>, cx: &App) -> Self {
if let Some(file) = buffer.read(cx).file() {
let buffer = buffer.read(cx);
if let Some(file) = buffer.file() {
Self::with_sort_prefix(file.worktree_id(cx).to_proto(), file.path().clone())
} else {
Self {
sort_prefix: None,
path: RelPath::from_unix_str(&buffer.entity_id().to_string())
path: RelPath::from_unix_str(&buffer.remote_id().to_string())
.unwrap()
.into_arc(),
}
@@ -421,6 +422,7 @@ impl MultiBuffer {
});
}
let mut primary_ranges_changed = false;
while let Some(excerpt) = cursor.item()
&& excerpt.path_key == path_key
{
@@ -428,7 +430,11 @@ impl MultiBuffer {
let Some(next_excerpt) = to_insert.peek() else {
break;
};
if &excerpt.range == *next_excerpt {
let context_anchors_match = excerpt.range.context == next_excerpt.context;
if context_anchors_match
|| excerpt.range.context.to_offset(buffer_snapshot)
== next_excerpt.context.to_offset(buffer_snapshot)
{
let before = new_excerpts.summary().len();
new_excerpts.update_last(
|prev_excerpt| {
@@ -442,7 +448,26 @@ impl MultiBuffer {
},
(),
);
new_excerpts.push(excerpt.clone(), ());
let primary_offsets_changed = excerpt.range.primary != next_excerpt.primary
&& excerpt.range.primary.to_offset(buffer_snapshot)
!= next_excerpt.primary.to_offset(buffer_snapshot);
primary_ranges_changed |= primary_offsets_changed;
let reused_excerpt = if context_anchors_match {
let mut reused_excerpt = excerpt.clone();
if reused_excerpt.range.primary != next_excerpt.primary {
reused_excerpt.range.primary = next_excerpt.primary.clone();
}
reused_excerpt
} else {
Excerpt::new(
path_key.clone(),
path_key_index,
buffer_snapshot,
(*next_excerpt).clone(),
excerpt.has_trailing_newline,
)
};
new_excerpts.push(reused_excerpt, ());
to_insert.next();
cursor.next();
continue;
@@ -609,11 +634,70 @@ impl MultiBuffer {
ranges: new_ranges,
});
cx.notify();
} else if primary_ranges_changed {
cx.emit(Event::BufferRangesUpdated {
buffer,
path_key: path_key.clone(),
ranges: new_ranges,
});
cx.notify();
}
added_new_excerpt
}
pub fn ranges_grouped_by_excerpt_path(
&self,
ranges: &[Range<Anchor>],
) -> Vec<(PathKey, Range<usize>)> {
debug_assert!(
ranges
.iter()
.all(|range| matches!(range.start, Anchor::Excerpt(_))),
"ranges must start with excerpt anchors, Min/Max starts stall the grouping"
);
let snapshot = self.snapshot.borrow();
let mut grouped: Vec<(PathKey, Range<usize>)> = Vec::new();
let mut next_range_index = 0;
for excerpt in snapshot.excerpts.iter() {
while ranges
.get(next_range_index)
.is_some_and(|range| match &range.start {
Anchor::Excerpt(anchor) => {
anchor.path != excerpt.path_key_index
&& snapshot
.path_keys
.get_index(anchor.path.0 as usize)
.is_some_and(|path| path < &excerpt.path_key)
}
Anchor::Min | Anchor::Max => false,
})
{
next_range_index += 1;
}
let span_start = match grouped.last() {
Some((last_path, span)) if *last_path == excerpt.path_key => span.start,
_ => next_range_index,
};
while ranges
.get(next_range_index)
.is_some_and(|range| match &range.start {
Anchor::Excerpt(anchor) => anchor.path == excerpt.path_key_index,
Anchor::Min | Anchor::Max => false,
})
{
next_range_index += 1;
}
match grouped.last_mut() {
Some((last_path, span)) if *last_path == excerpt.path_key => {
span.end = next_range_index;
}
_ => grouped.push((excerpt.path_key.clone(), span_start..next_range_index)),
}
}
grouped
}
pub fn remove_excerpts_for_buffer(&mut self, buffer: BufferId, cx: &mut Context<Self>) {
let snapshot = self.sync_mut(cx);
let Some(path) = snapshot.path_for_buffer(buffer).cloned() else {
@@ -622,6 +706,108 @@ impl MultiBuffer {
self.remove_excerpts(path, cx);
}
pub fn remove_excerpts_for_paths(&mut self, paths: Vec<PathKey>, cx: &mut Context<Self>) {
debug_assert!(paths.is_sorted(), "paths must be in ascending order");
assert_eq!(self.history.transaction_depth(), 0);
self.sync_mut(cx);
let mut snapshot = self.snapshot.get_mut();
let old_len = snapshot.excerpts.summary().len();
let mut cursor = snapshot
.excerpts
.cursor::<Dimensions<PathKey, ExcerptOffset>>(());
let mut new_excerpts = SumTree::new(());
let mut excerpt_edits: Vec<Edit<ExcerptOffset>> = Vec::new();
let mut removed_buffer_ids = Vec::new();
for path in &paths {
new_excerpts.append(cursor.slice(path, Bias::Left), ());
let Some(excerpt) = cursor.item() else {
break;
};
if excerpt.path_key != *path {
continue;
}
removed_buffer_ids.push(excerpt.buffer_id);
let edit_start = cursor.position.1;
cursor.seek_forward(path, Bias::Right);
let edit_end = cursor.position.1;
match excerpt_edits.last_mut() {
Some(last_edit) if last_edit.old.end == edit_start => {
last_edit.old.end = edit_end;
}
_ => {
let new_position = new_excerpts.summary().len();
excerpt_edits.push(Edit {
old: edit_start..edit_end,
new: new_position..new_position,
});
}
}
}
let suffix = cursor.suffix();
let changed_trailing_excerpt = suffix.is_empty()
&& excerpt_edits
.last()
.is_some_and(|last_edit| last_edit.old.end == old_len);
new_excerpts.append(suffix, ());
drop(cursor);
if removed_buffer_ids.is_empty() {
return;
}
for buffer_id in &removed_buffer_ids {
snapshot.buffers.remove(buffer_id);
remove_diff_state(&mut snapshot.diffs, *buffer_id);
self.buffers.remove(buffer_id);
self.diffs.remove(buffer_id);
}
cx.emit(Event::BuffersRemoved { removed_buffer_ids });
if changed_trailing_excerpt {
snapshot.trailing_excerpt_update_count += 1;
new_excerpts.update_last(
|excerpt| {
if excerpt.has_trailing_newline {
excerpt.has_trailing_newline = false;
if let Some(last_edit) = excerpt_edits.last_mut() {
last_edit.old.start.0.0 = last_edit
.old
.start
.0
.0
.checked_sub(1)
.expect("should have at least one excerpt");
last_edit.new.start.0.0 = last_edit
.new
.start
.0
.0
.checked_sub(1)
.expect("should have at least one excerpt");
last_edit.new.end = last_edit.new.start;
}
}
},
(),
);
}
snapshot.excerpts = new_excerpts;
let edits =
Self::sync_diff_transforms(&mut snapshot, excerpt_edits, DiffChangeKind::BufferEdited);
if !edits.is_empty() {
self.subscriptions.publish(edits);
}
cx.emit(Event::Edited {
edited_buffer: None,
source: BufferEditSource::User,
});
cx.notify();
}
pub fn remove_excerpts(&mut self, path: PathKey, cx: &mut Context<Self>) {
assert_eq!(self.history.transaction_depth(), 0);
self.sync_mut(cx);