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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 09:33:02 -04:00
1 parent 584e08d8ad
commit 10b9e7b694
8 files changed
+121 -24

No files matched your search

+1 -1
View File
@@ -283,4 +283,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。
- 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。
- 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
+13 -8
View File
@@ -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();
+1 -1
View File
@@ -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),
+27 -3
View File
@@ -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);
}
}
+4 -4
View File
@@ -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);
}
+3 -3
View File
@@ -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)
+66
View File
@@ -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));
}
+6 -4
View File
@@ -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.