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

Generated
+1
View File
@@ -16063,6 +16063,7 @@ dependencies = [
"gpui",
"itertools 0.14.0",
"language",
"log",
"lsp",
"menu",
"multi_buffer",
+2
View File
@@ -767,6 +767,8 @@
"regex": false,
// Whether to center the cursor on each search match when navigating.
"center_on_match": false,
// Start searching as you type in project search, without pressing Enter.
"search_on_type": true,
},
// When to populate a new search's query based on the text under the cursor.
// This setting can take the following three values:
-1
View File
@@ -1371,7 +1371,6 @@ impl DisplayMap {
}
}
#[cfg(test)]
pub fn is_rewrapping(&self, cx: &gpui::App) -> bool {
self.wrap_map.read(cx).is_rewrapping()
}
+27 -17
View File
@@ -203,9 +203,8 @@ impl WrapMap {
(handle, snapshot)
}
#[cfg(test)]
pub fn is_rewrapping(&self) -> bool {
self.background_task.is_some()
self.background_task.is_some() || (self.wrap_width.is_some() && self.snapshot.interpolated)
}
#[ztracing::instrument(skip_all)]
@@ -225,6 +224,13 @@ impl WrapMap {
self.snapshot.interpolated = false;
}
debug_assert!(
self.background_task.is_some()
|| self.wrap_width.is_none()
|| !self.snapshot.interpolated,
"an interpolated snapshot must always have a background task rewrapping it, \
otherwise is_rewrapping never settles and frozen scrollbar ranges leak"
);
(self.snapshot.clone(), mem::take(&mut self.edits_since_sync))
}
@@ -368,7 +374,7 @@ impl WrapMap {
if let Some(wrap_width) = self.wrap_width
&& self.background_task.is_none()
{
let mut pending_edits = self.pending_edits.clone();
let pending_edits = self.pending_edits.clone();
let mut snapshot = self.snapshot.clone();
let text_system = cx.text_system().clone();
let (font, font_size) = self.font_with_size.clone();
@@ -376,20 +382,24 @@ impl WrapMap {
LineFragmentBuilder::new(text_system.clone(), &font, font_size);
let mut line_wrapper = text_system.line_wrapper(font, font_size);
if pending_edits.len() == 1
&& let Some((_, tab_edits)) = pending_edits.back()
&& let [edit] = &**tab_edits
&& ((edit.new.end.row().saturating_sub(edit.new.start.row()) + 1) as usize)
< WRAP_YIELD_ROW_INTERVAL
&& let Some((tab_snapshot, tab_edits)) = pending_edits.pop_back()
{
let wrap_edits = gpui::block_on(snapshot.update(
tab_snapshot,
&tab_edits,
wrap_width,
&mut line_wrapper,
&mut fragment_builder,
));
let update_passes = pending_edits.len();
let total_new_rows = pending_edits
.iter()
.flat_map(|(_, tab_edits)| tab_edits.iter())
.map(|edit| (edit.new.end.row().saturating_sub(edit.new.start.row()) + 1) as usize)
.sum::<usize>();
if update_passes + total_new_rows < WRAP_YIELD_ROW_INTERVAL {
let mut wrap_edits = Patch::default();
for (tab_snapshot, tab_edits) in pending_edits {
let edits = gpui::block_on(snapshot.update(
tab_snapshot,
&tab_edits,
wrap_width,
&mut line_wrapper,
&mut fragment_builder,
));
wrap_edits = wrap_edits.compose(&edits);
}
self.snapshot = snapshot;
self.edits_since_sync = self.edits_since_sync.compose(&wrap_edits);
} else {
+130 -2
View File
@@ -915,6 +915,28 @@ struct ActionFetchReady {
actions: Rc<[AvailableCodeAction]>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum GutterLineNumberWidth {
#[default]
Dynamic,
Sticky {
min_digits: usize,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct ScrollRangeHold {
held: bool,
settled: Option<SettledScrollRange>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct SettledScrollRange {
range: Size<Pixels>,
editor_width: Pixels,
editor_bounds_size: Size<Pixels>,
}
/// Zed's primary implementation of text input, allowing users to edit a [`MultiBuffer`].
///
/// See the [module level documentation](self) for more information.
@@ -980,6 +1002,8 @@ pub struct Editor {
enable_runnables: bool,
enable_code_lens: bool,
enable_mouse_wheel_zoom: bool,
gutter_line_number_width: GutterLineNumberWidth,
scroll_range_hold: Option<ScrollRangeHold>,
show_line_numbers: Option<bool>,
use_relative_line_numbers: Option<bool>,
show_git_diff_gutter: Option<bool>,
@@ -1207,6 +1231,7 @@ pub struct EditorSnapshot {
pub mode: EditorMode,
show_gutter: bool,
offset_content: bool,
gutter_line_number_width: GutterLineNumberWidth,
show_line_numbers: Option<bool>,
number_deleted_lines: bool,
show_git_diff_gutter: Option<bool>,
@@ -2302,6 +2327,8 @@ impl Editor {
offset_content: !matches!(mode, EditorMode::SingleLine),
breadcrumbs_visibility: BreadcrumbsVisibility::from_settings(cx),
show_gutter: full_mode,
gutter_line_number_width: GutterLineNumberWidth::Dynamic,
scroll_range_hold: None,
show_line_numbers: (!full_mode).then_some(false),
use_relative_line_numbers: None,
disable_expand_excerpt_buttons: !full_mode,
@@ -3032,6 +3059,7 @@ impl Editor {
mode: self.mode.clone(),
show_gutter: self.show_gutter,
offset_content: self.offset_content,
gutter_line_number_width: self.gutter_line_number_width,
show_line_numbers: self.show_line_numbers,
number_deleted_lines: self.number_deleted_lines,
show_git_diff_gutter: self.show_git_diff_gutter,
@@ -3091,6 +3119,99 @@ impl Editor {
self.in_project_search = in_project_search;
}
/// Lets the gutter grow to fit the widest line number seen but never shrink,
/// until [`Editor::reset_gutter_line_number_width`] is called.
pub fn enable_sticky_gutter_line_number(&mut self, cx: &mut Context<Self>) {
if self.gutter_line_number_width == GutterLineNumberWidth::Dynamic {
self.gutter_line_number_width = GutterLineNumberWidth::Sticky { min_digits: 0 };
self.latch_gutter_line_number_width(cx);
}
}
pub fn reset_gutter_line_number_width(&mut self, cx: &mut Context<Self>) {
if matches!(
self.gutter_line_number_width,
GutterLineNumberWidth::Sticky { min_digits } if min_digits != 0
) {
self.gutter_line_number_width = GutterLineNumberWidth::Sticky { min_digits: 0 };
cx.notify();
}
}
/// Shrinks the sticky gutter width down to fit the current content and keeps latching from
/// there, so a settled search snaps to its real width instead of staying stuck at the widest
/// line number seen mid-typing.
pub fn refit_gutter_line_number_width(&mut self, cx: &mut Context<Self>) {
let GutterLineNumberWidth::Sticky { min_digits } = self.gutter_line_number_width else {
return;
};
let digits = self.widest_line_number_digits(cx);
if digits != min_digits {
self.gutter_line_number_width = GutterLineNumberWidth::Sticky { min_digits: digits };
cx.notify();
}
}
fn latch_gutter_line_number_width(&mut self, cx: &mut Context<Self>) {
let GutterLineNumberWidth::Sticky { min_digits } = self.gutter_line_number_width else {
return;
};
let digits = self.widest_line_number_digits(cx);
if digits > min_digits {
self.gutter_line_number_width = GutterLineNumberWidth::Sticky { min_digits: digits };
cx.notify();
}
}
fn widest_line_number_digits(&self, cx: &App) -> usize {
let snapshot = self.buffer.read(cx).read(cx);
if snapshot.is_empty() {
return 0;
}
(snapshot.widest_line_number().max(1).ilog10() + 1) as usize
}
#[cfg(any(test, feature = "test-support"))]
pub fn min_gutter_line_number_digits(&self) -> Option<usize> {
match self.gutter_line_number_width {
GutterLineNumberWidth::Dynamic => None,
GutterLineNumberWidth::Sticky { min_digits } => Some(min_digits),
}
}
pub fn hold_scrollbar_range(&mut self, hold: bool) {
self.scroll_range_hold.get_or_insert_default().held = hold;
}
#[cfg(any(test, feature = "test-support"))]
pub fn is_scrollbar_range_held(&self) -> Option<bool> {
Some(self.scroll_range_hold?.held)
}
fn frozen_scroll_range(
&mut self,
is_rewrapping: bool,
current_range: Size<Pixels>,
current_editor_width: Pixels,
current_editor_bounds_size: Size<Pixels>,
) -> Option<SettledScrollRange> {
let hold = self.scroll_range_hold.as_mut()?;
let current = SettledScrollRange {
range: current_range,
editor_width: current_editor_width,
editor_bounds_size: current_editor_bounds_size,
};
if !hold.held && !is_rewrapping {
hold.settled = Some(current);
return None;
}
let settled = hold.settled.get_or_insert(current);
if settled.editor_bounds_size != current_editor_bounds_size {
*settled = current;
}
Some(*settled)
}
pub fn set_custom_context_menu(
&mut self,
f: impl 'static
@@ -9692,6 +9813,7 @@ impl Editor {
} => {
self.scrollbar_marker_state.dirty = true;
self.active_indent_guides_state.dirty = true;
self.latch_gutter_line_number_width(cx);
self.refresh_active_diagnostics(cx);
self.refresh_code_actions_for_selection(window, cx);
self.refresh_single_line_folds(window, cx);
@@ -11737,8 +11859,14 @@ impl EditorSnapshot {
let line_gutter_width = if show_line_numbers {
// Avoid flicker-like gutter resizes when the line number gains another digit by
// only resizing the gutter on files with > 10**min_line_number_digits lines.
let min_width_for_number_on_gutter =
ch_advance * gutter_settings.min_line_number_digits as f32;
let sticky_min_digits = match self.gutter_line_number_width {
GutterLineNumberWidth::Dynamic => 0,
GutterLineNumberWidth::Sticky { min_digits } => min_digits,
};
let min_digits = gutter_settings
.min_line_number_digits
.max(sticky_min_digits);
let min_width_for_number_on_gutter = ch_advance * min_digits as f32;
self.max_line_number_width(style, window)
.max(min_width_for_number_on_gutter)
} else {
+3
View File
@@ -195,6 +195,8 @@ pub struct SearchSettings {
pub regex: bool,
/// Whether to center the cursor on each search match when navigating.
pub center_on_match: bool,
/// Start searching as you type in project search, without pressing Enter.
pub search_on_type: bool,
}
impl EditorSettings {
@@ -297,6 +299,7 @@ impl Settings for EditorSettings {
include_ignored: search.include_ignored.unwrap(),
regex: search.regex.unwrap(),
center_on_match: search.center_on_match.unwrap(),
search_on_type: search.search_on_type.unwrap(),
},
auto_signature_help: editor.auto_signature_help.unwrap(),
show_signature_help_after_edits: editor.show_signature_help_after_edits.unwrap(),
+59
View File
@@ -44211,3 +44211,62 @@ async fn assert_range_format_merge(
);
assert!(!cx.read(|cx| editor.is_dirty(cx)));
}
#[gpui::test]
async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorTestContext::new(cx).await;
cx.update_editor(|editor, _, _| {
let bounds = size(px(1800.), px(900.));
let settled = |width: f32, height: f32, editor_width: f32, bounds: Size<Pixels>| {
Some(SettledScrollRange {
range: size(px(width), px(height)),
editor_width: px(editor_width),
editor_bounds_size: bounds,
})
};
editor.hold_scrollbar_range(true);
assert_eq!(
editor.frozen_scroll_range(false, size(px(1780.), px(100.)), px(1786.), bounds),
settled(1780., 100., 1786., bounds),
"a hold that engages before any settled frame must freeze on the first \
state it sees instead of falling through to the live churning range"
);
assert_eq!(
editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1776.), bounds),
settled(1780., 100., 1786., bounds),
"an unwrapped interpolation spike and a gutter-driven viewport change \
mid-churn must not leak into the held state; comparing a settled width \
against a viewport from another gutter regime blinks the scrollbar"
);
assert_eq!(
editor.frozen_scroll_range(false, size(px(1770.), px(160.)), px(1776.), bounds),
settled(1780., 100., 1786., bounds),
"while held, the settled pair stays even when the live state shrinks"
);
let resized_bounds = size(px(1300.), px(900.));
assert_eq!(
editor.frozen_scroll_range(true, size(px(1300.), px(160.)), px(1276.), resized_bounds),
settled(1300., 160., 1276., resized_bounds),
"a window resize invalidates the frozen state even mid-churn, otherwise the \
scrollbar thumbs stay sized for the old window until the search settles"
);
assert_eq!(
editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1276.), resized_bounds),
settled(1300., 160., 1276., resized_bounds),
"after re-freezing on the resize, churn at the same bounds stays frozen again"
);
editor.hold_scrollbar_range(false);
assert_eq!(
editor.frozen_scroll_range(false, size(px(1770.), px(160.)), px(1776.), bounds),
None,
"once released and not rewrapping, the live state settles"
);
assert_eq!(
editor.frozen_scroll_range(true, size(px(9000.), px(400.)), px(1786.), bounds),
settled(1770., 160., 1776., bounds),
"a rewrap after release keeps the last settled pair frozen"
);
});
}
+28 -3
View File
@@ -9058,14 +9058,38 @@ impl Element for EditorElement {
cx,
);
let frozen_scroll_state = if self.editor.read(cx).scroll_range_hold.is_some() {
let is_rewrapping =
self.editor.read(cx).display_map.read(cx).is_rewrapping(cx);
self.editor.update(cx, |editor, _| {
editor.frozen_scroll_range(
is_rewrapping,
scrollbar_layout_information.scroll_range,
editor_width,
scrollbar_layout_information.editor_bounds.size,
)
})
} else {
None
};
let effective_scrollbar_layout_information =
frozen_scroll_state.map_or(scrollbar_layout_information, |settled| {
ScrollbarLayoutInformation {
scroll_range: settled.range,
..scrollbar_layout_information
}
});
let effective_editor_width =
frozen_scroll_state.map_or(editor_width, |settled| settled.editor_width);
let scrollbars_layout = self.layout_scrollbars(
&snapshot,
&scrollbar_layout_information,
&effective_scrollbar_layout_information,
content_offset,
scroll_position,
non_visible_cursors,
right_margin,
editor_width,
effective_editor_width,
window,
cx,
);
@@ -9289,7 +9313,7 @@ impl Element for EditorElement {
&snapshot,
minimap_width,
scroll_position,
&scrollbar_layout_information,
&effective_scrollbar_layout_information,
scrollbars_layout.as_ref(),
window,
cx,
@@ -9597,6 +9621,7 @@ struct ContextMenuLayout {
}
/// Holds information required for layouting the editor scrollbars.
#[derive(Clone, Copy)]
struct ScrollbarLayoutInformation {
/// The bounds of the editor area (excluding the content offset).
editor_bounds: Bounds<Pixels>,
+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);
+62 -15
View File
@@ -22,7 +22,7 @@ use postage::oneshot;
use rpc::{AnyProtoClient, proto};
use language::ByteContent;
use util::{ResultExt, maybe, paths::compare_rel_paths, rel_path::RelPath};
use util::{ResultExt, maybe, rel_path::RelPath};
use worktree::{
Entry, ProjectEntryId, Snapshot, Worktree, WorktreeSettings, decode_byte_header,
decode_file_text,
@@ -119,7 +119,11 @@ impl Search {
limit: usize,
cx: &mut App,
) -> Self {
let worktrees = worktree_store.read(cx).visible_worktrees(cx).collect();
let mut worktrees = worktree_store
.read(cx)
.visible_worktrees(cx)
.collect::<Vec<_>>();
worktrees.sort_by_key(|worktree| worktree.read(cx).id());
Self {
kind: SearchKind::Local { fs, worktrees },
buffer_store,
@@ -165,7 +169,9 @@ impl Search {
pub fn into_handle(mut self, query: SearchQuery, cx: &mut App) -> SearchResultsHandle {
let mut open_buffers = HashSet::default();
let mut unnamed_buffers = Vec::new();
let mut entryless_file_buffers = Vec::new();
const MAX_CONCURRENT_BUFFER_OPENS: usize = 64;
let searches_all_unnamed_buffers = !matches!(self.kind, SearchKind::OpenBuffersOnly);
let buffers = self.buffer_store.read(cx);
for handle in buffers.buffers() {
let buffer = handle.read(cx);
@@ -178,11 +184,23 @@ impl Search {
continue;
} else if let Some(entry_id) = buffer.entry_id(cx) {
open_buffers.insert(entry_id);
} else {
self.limit = self.limit.saturating_sub(1);
unnamed_buffers.push(handle)
} else if searches_all_unnamed_buffers {
match (&self.kind, buffer.file()) {
(SearchKind::Local { .. }, Some(file)) => {
self.limit = self.limit.saturating_sub(1);
let sort_key = (file.worktree_id(cx).to_proto(), file.path().clone());
entryless_file_buffers.push((sort_key, handle));
}
(SearchKind::Remote { .. }, _) => {}
_ => {
self.limit = self.limit.saturating_sub(1);
unnamed_buffers.push(handle);
}
}
};
}
unnamed_buffers.sort_by_cached_key(|buffer| path_key_sort_key(buffer, cx));
entryless_file_buffers.sort_by(|(key_a, _), (key_b, _)| key_a.cmp(key_b));
let open_buffers = Arc::new(open_buffers);
let executor = cx.background_executor().clone();
let (tx, rx) = unbounded();
@@ -241,6 +259,7 @@ impl Search {
self.buffer_store,
get_buffer_for_full_scan_rx,
grab_buffer_snapshot_tx,
entryless_file_buffers,
cx.clone(),
)
.boxed_local(),
@@ -540,13 +559,19 @@ impl Search {
buffer_store: Entity<BufferStore>,
rx: Receiver<(ProjectPath, MatchPositionHint)>,
find_all_matches_tx: Sender<(Entity<Buffer>, MatchPositionHint)>,
sorted_entryless_file_buffers: Vec<((u64, Arc<RelPath>), Entity<Buffer>)>,
mut cx: AsyncApp,
) {
let mut entryless_file_buffers = sorted_entryless_file_buffers.into_iter().peekable();
let mut rx = pin!(rx.ready_chunks(64));
_ = maybe!(async move {
while let Some(requested_paths) = rx.next().await {
let line_hints: Vec<MatchPositionHint> =
requested_paths.iter().map(|(_, line)| *line).collect();
let sort_keys: Vec<(u64, Arc<RelPath>)> = requested_paths
.iter()
.map(|(path, _)| (path.worktree_id.to_proto(), path.path.clone()))
.collect();
let mut buffers = buffer_store.update(&mut cx, |this, cx| {
requested_paths
.into_iter()
@@ -554,13 +579,28 @@ impl Search {
.collect::<FuturesOrdered<_>>()
});
let mut line_hints = line_hints.into_iter();
let mut sort_keys = sort_keys.into_iter();
while let Some(buffer) = buffers.next().await {
let line_hint = line_hints.next().unwrap_or(MatchPositionHint::default());
if let Some(sort_key) = sort_keys.next() {
while let Some((_, entryless_buffer)) =
entryless_file_buffers.next_if(|(key, _)| *key < sort_key)
{
find_all_matches_tx
.send((entryless_buffer, MatchPositionHint::default()))
.await?;
}
}
if let Some(buffer) = buffer.log_err() {
find_all_matches_tx.send((buffer, line_hint)).await?;
}
}
}
for (_, entryless_buffer) in entryless_file_buffers {
find_all_matches_tx
.send((entryless_buffer, MatchPositionHint::default()))
.await?;
}
Result::<_, anyhow::Error>::Ok(())
})
.await;
@@ -649,21 +689,28 @@ impl Search {
})
.cloned()
.collect::<Vec<_>>();
buffers.sort_by(|a, b| {
let a = a.read(cx);
let b = b.read(cx);
match (a.file(), b.file()) {
(None, None) => a.remote_id().cmp(&b.remote_id()),
(None, Some(_)) => std::cmp::Ordering::Less,
(Some(_), None) => std::cmp::Ordering::Greater,
(Some(a), Some(b)) => compare_rel_paths((a.path(), true), (b.path(), true)),
}
});
buffers.sort_by_cached_key(|buffer| path_key_sort_key(buffer, cx));
buffers.dedup_by_key(|buffer| buffer.entity_id());
buffers
}
}
fn path_key_sort_key(
buffer: &Entity<Buffer>,
cx: &App,
) -> (Option<u64>, Option<Arc<RelPath>>, String) {
let buffer = buffer.read(cx);
match buffer.file() {
Some(file) => (
Some(file.worktree_id(cx).to_proto()),
Some(file.path().clone()),
String::new(),
),
None => (None, None, buffer.remote_id().to_string()),
}
}
struct Worker {
query: Arc<SearchQuery>,
open_buffers: Arc<HashSet<ProjectEntryId>>,
@@ -47,7 +47,9 @@ use project::{
use remote::RemoteClient;
use rpc::proto;
use serde_json::json;
use settings::{Settings, SettingsLocation, SettingsStore, initial_server_settings_content};
use settings::{
Settings, SettingsLocation, SettingsStore, SplicingVec, initial_server_settings_content,
};
use smol::stream::StreamExt;
use std::{
path::{Path, PathBuf},
@@ -611,6 +613,145 @@ async fn test_remote_project_search_inclusion(
.await;
}
#[gpui::test]
async fn test_remote_project_search_reports_open_excluded_file_once(
cx: &mut TestAppContext,
server_cx: &mut TestAppContext,
) {
let fs = FakeFs::new(server_cx.executor());
fs.insert_tree(
path!("/code"),
json!({
"project1": {
"aaa.rs": "fn needle_aa() {}",
"mmm.rs": "fn needle_mm() {}",
"zzz.rs": "fn needle_zz() {}",
},
}),
)
.await;
let (project, _headless) = init_test(&fs, cx, server_cx).await;
server_cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.worktree.file_scan_exclusions =
Some(SplicingVec::from(vec!["**/mmm.rs".to_string()]));
});
});
});
let (worktree, _) = project
.update(cx, |project, cx| {
project.find_or_create_worktree(path!("/code/project1"), true, cx)
})
.await
.unwrap();
cx.run_until_parked();
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
let _excluded_buffer = project
.update(cx, |project, cx| {
project.open_buffer((worktree_id, rel_path("mmm.rs")), cx)
})
.await
.unwrap();
cx.run_until_parked();
worktree.read_with(cx, |worktree, _| {
assert_eq!(
worktree
.entry_for_path(rel_path("mmm.rs"))
.map(|entry| entry.id),
None,
"mmm.rs must have no worktree entry for this test to be meaningful"
);
});
do_search_and_assert(
&project,
"needle",
Default::default(),
false,
&[
path!("project1/aaa.rs"),
path!("project1/mmm.rs"),
path!("project1/zzz.rs"),
],
cx.clone(),
)
.await;
}
#[gpui::test]
async fn test_remote_project_search_reports_untitled_buffer_once(
cx: &mut TestAppContext,
server_cx: &mut TestAppContext,
) {
let fs = FakeFs::new(server_cx.executor());
fs.insert_tree(
path!("/code"),
json!({
"project1": {
"aaa.rs": "fn needle_aa() {}",
},
}),
)
.await;
let (project, _headless) = init_test(&fs, cx, server_cx).await;
let (_worktree, _) = project
.update(cx, |project, cx| {
project.find_or_create_worktree(path!("/code/project1"), true, cx)
})
.await
.unwrap();
cx.run_until_parked();
let untitled_buffer = project
.update(cx, |project, cx| project.create_buffer(None, true, cx))
.await
.unwrap();
untitled_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..0, "fn needle_untitled() {}")], None, cx)
});
cx.run_until_parked();
let receiver = project.update(cx, |project, cx| {
project.search(
SearchQuery::text(
"needle",
false,
true,
false,
Default::default(),
Default::default(),
false,
None,
)
.unwrap(),
cx,
)
});
let mut result_buffers = Vec::new();
while let Ok(result) = receiver.rx.recv().await {
match result {
SearchResult::Buffer { buffer, .. } => result_buffers.push(buffer),
SearchResult::LimitReached => panic!("unexpected limit"),
SearchResult::WaitingForScan | SearchResult::Searching => {}
}
}
assert_eq!(
result_buffers
.iter()
.filter(|buffer| **buffer == untitled_buffer)
.count(),
1,
"an untitled buffer on a remote project must be searched by the server only, \
not once per side"
);
assert_eq!(result_buffers.len(), 2);
}
#[gpui::test]
async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
let fs = FakeFs::new(server_cx.executor());
+1
View File
@@ -32,6 +32,7 @@ fs.workspace = true
futures.workspace = true
gpui.workspace = true
language.workspace = true
log.workspace = true
menu.workspace = true
multi_buffer.workspace = true
picker.workspace = true
+4
View File
@@ -3976,6 +3976,7 @@ mod tests {
include_ignored: false,
regex: false,
center_on_match: false,
search_on_type: false,
},
cx,
);
@@ -4039,6 +4040,7 @@ mod tests {
include_ignored: false,
regex: false,
center_on_match: false,
search_on_type: false,
},
cx,
);
@@ -4077,6 +4079,7 @@ mod tests {
include_ignored: false,
regex: false,
center_on_match: false,
search_on_type: false,
},
cx,
);
@@ -4305,6 +4308,7 @@ mod tests {
include_ignored: Some(search_settings.include_ignored),
regex: Some(search_settings.regex),
center_on_match: Some(search_settings.center_on_match),
search_on_type: Some(search_settings.search_on_type),
});
});
});
File diff suppressed because it is too large. Load diff
+1
View File
@@ -377,6 +377,7 @@ impl VsCodeSettings {
fn search_content(&self) -> Option<SearchSettingsContent> {
skip_default(SearchSettingsContent {
include_ignored: self.read_bool("search.useIgnoreFiles"),
search_on_type: self.read_bool("search.searchOnType"),
..Default::default()
})
}
+2
View File
@@ -1001,6 +1001,8 @@ pub struct SearchSettingsContent {
pub regex: Option<bool>,
/// Whether to center the cursor on each search match when navigating.
pub center_on_match: Option<bool>,
/// Start searching as you type in project search, without pressing Enter.
pub search_on_type: Option<bool>,
}
#[with_fallible_options]
+25 -1
View File
@@ -3578,7 +3578,7 @@ fn languages_and_tools_page(cx: &App) -> SettingsPage {
}
fn search_and_files_page() -> SettingsPage {
fn search_section() -> [SettingsPageItem; 9] {
fn search_section() -> [SettingsPageItem; 10] {
[
SettingsPageItem::SectionHeader("Search"),
SettingsPageItem::SettingItem(SettingItem {
@@ -3719,6 +3719,30 @@ fn search_and_files_page() -> SettingsPage {
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Search on Type",
description: "Start searching as you type in project search, without pressing Enter.",
field: Box::new(SettingField {
organization_override: None,
json_path: Some("editor.search.search_on_type"),
pick: |settings_content| {
settings_content
.editor
.search
.as_ref()
.and_then(|search| search.search_on_type.as_ref())
},
write: |settings_content, value, _| {
settings_content
.editor
.search
.get_or_insert_default()
.search_on_type = value;
},
}),
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Seed Search Query From Cursor",
description: "When to populate a new search's query based on the text under the cursor.",
+8 -1
View File
@@ -3894,7 +3894,8 @@ Non-negative `integer` values
"case_sensitive": false,
"include_ignored": false,
"regex": false,
"center_on_match": false
"center_on_match": false,
"search_on_type": true
}
}
```
@@ -3937,6 +3938,12 @@ Non-negative `integer` values
- Setting: `center_on_match`
- Default: `false`
### Search On Type
- Description: Start searching as you type in project search, without pressing Enter.
- Setting: `search_on_type`
- Default: `true`
## Search Wrap
- Description: If `search_wrap` is disabled, search results do not wrap around the end of the file
+2
View File
@@ -110,6 +110,8 @@ extend-ignore-re = [
"caf…",
'\[elete\]',
"GHIzJ",
# Intentionally non-matching query in project search tests
"NOTHINGmATCHEStHIS",
]
check-filename = true