Keep first match selecting during type on search (#63375)
Follow-up to https://github.com/zed-industries/zed/pull/62506 Before: https://github.com/user-attachments/assets/19f6f0df-c5e3-417e-a351-13a09ccb726b After: https://github.com/user-attachments/assets/0b302ed7-a98d-4db4-81bc-d67f5cc7db87 * Fixes type on search not selecting the very first result as the regular search does. * Unifies the flicker freezing state better in the editor Release Notes: - N/A
This commit is contained in:
1 parent
c99db2149e
commit
77d716d5ce
4 files changed
+194
-96
No files matched your search
+50
-78
@@ -920,18 +920,17 @@ struct ActionFetchReady {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
enum GutterLineNumberWidth {
|
||||
#[default]
|
||||
Dynamic,
|
||||
Sticky {
|
||||
min_digits: usize,
|
||||
},
|
||||
pub struct SearchResultsStatus {
|
||||
pub pending: bool,
|
||||
pub results_stale: bool,
|
||||
pub query_confirmed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct ScrollRangeHold {
|
||||
held: bool,
|
||||
settled: Option<SettledScrollRange>,
|
||||
pub struct SearchResultsHold {
|
||||
pub status: SearchResultsStatus,
|
||||
pub min_line_number_digits: usize,
|
||||
settled_scroll_range: Option<SettledScrollRange>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -1006,8 +1005,7 @@ 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>,
|
||||
search_results_hold: Option<SearchResultsHold>,
|
||||
show_line_numbers: Option<bool>,
|
||||
use_relative_line_numbers: Option<bool>,
|
||||
show_git_diff_gutter: Option<bool>,
|
||||
@@ -1238,7 +1236,7 @@ pub struct EditorSnapshot {
|
||||
pub mode: EditorMode,
|
||||
show_gutter: bool,
|
||||
offset_content: bool,
|
||||
gutter_line_number_width: GutterLineNumberWidth,
|
||||
sticky_line_number_digits: usize,
|
||||
show_line_numbers: Option<bool>,
|
||||
number_deleted_lines: bool,
|
||||
show_git_diff_gutter: Option<bool>,
|
||||
@@ -2337,8 +2335,7 @@ 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,
|
||||
search_results_hold: None,
|
||||
show_line_numbers: (!full_mode).then_some(false),
|
||||
use_relative_line_numbers: None,
|
||||
disable_expand_excerpt_buttons: !full_mode,
|
||||
@@ -3078,7 +3075,9 @@ 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,
|
||||
sticky_line_number_digits: self
|
||||
.search_results_hold
|
||||
.map_or(0, |hold| hold.min_line_number_digits),
|
||||
show_line_numbers: self.show_line_numbers,
|
||||
number_deleted_lines: self.number_deleted_lines,
|
||||
show_git_diff_gutter: self.show_git_diff_gutter,
|
||||
@@ -3138,21 +3137,20 @@ 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 };
|
||||
pub fn set_search_results_status(
|
||||
&mut self,
|
||||
status: SearchResultsStatus,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let hold = self.search_results_hold.get_or_insert_default();
|
||||
let previous = mem::replace(&mut hold.status, status);
|
||||
let results_became_current = previous.results_stale && !status.results_stale;
|
||||
let search_settled = previous.pending && !status.pending;
|
||||
let allow_shrink = !status.results_stale
|
||||
&& (self.buffer.read(cx).read(cx).is_empty()
|
||||
|| status.query_confirmed && (results_became_current || search_settled));
|
||||
self.fit_gutter_line_number_width(allow_shrink, cx);
|
||||
if previous != status {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -3160,51 +3158,29 @@ impl Editor {
|
||||
/// 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 {
|
||||
fn fit_gutter_line_number_width(&mut self, allow_shrink: bool, cx: &mut Context<Self>) {
|
||||
let Some(hold) = &mut self.search_results_hold else {
|
||||
return;
|
||||
};
|
||||
let digits = self.widest_line_number_digits(cx);
|
||||
if digits != min_digits {
|
||||
self.gutter_line_number_width = GutterLineNumberWidth::Sticky { min_digits: digits };
|
||||
let digits = {
|
||||
let snapshot = self.buffer.read(cx).read(cx);
|
||||
if snapshot.is_empty() {
|
||||
0
|
||||
} else {
|
||||
(snapshot.widest_line_number().max(1).ilog10() + 1) as usize
|
||||
}
|
||||
};
|
||||
if hold.min_line_number_digits < digits
|
||||
|| allow_shrink && hold.min_line_number_digits != digits
|
||||
{
|
||||
hold.min_line_number_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)
|
||||
pub fn search_results_hold(&self) -> Option<SearchResultsHold> {
|
||||
self.search_results_hold
|
||||
}
|
||||
|
||||
fn frozen_scroll_range(
|
||||
@@ -3214,17 +3190,17 @@ impl Editor {
|
||||
current_editor_width: Pixels,
|
||||
current_editor_bounds_size: Size<Pixels>,
|
||||
) -> Option<SettledScrollRange> {
|
||||
let hold = self.scroll_range_hold.as_mut()?;
|
||||
let hold = self.search_results_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);
|
||||
if !hold.status.pending && !is_rewrapping {
|
||||
hold.settled_scroll_range = Some(current);
|
||||
return None;
|
||||
}
|
||||
let settled = hold.settled.get_or_insert(current);
|
||||
let settled = hold.settled_scroll_range.get_or_insert(current);
|
||||
if settled.editor_bounds_size != current_editor_bounds_size {
|
||||
*settled = current;
|
||||
}
|
||||
@@ -9851,7 +9827,7 @@ impl Editor {
|
||||
} => {
|
||||
self.scrollbar_marker_state.dirty = true;
|
||||
self.active_indent_guides_state.dirty = true;
|
||||
self.latch_gutter_line_number_width(cx);
|
||||
self.fit_gutter_line_number_width(false, cx);
|
||||
self.refresh_active_diagnostics(cx);
|
||||
self.refresh_code_actions_for_selection(window, cx);
|
||||
self.refresh_single_line_folds(window, cx);
|
||||
@@ -11960,13 +11936,9 @@ 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 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);
|
||||
.max(self.sticky_line_number_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)
|
||||
|
||||
@@ -45725,7 +45725,7 @@ async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut Test
|
||||
init_test(cx, |_| {});
|
||||
let mut cx = EditorTestContext::new(cx).await;
|
||||
|
||||
cx.update_editor(|editor, _, _| {
|
||||
cx.update_editor(|editor, _, cx| {
|
||||
let bounds = size(px(1800.), px(900.));
|
||||
let settled = |width: f32, height: f32, editor_width: f32, bounds: Size<Pixels>| {
|
||||
Some(SettledScrollRange {
|
||||
@@ -45734,7 +45734,13 @@ async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut Test
|
||||
editor_bounds_size: bounds,
|
||||
})
|
||||
};
|
||||
editor.hold_scrollbar_range(true);
|
||||
editor.set_search_results_status(
|
||||
SearchResultsStatus {
|
||||
pending: true,
|
||||
..SearchResultsStatus::default()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(
|
||||
editor.frozen_scroll_range(false, size(px(1780.), px(100.)), px(1786.), bounds),
|
||||
settled(1780., 100., 1786., bounds),
|
||||
@@ -45765,7 +45771,7 @@ async fn test_scroll_range_hold_freezes_before_first_settled_frame(cx: &mut Test
|
||||
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);
|
||||
editor.set_search_results_status(SearchResultsStatus::default(), cx);
|
||||
assert_eq!(
|
||||
editor.frozen_scroll_range(false, size(px(1770.), px(160.)), px(1776.), bounds),
|
||||
None,
|
||||
|
||||
@@ -9058,7 +9058,8 @@ impl Element for EditorElement {
|
||||
cx,
|
||||
);
|
||||
|
||||
let frozen_scroll_state = if self.editor.read(cx).scroll_range_hold.is_some() {
|
||||
let frozen_scroll_state = if self.editor.read(cx).search_results_hold.is_some()
|
||||
{
|
||||
let is_rewrapping =
|
||||
self.editor.read(cx).display_map.read(cx).is_rewrapping(cx);
|
||||
self.editor.update(cx, |editor, _| {
|
||||
|
||||
@@ -14,7 +14,7 @@ use anyhow::Context as _;
|
||||
use collections::HashMap;
|
||||
use editor::{
|
||||
Anchor, Editor, EditorEvent, EditorSettings, MAX_TAB_TITLE_LEN, MultiBuffer, PathKey,
|
||||
SelectionEffects,
|
||||
SearchResultsStatus, SelectionEffects,
|
||||
actions::{Backtab, FoldAll, SelectAll, Tab, UnfoldAll},
|
||||
items::active_match_index,
|
||||
multibuffer_context_lines,
|
||||
@@ -1530,7 +1530,6 @@ impl ProjectSearchView {
|
||||
}
|
||||
this.entity.update(cx, |model, cx| model.clear(cx));
|
||||
this.results_editor.update(cx, |editor, cx| {
|
||||
editor.reset_gutter_line_number_width(cx);
|
||||
editor.scroll(Point::default(), window, cx);
|
||||
});
|
||||
}
|
||||
@@ -1557,7 +1556,6 @@ impl ProjectSearchView {
|
||||
let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), window, cx);
|
||||
editor.set_searchable(false);
|
||||
editor.set_in_project_search(true);
|
||||
editor.enable_sticky_gutter_line_number(cx);
|
||||
editor
|
||||
});
|
||||
subscriptions.push(cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab)));
|
||||
@@ -1978,9 +1976,23 @@ impl ProjectSearchView {
|
||||
model.phase = SearchPhase::Confirmed;
|
||||
model.record_search_history(cx);
|
||||
});
|
||||
self.sync_search_results_status(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_search_results_status(&self, cx: &mut Context<Self>) {
|
||||
let model = self.entity.read(cx);
|
||||
let pending = model.pending_search.is_some();
|
||||
let status = SearchResultsStatus {
|
||||
pending,
|
||||
results_stale: pending && !model.results_refreshed,
|
||||
query_confirmed: model.phase == SearchPhase::Confirmed,
|
||||
};
|
||||
self.results_editor.update(cx, |editor, cx| {
|
||||
editor.set_search_results_status(status, cx)
|
||||
});
|
||||
}
|
||||
|
||||
/// The signature captures search inputs, not project content; returning to a previously
|
||||
/// searched query shows the old results until the user confirms with Enter, which always
|
||||
/// forces a refresh.
|
||||
@@ -2345,15 +2357,29 @@ impl ProjectSearchView {
|
||||
let results_stale = search_pending && !model.results_refreshed;
|
||||
let preserve_view = phase == SearchPhase::Typing || results_stale;
|
||||
let match_ranges = model.match_ranges.clone();
|
||||
self.results_editor
|
||||
.update(cx, |editor, _| editor.hold_scrollbar_range(search_pending));
|
||||
self.sync_search_results_status(cx);
|
||||
|
||||
if phase == SearchPhase::Typing
|
||||
&& let Some(first_match) = match_ranges.first()
|
||||
{
|
||||
self.results_editor.update(cx, |editor, cx| {
|
||||
let range_to_select = editor.range_for_match(first_match);
|
||||
if editor.selections.newest_anchor().range() != range_to_select {
|
||||
editor.change_selections(
|
||||
SelectionEffects::no_scroll().nav_history(false),
|
||||
window,
|
||||
cx,
|
||||
|s| s.select_ranges([range_to_select]),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if match_ranges.is_empty() {
|
||||
self.active_match_index = None;
|
||||
if !results_stale {
|
||||
self.results_editor.update(cx, |editor, cx| {
|
||||
editor.clear_background_highlights(HighlightKey::ProjectSearchView, cx);
|
||||
editor.refit_gutter_line_number_width(cx);
|
||||
if concluded_no_results {
|
||||
editor.scroll(Point::default(), window, cx);
|
||||
}
|
||||
@@ -2378,16 +2404,12 @@ impl ProjectSearchView {
|
||||
s.select_ranges(range_to_select)
|
||||
});
|
||||
editor.scroll(Point::default(), window, cx);
|
||||
editor.refit_gutter_line_number_width(cx);
|
||||
});
|
||||
if phase == SearchPhase::Confirmed
|
||||
&& self.query_editor.focus_handle(cx).is_focused(window)
|
||||
{
|
||||
self.focus_results_editor(window, cx);
|
||||
}
|
||||
} else if !search_pending && phase == SearchPhase::Confirmed {
|
||||
self.results_editor
|
||||
.update(cx, |editor, cx| editor.refit_gutter_line_number_width(cx));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3491,7 +3513,7 @@ pub mod tests {
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use editor::{DisplayPoint, display_map::DisplayRow};
|
||||
use editor::{DisplayPoint, ToPoint, display_map::DisplayRow};
|
||||
use gpui::{Action, TestAppContext, VisualTestContext, WindowHandle};
|
||||
use language::{FakeLspAdapter, Point as BufferPoint, rust_lang};
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -9041,7 +9063,8 @@ pub mod tests {
|
||||
search_view
|
||||
.results_editor
|
||||
.read(cx)
|
||||
.is_scrollbar_range_held()
|
||||
.search_results_hold()
|
||||
.map(|hold| hold.status.pending)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
@@ -9407,6 +9430,102 @@ pub mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_on_type_search_keeps_selection_on_first_match(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.background_executor.clone());
|
||||
let mut files = serde_json::Map::new();
|
||||
for ix in 0..50 {
|
||||
files.insert(
|
||||
format!("file_{ix:03}.txt"),
|
||||
json!(format!("line with needle {ix}\nmore needle text {ix}")),
|
||||
);
|
||||
}
|
||||
fs.insert_tree(path!("/dir"), serde_json::Value::Object(files))
|
||||
.await;
|
||||
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
|
||||
let window =
|
||||
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let workspace = window
|
||||
.read_with(cx, |mw, _| mw.workspace().clone())
|
||||
.unwrap();
|
||||
let search = cx.new(|cx| ProjectSearch::new(project.clone(), workspace.downgrade(), cx));
|
||||
let search_view = cx.add_window(|window, cx| {
|
||||
ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
|
||||
});
|
||||
|
||||
let selection_head = |cx: &mut TestAppContext| {
|
||||
search_view
|
||||
.update(cx, |search_view, _, cx| {
|
||||
search_view.results_editor.update(cx, |editor, cx| {
|
||||
let snapshot = editor.display_snapshot(cx);
|
||||
editor.selections.newest::<BufferPoint>(&snapshot).head()
|
||||
})
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let first_match_head = |cx: &mut TestAppContext| {
|
||||
search_view
|
||||
.update(cx, |search_view, _, cx| {
|
||||
let first_match = search_view
|
||||
.entity
|
||||
.read(cx)
|
||||
.match_ranges
|
||||
.first()
|
||||
.expect("the query matches the fixture")
|
||||
.clone();
|
||||
search_view.results_editor.update(cx, |editor, cx| {
|
||||
let snapshot = editor.display_snapshot(cx);
|
||||
editor
|
||||
.range_for_match(&first_match)
|
||||
.end
|
||||
.to_point(snapshot.buffer_snapshot())
|
||||
})
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
search_view
|
||||
.update(cx, |search_view, window, cx| {
|
||||
search_view.query_editor.update(cx, |query_editor, cx| {
|
||||
query_editor.set_text("needle", window, cx)
|
||||
});
|
||||
search_view.search(SearchMode::Manual, cx);
|
||||
})
|
||||
.unwrap();
|
||||
cx.background_executor.run_until_parked();
|
||||
assert_eq!(
|
||||
selection_head(cx),
|
||||
first_match_head(cx),
|
||||
"a confirmed search selects the first match"
|
||||
);
|
||||
|
||||
perform_incremental_search(search_view, "needle 4", cx);
|
||||
cx.background_executor.run_until_parked();
|
||||
assert_eq!(
|
||||
selection_head(cx),
|
||||
first_match_head(cx),
|
||||
"an on-type search must keep the selection on the first match too: a stale \
|
||||
anchor resolves to an arbitrary position, and relative line numbers measured \
|
||||
against it renumber unchanged excerpts on every keystroke"
|
||||
);
|
||||
assert_ne!(
|
||||
selection_head(cx),
|
||||
BufferPoint::zero(),
|
||||
"the fixture is arranged so the first match does not sit at the buffer start, \
|
||||
otherwise this test cannot tell a real selection from a collapsed stale anchor"
|
||||
);
|
||||
|
||||
perform_incremental_search(search_view, "needle 41", cx);
|
||||
cx.background_executor.run_until_parked();
|
||||
assert_eq!(
|
||||
selection_head(cx),
|
||||
first_match_head(cx),
|
||||
"narrowing again keeps following the first match"
|
||||
);
|
||||
}
|
||||
|
||||
fn perform_incremental_search(
|
||||
search_view: WindowHandle<ProjectSearchView>,
|
||||
text: impl Into<Arc<str>>,
|
||||
@@ -9483,8 +9602,8 @@ pub mod tests {
|
||||
search_view
|
||||
.results_editor
|
||||
.read(cx)
|
||||
.min_gutter_line_number_digits()
|
||||
.unwrap_or(0)
|
||||
.search_results_hold()
|
||||
.map_or(0, |hold| hold.min_line_number_digits)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user