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:
1 parent
4ccbcabfee
commit
1475887faf
21 files changed
+4459
-212
No files matched your search
Generated
+1
@@ -16063,6 +16063,7 @@ dependencies = [
|
||||
"gpui",
|
||||
"itertools 0.14.0",
|
||||
"language",
|
||||
"log",
|
||||
"lsp",
|
||||
"menu",
|
||||
"multi_buffer",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1371,7 +1371,6 @@ impl DisplayMap {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn is_rewrapping(&self, cx: &gpui::App) -> bool {
|
||||
self.wrap_map.read(cx).is_rewrapping()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+3462
-166
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -110,6 +110,8 @@ extend-ignore-re = [
|
||||
"caf…",
|
||||
'\[elete\]',
|
||||
"GHIzJ",
|
||||
# Intentionally non-matching query in project search tests
|
||||
"NOTHINGmATCHEStHIS",
|
||||
]
|
||||
check-filename = true
|
||||
|
||||
|
||||
Reference in new issue
Block a user