C14.13: a search hit puts the caret on the match, not the line start
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
584e08d8ad
commit
10b9e7b694
8 files changed
+121
-24
No files matched your search
@@ -36,8 +36,8 @@ pub struct Desk {
|
||||
/// Files and folders opened lately, newest first.
|
||||
pub recent: Vec<PathBuf>,
|
||||
pub recent_file: PathBuf,
|
||||
/// A file asked to open at a line.
|
||||
pub going: Option<(PathBuf, usize)>,
|
||||
/// A file asked to open at a line and column.
|
||||
pub going: Option<(PathBuf, (usize, usize))>,
|
||||
/// The path each tab last put in the recent list.
|
||||
pub remembered: HashMap<u64, PathBuf>,
|
||||
pub(crate) side: Entity<crate::side::Side>,
|
||||
@@ -140,9 +140,9 @@ impl Host for Desk {
|
||||
.going
|
||||
.as_ref()
|
||||
.is_some_and(|(file, _)| Some(file) == real.as_ref())
|
||||
&& let Some((_, line)) = self.going.take()
|
||||
&& let Some((_, at)) = self.going.take()
|
||||
{
|
||||
self.reveal_later(view.clone(), line, cx);
|
||||
self.reveal_later(view.clone(), at, cx);
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
@@ -337,14 +337,19 @@ impl Desk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings `line` into view from outside a window update.
|
||||
pub fn reveal_later(&mut self, view: Entity<MarkView>, line: usize, cx: &mut Context<Self>) {
|
||||
/// Shows line and column `at` from outside an update.
|
||||
pub fn reveal_later(
|
||||
&mut self,
|
||||
view: Entity<MarkView>,
|
||||
at: (usize, usize),
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let shown = ely_office::in_window(&desk, cx, |_, window, cx| {
|
||||
view.update(cx, |view, cx| view.reveal(line, window, cx))
|
||||
view.update(cx, |view, cx| view.reveal(at, window, cx))
|
||||
});
|
||||
if let Err(error) = shown {
|
||||
log::warn!("mark: line {line} was not shown: {error:#}");
|
||||
log::warn!("mark: line {} was not shown: {error:#}", at.0);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
@@ -68,7 +68,7 @@ fn a_file_opens_with_its_outline_and_joins_the_recent_list(cx: &mut TestAppConte
|
||||
[(1, "One".into(), 0), (2, "Two".into(), 4)]
|
||||
);
|
||||
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
|
||||
view.update_in(cx, |view, window, cx| view.reveal(4, window, cx));
|
||||
view.update_in(cx, |view, window, cx| view.reveal((4, 0), window, cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
view.read_with(cx, |view, cx| view.place(cx).line),
|
||||
|
||||
@@ -49,9 +49,24 @@ pub struct Hit {
|
||||
pub file: PathBuf,
|
||||
/// Counted from one.
|
||||
pub line: usize,
|
||||
/// The first match, in characters from zero.
|
||||
pub column: usize,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Where lowercase `query` starts in `line`, in characters.
|
||||
fn column_of(line: &str, query: &str) -> Option<usize> {
|
||||
// Each lowered byte keeps its character's place.
|
||||
let (mut lowered, mut places) = (String::new(), Vec::new());
|
||||
for (place, ch) in line.chars().enumerate() {
|
||||
for low in ch.to_lowercase() {
|
||||
lowered.push(low);
|
||||
places.extend(std::iter::repeat_n(place, low.len_utf8()));
|
||||
}
|
||||
}
|
||||
lowered.find(query).map(|at| places[at])
|
||||
}
|
||||
|
||||
/// Lines of `text` holding `query`, any case.
|
||||
pub fn lines_with(file: &Path, text: &str, query: &str, hits: &mut Vec<Hit>) {
|
||||
let query = query.to_lowercase();
|
||||
@@ -59,11 +74,12 @@ pub fn lines_with(file: &Path, text: &str, query: &str, hits: &mut Vec<Hit>) {
|
||||
if hits.len() == MOST_HITS {
|
||||
return;
|
||||
}
|
||||
if line.to_lowercase().contains(&query) {
|
||||
if let Some(column) = column_of(line, &query) {
|
||||
let shown: String = line.trim().chars().take(160).collect();
|
||||
hits.push(Hit {
|
||||
file: file.to_path_buf(),
|
||||
line: at + 1,
|
||||
column,
|
||||
text: shown,
|
||||
});
|
||||
}
|
||||
@@ -108,8 +124,16 @@ mod tests {
|
||||
);
|
||||
let lines: Vec<_> = hits
|
||||
.iter()
|
||||
.map(|hit| (hit.line, hit.text.as_str()))
|
||||
.map(|hit| (hit.line, hit.column, hit.text.as_str()))
|
||||
.collect();
|
||||
assert_eq!(lines, [(2, "some Words here"), (4, "WORDS")]);
|
||||
assert_eq!(lines, [(2, 5, "some Words here"), (4, 0, "WORDS")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_column_counts_characters_through_case_changes() {
|
||||
// İ lowers to two characters.
|
||||
assert_eq!(super::column_of("İİ née Word", "word"), Some(7));
|
||||
assert_eq!(super::column_of("中文 搜索", "搜索"), Some(3));
|
||||
assert_eq!(super::column_of("none", "word"), None);
|
||||
}
|
||||
}
|
||||
@@ -108,12 +108,12 @@ impl Desk {
|
||||
}
|
||||
}
|
||||
|
||||
/// Line `line` in `tab`, or in `file` once open.
|
||||
/// Line and column `at` in a tab, or a file once open.
|
||||
pub fn open_at(
|
||||
&mut self,
|
||||
tab: Option<u64>,
|
||||
file: PathBuf,
|
||||
line: usize,
|
||||
at: (usize, usize),
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
@@ -123,14 +123,14 @@ impl Desk {
|
||||
if let Some(view) = self.tab(id).map(|open| open.view.clone()) {
|
||||
// After the list's own key handling, which keeps focus.
|
||||
window.defer(cx, move |window, cx| {
|
||||
view.update(cx, |view, cx| view.reveal(line, window, cx))
|
||||
view.update(cx, |view, cx| view.reveal(at, window, cx))
|
||||
});
|
||||
}
|
||||
return cx.notify();
|
||||
}
|
||||
// A file gone is reported by the open itself.
|
||||
if let Ok(real) = std::fs::canonicalize(&file) {
|
||||
self.going = Some((real, line));
|
||||
self.going = Some((real, at));
|
||||
}
|
||||
self.open_file(file, cx);
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ impl Side {
|
||||
};
|
||||
// The heading in view, the caret on it.
|
||||
let line = heading.line;
|
||||
view.update(cx, |view, cx| view.reveal(line, window, cx));
|
||||
view.update(cx, |view, cx| view.reveal((line, 0), window, cx));
|
||||
});
|
||||
ScrollArea::new("mark-outline-scroll")
|
||||
.vertical()
|
||||
@@ -317,9 +317,9 @@ impl Side {
|
||||
return;
|
||||
};
|
||||
// Hits count lines from one.
|
||||
let (tab, file, line) = (*tab, hit.file.clone(), hit.line - 1);
|
||||
let (tab, file, at) = (*tab, hit.file.clone(), (hit.line - 1, hit.column));
|
||||
opener
|
||||
.update(cx, |desk, cx| desk.open_at(tab, file, line, window, cx))
|
||||
.update(cx, |desk, cx| desk.open_at(tab, file, at, window, cx))
|
||||
.ok();
|
||||
});
|
||||
let more = (self.hits.len() == MOST_HITS)
|
||||
|
||||
@@ -45,3 +45,69 @@ fn a_long_outline_scrolls_to_its_last_heading(cx: &mut TestAppContext) {
|
||||
"the last heading is in view: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Searches the folder for `words`; the hits as found.
|
||||
fn search(
|
||||
side: &gpui::Entity<super::Side>,
|
||||
words: &str,
|
||||
cx: &mut gpui::VisualTestContext,
|
||||
) -> Vec<(Option<u64>, crate::folder::Hit)> {
|
||||
side.update(cx, |side, cx| {
|
||||
side.query.update(cx, |query, cx| query.set_text(words, cx));
|
||||
side.wide = true;
|
||||
side.search(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
side.read_with(cx, |side, _| side.hits.clone())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_hit_opens_its_file_with_the_caret_on_the_match(cx: &mut TestAppContext) {
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
std::fs::write(files.path().join("a.md"), "# A\n\nNo match.\n").expect("a");
|
||||
std::fs::write(files.path().join("b.md"), "# B\n\nThe Needle here.\n").expect("b");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, files.path(), cx);
|
||||
let side = desk.read_with(cx, |desk, _| desk.side.clone());
|
||||
let hits = search(&side, "needle", cx);
|
||||
let found: Vec<_> = hits.iter().map(|(_, hit)| (hit.line, hit.column)).collect();
|
||||
assert_eq!(found, [(3, 4)]);
|
||||
let (tab, hit) = hits[0].clone();
|
||||
assert_eq!(tab, None, "b.md is not open");
|
||||
desk.update_in(cx, |desk, window, cx| {
|
||||
desk.open_at(tab, hit.file, (hit.line - 1, hit.column), window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let place = desk.read_with(cx, |desk, cx| {
|
||||
desk.active_view().expect("b.md opened").read(cx).place(cx)
|
||||
});
|
||||
assert_eq!((place.line, place.column), (2, 4));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_hit_in_an_open_file_moves_its_caret(cx: &mut TestAppContext) {
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("a.md");
|
||||
std::fs::write(&path, "# A\n\nfirst\n\nsecond Word\n").expect("a");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, files.path(), cx);
|
||||
open(&desk, &path, cx);
|
||||
let side = desk.read_with(cx, |desk, _| desk.side.clone());
|
||||
let hits = search(&side, "word", cx);
|
||||
let (tab, hit) = hits[0].clone();
|
||||
assert!(tab.is_some(), "the open tab searches");
|
||||
desk.update_in(cx, |desk, window, cx| {
|
||||
desk.open_at(tab, hit.file, (hit.line - 1, hit.column), window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let place = desk.read_with(cx, |desk, cx| {
|
||||
desk.active_view().expect("a").read(cx).place(cx)
|
||||
});
|
||||
assert_eq!((place.line, place.column), (4, 7));
|
||||
}
|
||||
@@ -186,10 +186,12 @@ impl MarkView {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Line `line` in view and focus.
|
||||
pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.document
|
||||
.update(cx, |document, cx| document.reveal(line, window, cx));
|
||||
/// Line and column `at` in view and focus, caret there.
|
||||
pub fn reveal(&mut self, at: (usize, usize), window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.document.update(cx, |document, cx| {
|
||||
document.reveal(at.0, window, cx);
|
||||
document.set_caret(at.0, at.1, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// The caret and the view, to keep across launches.
|
||||
|
||||
Reference in new issue
Block a user