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:
Kirill Bulatov authored and GitHub committed 2026-08-31 11:41:34 +00:00
1 parent c99db2149e
commit 77d716d5ce
4 files changed
+194 -96

No files matched your search

+50 -78
View File
@@ -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)
+9 -3
View File
@@ -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,
+2 -1
View File
@@ -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, _| {
+133 -14
View File
@@ -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()
}