C14.15: the kept mode is typed, an unknown one does not read; relaunch and crash restore tested in windows

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 11:21:23 -04:00
1 parent d9dadb3d0e
commit e1b889cb7f
4 files changed
+229 -68

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
+4 -66
View File
@@ -21,7 +21,7 @@ use crate::view::{Mode, Place};
pub struct Kept {
path: Option<PathBuf>,
name: String,
mode: String,
mode: Mode,
place: Option<Place>,
slot: String,
}
@@ -219,7 +219,7 @@ impl Desk {
else {
return;
};
let mode = Mode::parse(&kept.mode).unwrap_or(Mode::Visual);
let mode = kept.mode;
log::info!("mark: restores tab {id} as {}", mode.name());
let tab = self.tab_mut(id).expect("open");
// A recovered copy keeps writing to the same points.
@@ -262,7 +262,7 @@ impl Desk {
kept.push(Kept {
path: tab.session.path().map(Path::to_path_buf),
name: tab.name.to_string(),
mode: view.mode.name().to_owned(),
mode: view.mode,
place: Some(view.place(cx)),
slot: tab.extra.slot.clone(),
});
@@ -405,66 +405,4 @@ fn humantime(at: SystemTime) -> String {
}
#[cfg(test)]
mod tests {
#[test]
fn a_place_without_a_column_or_top_reads_zero() {
let kept: super::Kept = serde_json::from_str(
r#"{"path":null,"name":"a.md","mode":"source","place":{"line":3,"scroll":0},"slot":"s"}"#,
)
.expect("an older place reads");
let place = kept.place.expect("a place");
assert_eq!((place.line, place.column, place.top), (3, 0, 0));
}
#[test]
fn each_untitled_copy_goes_to_the_tab_made_for_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let kept = |name: &str, mode: &str, slot: &str| super::Kept {
path: None,
name: name.into(),
mode: mode.into(),
place: Some(super::Place { line: slot.len() as u64, column: 1, top: 0 }),
slot: slot.into(),
};
let tabs = vec![kept("Untitled1", "visual", "a"), kept("Untitled2", "source", "bb")];
for (slot, text) in [("a", "first"), ("bb", "second")] {
let mut points = super::Recovery::new(super::recovery(dir.path(), slot));
points.write_once(text.as_bytes(), None, std::time::SystemTime::now()).expect("a point");
}
let saved = serde_json::to_vec(&super::Saved { tabs: tabs.clone() }).expect("a list");
std::fs::write(dir.path().join("session.json"), saved).expect("the list");
// A pid no process has: the last run crashed.
std::fs::write(dir.path().join("session.lock"), "open 4294967295").expect("a lock");
let (mut keeping, ..) = super::Keeping::start(dir.path()).expect("a session");
let made: Vec<_> = keeping.untitled();
assert_eq!(made, [(0, "Untitled1".to_string()), (1, "Untitled2".to_string())]);
for (at, _) in made {
keeping.made(at, 10 + at as u64);
}
assert!(keeping.take(None, 12).is_none(), "a new tab takes nothing");
// The second tab shows first: each still takes its own.
for (id, want, text) in [(11, &tabs[1], "second"), (10, &tabs[0], "first")] {
let got = keeping.take(None, id).expect("its copy");
assert_eq!((&got.kept, got.text.map(|(text, _)| text)), (want, Some(text.to_string())));
}
}
#[test]
fn an_unasked_end_keeps_the_lock_and_points_past_release() {
let dir = tempfile::tempdir().expect("a temp dir");
let point = dir.path().join("recovery/s/point");
let (lock, slot) = (dir.path().join("session.lock"), point.parent().expect("a slot"));
let (mut held, ..) = super::Keeping::start(dir.path()).expect("a session");
std::fs::create_dir_all(slot).expect("a slot");
std::fs::write(&point, "unsaved").expect("a point");
held.hold();
held.close();
assert!(lock.exists() && point.exists(), "release after quit wiped them");
drop(held);
let (mut clean, ..) = super::Keeping::start(dir.path()).expect("a session");
std::fs::create_dir_all(slot).expect("a slot");
std::fs::write(&point, "unsaved").expect("a point");
clean.close();
assert!(!lock.exists() && !point.exists(), "a clean end clears");
}
}
mod tests;
+222
View File
@@ -0,0 +1,222 @@
use crate::view::Mode;
#[test]
fn a_place_without_a_column_or_top_reads_zero() {
let kept: super::Kept = serde_json::from_str(
r#"{"path":null,"name":"a.md","mode":"source","place":{"line":3,"scroll":0},"slot":"s"}"#,
)
.expect("an older place reads");
let place = kept.place.expect("a place");
assert_eq!((place.line, place.column, place.top), (3, 0, 0));
}
#[test]
fn each_untitled_copy_goes_to_the_tab_made_for_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let kept = |name: &str, mode: Mode, slot: &str| super::Kept {
path: None,
name: name.into(),
mode,
place: Some(super::Place {
line: slot.len() as u64,
column: 1,
top: 0,
}),
slot: slot.into(),
};
let tabs = vec![
kept("Untitled1", Mode::Visual, "a"),
kept("Untitled2", Mode::Source, "bb"),
];
for (slot, text) in [("a", "first"), ("bb", "second")] {
let mut points = super::Recovery::new(super::recovery(dir.path(), slot));
points
.write_once(text.as_bytes(), None, std::time::SystemTime::now())
.expect("a point");
}
let saved = serde_json::to_vec(&super::Saved { tabs: tabs.clone() }).expect("a list");
std::fs::write(dir.path().join("session.json"), saved).expect("the list");
// A pid no process has: the last run crashed.
std::fs::write(dir.path().join("session.lock"), "open 4294967295").expect("a lock");
let (mut keeping, ..) = super::Keeping::start(dir.path()).expect("a session");
let made: Vec<_> = keeping.untitled();
assert_eq!(
made,
[(0, "Untitled1".to_string()), (1, "Untitled2".to_string())]
);
for (at, _) in made {
keeping.made(at, 10 + at as u64);
}
assert!(keeping.take(None, 12).is_none(), "a new tab takes nothing");
// The second tab shows first: each still takes its own.
for (id, want, text) in [(11, &tabs[1], "second"), (10, &tabs[0], "first")] {
let got = keeping.take(None, id).expect("its copy");
assert_eq!(
(&got.kept, got.text.map(|(text, _)| text)),
(want, Some(text.to_string()))
);
}
}
#[test]
fn an_unasked_end_keeps_the_lock_and_points_past_release() {
let dir = tempfile::tempdir().expect("a temp dir");
let point = dir.path().join("recovery/s/point");
let (lock, slot) = (
dir.path().join("session.lock"),
point.parent().expect("a slot"),
);
let (mut held, ..) = super::Keeping::start(dir.path()).expect("a session");
std::fs::create_dir_all(slot).expect("a slot");
std::fs::write(&point, "unsaved").expect("a point");
held.hold();
held.close();
assert!(
lock.exists() && point.exists(),
"release after quit wiped them"
);
drop(held);
let (mut clean, ..) = super::Keeping::start(dir.path()).expect("a session");
std::fs::create_dir_all(slot).expect("a slot");
std::fs::write(&point, "unsaved").expect("a point");
clean.close();
assert!(!lock.exists() && !point.exists(), "a clean end clears");
}
mod window {
use gpui::{Entity, TestAppContext, VisualTestContext};
use crate::desk::{
Desk,
tests::{open, start},
};
use crate::view::{MarkView, Mode};
/// Shows the tab named `name`; its view.
fn show(desk: &Entity<Desk>, name: &str, cx: &mut VisualTestContext) -> Entity<MarkView> {
let view = desk.update(cx, |desk, cx| {
let tab = desk.core.tabs.iter().find(|tab| tab.name.as_ref() == name);
let tab = tab.unwrap_or_else(|| panic!("no tab {name}"));
let (id, view) = (tab.id, tab.view.clone());
desk.core.active = Some(id);
cx.notify();
view
});
cx.run_until_parked();
view
}
fn names(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<String> {
desk.read_with(cx, |desk, _| {
desk.core
.tabs
.iter()
.map(|tab| tab.name.to_string())
.collect()
})
}
#[gpui::test]
fn a_new_launch_brings_back_tabs_modes_places_and_look(
first: &mut TestAppContext,
second: &mut TestAppContext,
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let long: String = (0..80).map(|n| format!("Line {n}\n\n")).collect();
let (a, b) = (files.path().join("a.md"), files.path().join("b.md"));
std::fs::write(&a, &long).expect("a");
std::fs::write(&b, "# B\n").expect("b");
let (desk, cx) = start(first, data.path());
open(&desk, &a, cx);
open(&desk, &b, cx);
let view = show(&desk, "a.md", cx);
view.update_in(cx, |view, window, cx| {
view.switch(Mode::Source, window, cx);
view.document().update(cx, |document, cx| {
document.set_caret(40, 2, cx);
document.set_top_line(30, window, cx);
});
});
cx.run_until_parked();
desk.update(cx, |desk, cx| {
desk.relook(|look| look.focus = true, cx);
desk.leave(cx);
desk.keeping.close();
});
cx.run_until_parked();
let (desk, cx) = start(second, data.path());
assert_eq!(names(&desk, cx), ["a.md", "b.md"]);
let view = show(&desk, "a.md", cx);
let (mode, place) = view.read_with(cx, |view, cx| (view.mode, view.place(cx)));
assert_eq!(mode, Mode::Source);
assert_eq!((place.line, place.column, place.top), (40, 2, 30));
assert!(
desk.read_with(cx, |desk, _| desk.look.focus),
"the look came back"
);
}
#[gpui::test]
fn a_crash_brings_unsaved_text_back_as_a_copy(
first: &mut TestAppContext,
second: &mut TestAppContext,
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let a = files.path().join("a.md");
std::fs::write(&a, "Text.\n").expect("a");
let (desk, cx) = start(first, data.path());
open(&desk, &a, cx);
let view = show(&desk, "a.md", cx);
view.update(cx, |view, cx| view.adopt("Changed.\n".into(), cx));
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
desk.update(cx, |desk, cx| desk.keep(cx));
cx.run_until_parked();
// A pid no process has: this run crashed.
std::fs::write(data.path().join("session.lock"), "open 4294967295").expect("a lock");
let (desk, cx) = start(second, data.path());
assert_eq!(names(&desk, cx).len(), 2, "{:?}", names(&desk, cx));
let copy = desk.read_with(cx, |desk, _| {
let tab = desk
.core
.tabs
.iter()
.find(|tab| tab.session.path().is_none());
tab.expect("an untitled copy").name.to_string()
});
show(&desk, &copy, cx);
let (text, dirty) = desk.read_with(cx, |desk, cx| {
let tab = desk
.core
.tabs
.iter()
.find(|tab| tab.name.as_ref() == copy)
.expect("the copy");
(desk.text_of(tab.id).expect("text"), tab.dirty(cx))
});
assert_eq!(text, "Changed.\n");
assert!(dirty, "the copy is unsaved");
assert_eq!(
std::fs::read_to_string(&a).expect("reads"),
"Text.\n",
"the file stays"
);
}
#[test]
fn a_kept_mode_no_build_knows_does_not_read() {
let read = serde_json::from_str::<super::super::Kept>(
r#"{"path":null,"name":"a.md","mode":"split","place":null,"slot":"s"}"#,
);
assert!(read.is_err(), "an unknown mode is no default");
}
}
+2 -1
View File
@@ -17,7 +17,8 @@ pub struct Reported;
pub struct Failed(pub String);
/// How a document shows (MARK-02).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Visual,
Source,