diff --git a/agents.md b/agents.md index 8355677..893c692 100644 --- a/agents.md +++ b/agents.md @@ -30,7 +30,7 @@ patches/ 对上游的补丁,随源码发行(libreoffice、mupdf) packaging/ 三端打包脚本,apps.tsv 是八个应用的二进制名、名字、桌面分类与发布批次;icons/ 由图标母版渲染各端格式;libreoffice/ely.xcd 是内核的默认配置层 assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns、png、ico,规则见其 README dist/ 打包产物,不入库 -docs/ 工程风险与已知限制 +docs/ 兼容清单、无障碍、帮助、许可、性能记录;engineering-risks.md、qa.md 等内部笔记只在本机,不入库 scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎 tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely) tasks/ TASKS.md 进度真相 @@ -192,7 +192,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并推送(2026-10-09 起推到 git.zacharyzhang.com,HTTPS,令牌在本机钥匙串)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。 - 2026-10-06:审查者为探针复制的源码树须用自己的 `CARGO_TARGET_DIR`(`scripts/review.sh` 写明);写完结论前删掉这些副本与其构建目录(曾积到 /tmp 近 900 GB,磁盘写满、链接失败):曾编进本仓库 `target/`,ely-lok 里烘焙的源码路径指向已删的副本,单测找不到 worker。 - 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high;本机 `codex` 别名已带 `--yolo`,重复传参会报错)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。 -- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(macOS 另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定义删除与列表判定、自动计算状态、xlsx/xls 的手动计算往返。`0015-aqua-no-wait-with-a-due-timer.patch`:Aqua 在直接超时待处理时不阻塞等原生事件(请求块里起的 0 毫秒定时器原会让调度器停到下一个请求)。`0005` 另加存活标记:发送消息的客户端关掉对话框后不再读已释放成员。worker 记下每个文档载入时的内核视图,每次请求先切到该视图(Calc 按当前视图读写,多工作簿时否则会错表)。切表经视图的有序输入队列。视图记录跳转所需的最远点,滚动与图块以内核文档尺寸和它的较大者为界(Calc 空白区随可见区扩展)。视图按内核报的冻结行列(`.uno:FreezePanesColumn/Row`)分格:冻结部分不随滚动,其余照滚,图块按格裁剪,命中与表头同一换算(`view/panes.rs`)。文档占用 Tab 与 Shift-Tab(在 LokView 键位上下文里优先于焦点切换):Writer 插制表符或跳表格,Calc 跳单元格。worker 崩溃时打印本机栈(`worker/fatal.rs`),客户端报出退出信号。官方 macOS 包的 `paintTile` 不出图,上游 master 的修复也不够。Skia 必须开。工作进程在主线程以 unipoll 模式运行,请求经主 GCD 队列派发,环境设 `SAL_FORCE_HIDPI_SCALING=1` 与 `SAL_SKIA=raster`。证据见 `docs/engineering-risks.md` R-01。 +- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(macOS 另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定义删除与列表判定、自动计算状态、xlsx/xls 的手动计算往返。`0015-aqua-no-wait-with-a-due-timer.patch`:Aqua 在直接超时待处理时不阻塞等原生事件(请求块里起的 0 毫秒定时器原会让调度器停到下一个请求)。`0005` 另加存活标记:发送消息的客户端关掉对话框后不再读已释放成员。worker 记下每个文档载入时的内核视图,每次请求先切到该视图(Calc 按当前视图读写,多工作簿时否则会错表)。切表经视图的有序输入队列。视图记录跳转所需的最远点,滚动与图块以内核文档尺寸和它的较大者为界(Calc 空白区随可见区扩展)。视图按内核报的冻结行列(`.uno:FreezePanesColumn/Row`)分格:冻结部分不随滚动,其余照滚,图块按格裁剪,命中与表头同一换算(`view/panes.rs`)。文档占用 Tab 与 Shift-Tab(在 LokView 键位上下文里优先于焦点切换):Writer 插制表符或跳表格,Calc 跳单元格。worker 崩溃时打印本机栈(`worker/fatal.rs`),客户端报出退出信号。官方 macOS 包的 `paintTile` 不出图,上游 master 的修复也不够。Skia 必须开。工作进程在主线程以 unipoll 模式运行,请求经主 GCD 队列派发,环境设 `SAL_FORCE_HIDPI_SCALING=1` 与 `SAL_SKIA=raster`。证据见 `docs/engineering-risks.md` R-01(本机笔记,不入库)。 - 2026-10-04:LOK 命令与对话框。界面只经 `ely_lok::commands::COMMANDS` 发 UNO 命令,worker 拒绝表外命令;内核弹出的对话框(`LOK_CALLBACK_WINDOW` created 或 JSDialog)在回调里即刻关闭并经 `LokError` 报给应用,需要的对话框由 Ely 自建。起因:Calc 的 Backspace 弹同步模态“删除内容”,曾卡死 worker。 - 2026-10-04:ElyDoc 查找替换、拼写、比较、邮件合并(DOC-12 至 14)。查找经 `.uno:ExecuteSearch`:查全部计数并高亮,下一处按全部的矩形定当前序号;替换一处后重新计数,无当前项时“替换”先选下一处。拼写建议来自右键时内核给的上下文菜单,只取拼写部分,`ContextMenu::manual` 在插入点下打开。比较把当前文档存成工作副本、开成新标签、对它发 `.uno:CompareDocuments`,两个源文件都不动。邮件合并的字段在正文里写成 `«列名»`(同 Word 的显示,已定),预览与生成都是对模板副本整体替换;生成逐行写 DOCX 或 PDF 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。 - 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,macOS 用 PDFKit 的 `NSPrintOperation` 弹系统打印面板(`ely_platform::print`),其他平台报错,未实现。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。 diff --git a/apps/ely-doc/src/tests/more.rs b/apps/ely-doc/src/tests/more.rs index 4ee1fce..e37d0e5 100644 --- a/apps/ely-doc/src/tests/more.rs +++ b/apps/ely-doc/src/tests/more.rs @@ -263,3 +263,41 @@ fn closing_a_tab_takes_its_export_dialog() { // Drawn now, a dialog for the gone tab would crash. assert!(cx.update(|app| docs.read(app).dialog.is_none()), "the dialog went with its tab"); } + +#[test] +fn a_tab_on_a_vanished_folder_takes_no_other_file() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + let gone = dir.path().join("volume"); + std::fs::create_dir(&gone).expect("a folder"); + let first = gone.join("first.docx"); + with_window(&mut cx, &docs, |docs, window, cx| docs.write_as(id, first.clone(), None, window, cx)); + written(&cx, &first, None); + // Its volume goes away; another file still saves. + std::fs::remove_dir_all(&gone).expect("the folder goes"); + let other = dir.path().join("other.docx"); + let holder = cx.update(|app| docs.read(app).holder(&other).map_err(|error| error.to_string())); + assert_eq!(holder, Ok(None)); +} + +#[test] +fn an_edit_after_a_save_raced_it_counts_again() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let Some(docs) = docs(&mut cx, dir.path()) else { + eprintln!("SKIP: no LOK build"); + return; + }; + let id = blank(&mut cx, &docs); + type_into(&mut cx, &docs, "a"); + settle(&cx, |app| docs.read(app).tab(id).is_some_and(|tab| tab.modified)); + // A save's ticket landed after this edit: saved, still modified. + cx.update(|app| docs.update(app, |docs, _| docs.tab_mut(id).expect("a tab").session.reverted())); + type_into(&mut cx, &docs, "b"); + settle(&cx, |app| docs.read(app).tab(id).is_some_and(|tab| tab.session.state() == ely_files::SaveState::Dirty)); +} diff --git a/apps/ely-mark/src/compare.rs b/apps/ely-mark/src/compare.rs index 35a7d14..6a305b1 100644 --- a/apps/ely-mark/src/compare.rs +++ b/apps/ely-mark/src/compare.rs @@ -120,6 +120,7 @@ impl Desk { return self.set_outside(id, Outside::Unread(why), cx); } tab.extra.base = disk.text.clone(); + tab.mark = crate::engine::mark(&disk.text); tab.extra.restart_autosave(); tab.doc.lock().expect("the text lock").source = disk; let view = tab.view.clone(); diff --git a/apps/ely-mark/src/desk.rs b/apps/ely-mark/src/desk.rs index 5aa45cf..83c6c47 100644 --- a/apps/ely-mark/src/desk.rs +++ b/apps/ely-mark/src/desk.rs @@ -53,7 +53,9 @@ pub struct Desk { pub keeping: crate::restore::Keeping, /// The export dialog's choice, while it is open. pub output: Option, - pub printing: Option, + /// Pages printing to PDF, by job; several may run. + pub printing: HashMap, + pub print_jobs: u64, pub look: crate::look::Look, pub look_file: PathBuf, /// Git's word on the shown file, and which tab asked. @@ -113,7 +115,7 @@ impl Host for Desk { self.watch_folders(cx); let look = serde_json::to_value(&self.look).expect("a look serializes"); view.update(cx, |view, cx| view.set_look(look, cx)); - let changed = view.read(cx).changed; + let changed = view.read(cx).changed(); self.autosave(id, changed, cx); let recheck = self.tab_mut(id).is_some_and(|tab| { if !tab.dirty(cx) { @@ -200,7 +202,8 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { comparing: None, keeping, output: None, - printing: None, + printing: Default::default(), + print_jobs: 0, look: look.clone(), look_file, tracked: None, @@ -228,8 +231,13 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { desk.open_path(path, cx); } } - for name in desk.keeping.untitled() { + for (at, name) in desk.keeping.untitled() { + // Tab numbers come from `next` as the load starts. + let id = desk.core.next; desk.new_from_text(&name, "", cx); + if desk.core.next > id { + desk.keeping.made(at, id); + } } for path in asked { desk.open_path(path, cx); @@ -246,8 +254,13 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { } }) .detach(); - cx.on_app_quit(|desk, _| { - desk.keeping.close(); + cx.on_app_quit(|desk, cx| { + // A system end skips asking: unsaved tabs keep points. + if desk.core.tabs.iter().any(|tab| tab.dirty(cx)) { + log::warn!("mark: quit with unsaved tabs; points stay"); + } else { + desk.keeping.close(); + } async {} }) .detach(); diff --git a/apps/ely-mark/src/engine.rs b/apps/ely-mark/src/engine.rs index f2fcf9f..a9cedfe 100644 --- a/apps/ely-mark/src/engine.rs +++ b/apps/ely-mark/src/engine.rs @@ -132,7 +132,7 @@ impl Engine for Mark { } fn edited(view: &MarkView) -> u64 { - view.changed + view.changed() } fn edit_pending(view: &MarkView) -> bool { diff --git a/apps/ely-mark/src/export.rs b/apps/ely-mark/src/export.rs index 7b1d4b2..8497000 100644 --- a/apps/ely-mark/src/export.rs +++ b/apps/ely-mark/src/export.rs @@ -108,8 +108,7 @@ impl Desk { let Some(id) = self.active_tab().map(|tab| tab.id) else { return; }; - let pdf = self.core.work.join(format!("print-{id}.pdf")); - self.render_pdf(id, pdf, None, cx); + self.render_pdf(id, None, cx); } /// Asks where, renders the whole text, then writes it. @@ -126,20 +125,28 @@ impl Desk { let dir = ely_office::folder(tab).unwrap_or_else(std::env::temp_dir); let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", output.extension()))); cx.spawn(async move |desk, cx| { - let Ok(Ok(Some(path))) = chosen.await else { - return log::info!("mark: the export panel closed without a path"); + // A cancel says nothing; a panel that failed says why. + let failed = match chosen.await { + Ok(Ok(Some(path))) => Ok(path), + Ok(Ok(None)) => return log::info!("mark: the export panel was cancelled"), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + let path = match failed { + Ok(path) => path, + Err(why) => { + if desk.update(cx, |desk, cx| desk.panel_failed(&why, cx)).is_err() { + log::error!("mark: the export panel failed after its window: {why}"); + } + return; + } }; desk.update(cx, |desk, cx| { if let Err(error) = not_open(desk, &path) { return desk.exported(Err(error), cx); } match output { - Output::Pdf => desk.render_pdf( - id, - desk.core.work.join(format!("export-{id}.pdf")), - Some(path), - cx, - ), + Output::Pdf => desk.render_pdf(id, Some(path), cx), _ => desk.write_output(id, output, path, cx), } }) @@ -257,7 +264,11 @@ impl Desk { } /// The whole text as PDF; then to `to`, else printed. - fn render_pdf(&mut self, id: u64, pdf: PathBuf, to: Option, cx: &mut Context) { + fn render_pdf(&mut self, id: u64, to: Option, cx: &mut Context) { + // Each job has its own files; none cancels another. + self.print_jobs += 1; + let n = self.print_jobs; + let pdf = self.core.work.join(format!("print-{n}.pdf")); let Some(tab) = self.tab(id) else { return; }; @@ -273,14 +284,20 @@ impl Desk { view.render( "html", move |rendered, cx: &mut App| { - let staged = work.join(format!("print-{id}.html")); - let page = rendered.map_err(io::Error::other).and_then(|rendered| { - let (html, images) = paper::Marked::read(&rendered)?.inline(&folder); - let style = format!("", std::fs::read_to_string(&style)?); - std::fs::write(&staged, paper::page(&name, &style, &html))?; - Ok(Lost::of(images, &rendered)) - }); + let staged = work.join(format!("print-{n}.html")); + let writing = staged.clone(); cx.spawn(async move |cx| { + // Images inline and the page writes off the window thread. + let page = cx + .background_spawn(async move { + rendered.map_err(io::Error::other).and_then(|rendered| { + let (html, images) = paper::Marked::read(&rendered)?.inline(&folder); + let style = format!("", std::fs::read_to_string(&style)?); + std::fs::write(&writing, paper::page(&name, &style, &html))?; + Ok(Lost::of(images, &rendered)) + }) + }) + .await; in_window(&desk, cx, |desk, window, cx| match page { Ok(lost) => { let job = Job { @@ -289,7 +306,7 @@ impl Desk { open, lost, }; - desk.print_page(&staged, job, window, cx) + desk.print_page(n, &staged, job, window, cx) } Err(error) => desk.exported(Err(error), cx), }) @@ -303,7 +320,7 @@ impl Desk { } /// Chromium prints the staged page; the PDF goes where asked. - fn print_page(&mut self, page: &Path, job: Job, window: &mut Window, cx: &mut Context) { + fn print_page(&mut self, n: u64, page: &Path, job: Job, window: &mut Window, cx: &mut Context) { let root = page .parent() .expect("a staged page sits in a folder") @@ -315,20 +332,20 @@ impl Desk { let heard = cx.subscribe(&view, move |desk, view, event: &Event, cx| match event { Event::Loaded => view.read(cx).print_pdf(&job.pdf), Event::Printed(path, ok) => { - desk.printing = None; + desk.printing.remove(&n); let printed = ok .then(|| path.clone()) .ok_or_else(|| io::Error::other("Chromium did not print the page")); desk.printed(printed, job.clone(), cx); } Event::Failed(why) => { - desk.printing = None; + desk.printing.remove(&n); desk.exported(Err(io::Error::other(why.clone())), cx); } Event::Query(_, reply) => reply.ok(""), Event::Leave(_) => {} }); - self.printing = Some(Printing { + self.printing.insert(n, Printing { _view: view, _heard: heard, }); diff --git a/apps/ely-mark/src/finding.rs b/apps/ely-mark/src/finding.rs index f68f54f..96e2e94 100644 --- a/apps/ely-mark/src/finding.rs +++ b/apps/ely-mark/src/finding.rs @@ -115,8 +115,12 @@ impl Desk { fn close_find(&mut self, window: &mut Window, cx: &mut Context) { self.finder = None; - if let Some(view) = self.active_view() { + // Every tab's page holds its own highlights. + let views: Vec<_> = self.core.tabs.iter().map(|tab| tab.view.clone()).collect(); + for view in views { view.update(cx, |view, cx| view.unfind(cx)); + } + if let Some(view) = self.active_view() { let focus = view.read(cx).focus.clone(); window.focus(&focus, cx); } diff --git a/apps/ely-mark/src/main.rs b/apps/ely-mark/src/main.rs index 203de3a..61505e0 100644 --- a/apps/ely-mark/src/main.rs +++ b/apps/ely-mark/src/main.rs @@ -15,6 +15,7 @@ mod places; mod rename; mod restore; mod side; +mod tally; mod text; mod vcs; mod view; diff --git a/apps/ely-mark/src/outside.rs b/apps/ely-mark/src/outside.rs index 631dccf..48dbd3d 100644 --- a/apps/ely-mark/src/outside.rs +++ b/apps/ely-mark/src/outside.rs @@ -308,6 +308,8 @@ impl Desk { return None; } tab.extra.base = disk.text.clone(); + // The file holds the disk's text: it is saved. + tab.mark = crate::engine::mark(&disk.text); tab.extra.restart_autosave(); // Its bytes (BOM, line ends) are what saves keep now. tab.doc.lock().expect("the text lock").source = disk.clone(); diff --git a/apps/ely-mark/src/page.rs b/apps/ely-mark/src/page.rs index 2d41dc7..04edc15 100644 --- a/apps/ely-mark/src/page.rs +++ b/apps/ely-mark/src/page.rs @@ -75,6 +75,11 @@ pub fn open( )); } +/// The disk's text in place; reports start a new generation. +pub fn refresh(view: &WebView, markdown: &str, load: u64) { + view.run_js(&format!("ely.refresh({}, {load})", json!(markdown))); +} + /// The same text in another mode; pending edits go first. pub fn switch(view: &WebView, mode: Mode) { view.run_js(&format!("ely.mode('{}')", mode.name())); diff --git a/apps/ely-mark/src/restore.rs b/apps/ely-mark/src/restore.rs index eeb981c..908ab4f 100644 --- a/apps/ely-mark/src/restore.rs +++ b/apps/ely-mark/src/restore.rs @@ -37,6 +37,8 @@ struct Waiting { kept: Kept, real: Option, text: Option<(String, SystemTime)>, + /// The untitled tab made for it, once made. + tab: Option, } /// The session's files and what still waits to restore. @@ -97,6 +99,7 @@ impl Keeping { }, real: Some(real), text: None, + tab: None, }), Err(error) => { log::warn!("mark: a kept file does not open: {error}"); @@ -111,6 +114,7 @@ impl Keeping { kept, real: None, text, + tab: None, }); } } @@ -132,13 +136,16 @@ impl Keeping { Ok((keeping, paths, missing)) } - /// The untitled tabs to make, with their names. - pub fn untitled(&self) -> Vec { - let untitled = self - .waiting - .iter() - .filter(|waiting| waiting.kept.path.is_none()); - untitled.map(|waiting| waiting.kept.name.clone()).collect() + /// The untitled tabs to make: their place here and name. + pub fn untitled(&self) -> Vec<(usize, String)> { + let waiting = self.waiting.iter().enumerate(); + let untitled = waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none()); + untitled.map(|(at, waiting)| (at, waiting.kept.name.clone())).collect() + } + + /// Entry `at` belongs to untitled tab `tab`. + pub fn made(&mut self, at: usize, tab: u64) { + self.waiting[at].tab = Some(tab); } /// A clean end: no crash next time, no stale points. @@ -177,6 +184,15 @@ fn clear(dir: &Path) -> io::Result<()> { } } +/// What tab `id` restores: by file, or untitled by number. +fn waiting_for(waiting: &[Waiting], real: Option<&PathBuf>, id: u64) -> Option { + waiting.iter().position(|waiting| match (&waiting.real, real) { + (Some(kept), Some(open)) => kept == open, + (None, None) => waiting.tab == Some(id), + _ => false, + }) +} + impl Desk { /// A tab just shown takes its kept mode, place, text. pub fn restore_shown(&mut self, id: u64, cx: &mut Context) { @@ -190,16 +206,8 @@ impl Desk { .session .path() .and_then(|path| std::fs::canonicalize(path).ok()); - let untitled = tab.session.path().is_none(); let waiting = &mut self.keeping.waiting; - let Some(at) = waiting - .iter() - .position(|waiting| match (&waiting.real, &real) { - (Some(kept), Some(open)) => kept == open, - (None, None) => untitled, - _ => false, - }) - else { + let Some(at) = waiting_for(waiting, real.as_ref(), id) else { return; }; let Waiting { @@ -392,4 +400,19 @@ mod tests { let place = kept.place.expect("a place"); assert_eq!((place.line, place.column), (3, 0)); } + + #[test] + fn an_untitled_copy_goes_to_the_tab_made_for_it() { + let untitled = |name: &str, tab: u64| super::Waiting { + kept: super::Kept { path: None, name: name.into(), mode: "visual".into(), place: None, slot: String::new() }, + real: None, + text: None, + tab: Some(tab), + }; + let waiting = [untitled("Untitled1", 4), untitled("Untitled2", 5)]; + // The second tab shows first: it still takes its own. + assert_eq!(super::waiting_for(&waiting, None, 5), Some(1)); + assert_eq!(super::waiting_for(&waiting, None, 4), Some(0)); + assert_eq!(super::waiting_for(&waiting, None, 9), None, "a new tab takes nothing"); + } } diff --git a/apps/ely-mark/src/tally.rs b/apps/ely-mark/src/tally.rs new file mode 100644 index 0000000..62bda00 --- /dev/null +++ b/apps/ely-mark/src/tally.rs @@ -0,0 +1,114 @@ +//! Which page reports count, by page load and by version. + +use std::collections::HashMap; + +/// One page load's change counts and the renders it owes. +pub struct Tally { + load: u64, + /// The newest change the page announced. + changed: u64, + /// The change whose text arrived. + reported: u64, + renders: HashMap, + asked: u64, +} + +impl Tally { + pub fn new() -> Self { + Tally { load: 0, changed: 0, reported: 0, renders: HashMap::new(), asked: 0 } + } + + pub fn load(&self) -> u64 { + self.load + } + + pub fn changed(&self) -> u64 { + self.changed + } + + /// Every change's text has arrived. + pub fn settled(&self) -> bool { + self.reported == self.changed + } + + /// A reload or refresh: older reports lapse, owed renders end. + pub fn restart(&mut self) -> Vec { + self.load += 1; + (self.changed, self.reported) = (0, 0); + self.lapse() + } + + /// The renders owed, as the page fails or restarts. + pub fn lapse(&mut self) -> Vec { + self.renders.drain().map(|(_, render)| render).collect() + } + + /// Whether a report from page load `generation` still counts. + pub fn current(&self, generation: Option) -> bool { + generation.is_none_or(|load| load == self.load) + } + + pub fn change(&mut self, version: u64) { + self.changed = self.changed.max(version); + } + + /// Takes text `version` if it is the newest. + pub fn text(&mut self, version: u64) -> bool { + let newest = version >= self.reported; + if newest { + self.reported = version; + } + newest + } + + /// Owes `render`; its number goes to the page. + pub fn ask(&mut self, render: R) -> u64 { + self.asked += 1; + self.renders.insert(self.asked, render); + self.asked + } + + pub fn answered(&mut self, ask: u64) -> Option { + self.renders.remove(&ask) + } +} + +#[cfg(test)] +mod tests { + use super::Tally; + + #[test] + fn a_key_from_before_a_refresh_leaves_it_settled() { + let mut tally = Tally::<()>::new(); + let old = tally.load(); + tally.restart(); + // The renderer's change for the old text lands late. + if tally.current(Some(old)) { + tally.change(1); + } + assert!(tally.settled(), "an old change does not count"); + // The refreshed page's own edits count again. + tally.change(1); + assert!(!tally.settled()); + assert!(tally.text(1)); + assert!(tally.settled()); + } + + #[test] + fn a_restart_ends_every_owed_render() { + let mut tally = Tally::new(); + let ask = tally.ask("export"); + assert_eq!(tally.restart(), vec!["export"]); + // Its late answer finds nobody waiting. + assert_eq!(tally.answered(ask), None); + } + + #[test] + fn only_the_newest_text_counts() { + let mut tally = Tally::<()>::new(); + tally.change(2); + assert!(tally.text(2)); + assert!(!tally.text(1), "an older text is dropped"); + assert!(tally.settled()); + } +} diff --git a/apps/ely-mark/src/view.rs b/apps/ely-mark/src/view.rs index 05a7ed8..20321b8 100644 --- a/apps/ely-mark/src/view.rs +++ b/apps/ely-mark/src/view.rs @@ -43,14 +43,10 @@ pub struct Failed(pub String); pub struct MarkView { pub(crate) web: Entity, doc: Doc, - /// The newest change the page announced. - pub changed: u64, - /// The change whose text `doc` holds. - reported: u64, + /// Page loads, changes and owed renders. + tally: crate::tally::Tally, /// The mark at the last load or save. saved: String, - /// Page loads so far; a reload restarts its versions. - load: u64, pub mode: Mode, /// Headings as the shown editor reads them. pub outline: Vec, @@ -70,9 +66,6 @@ pub struct MarkView { pub place: Option, /// The writing view, as the page applies it (MARK-09). look: Value, - /// Renders asked of the page, by number. - renders: std::collections::HashMap, - asked: u64, pub focus: FocusHandle, } @@ -95,10 +88,8 @@ impl MarkView { MarkView { web, doc, - changed: 0, - reported: 0, + tally: crate::tally::Tally::new(), saved, - load: 0, mode: if large { Mode::Source } else { Mode::Visual }, large, outline: Vec::new(), @@ -108,8 +99,6 @@ impl MarkView { attachable: false, place: None, look: Value::Null, - renders: std::collections::HashMap::new(), - asked: 0, folder, focus, } @@ -117,7 +106,12 @@ impl MarkView { /// Every change's text has arrived. pub fn settled(&self) -> bool { - self.reported == self.changed + self.tally.settled() + } + + /// The newest change the page announced. + pub fn changed(&self) -> u64 { + self.tally.changed() } /// The text's size now, in bytes. @@ -190,8 +184,10 @@ impl MarkView { doc.source = source; } self.saved = mark(&text); - (self.changed, self.reported) = (0, 0); - crate::page::call(self.web.read(cx), "refresh", &Value::from(text)); + // Reports and renders from before belong to the old text. + let lapsed = self.tally.restart(); + self.end_renders(lapsed, "the text was replaced from disk", cx); + crate::page::refresh(self.web.read(cx), &text, self.tally.load()); cx.emit(Reported); } @@ -213,9 +209,8 @@ impl MarkView { done: impl FnOnce(Result, &mut gpui::App) + 'static, cx: &mut Context, ) { - self.asked += 1; - self.renders.insert(self.asked, Box::new(done)); - let ask = serde_json::json!({ "kind": kind, "ask": self.asked }); + let ask = self.tally.ask(Box::new(done)); + let ask = serde_json::json!({ "kind": kind, "ask": ask }); crate::page::call(self.web.read(cx), "render", &ask); } @@ -240,7 +235,7 @@ impl MarkView { drop(doc); let mode = if self.large { Mode::Source } else { mode }; self.mode = mode; - let at = (mode, self.load, place); + let at = (mode, self.tally.load(), place); crate::page::open(self.web.read(cx), ¤t, at, &self.folder, cx); cx.emit(Reported); } @@ -262,23 +257,50 @@ impl MarkView { match event { // A reload shows the last text that arrived. Event::Loaded => { - self.load += 1; + let lapsed = self.tally.restart(); + self.end_renders(lapsed, "the page reloaded", cx); self.shown = false; - (self.changed, self.reported) = (0, 0); let text = self.doc.lock().expect("the text lock").current.clone(); - let at = (self.mode, self.load, self.place); + let at = (self.mode, self.tally.load(), self.place); crate::page::open(web.read(cx), &text, at, &self.folder, cx); cx.emit(Reported); } Event::Query(said, reply) => self.query(said, reply, cx), - Event::Leave(url) => log::info!("mark: a link out was not followed: {url}"), - Event::Failed(why) => cx.emit(Failed(why.clone())), + // A click on a link: web and mail open outside. + Event::Leave(url) if ely_office::outward(url) => { + log::info!("mark: opens a link outside"); + cx.open_url(url); + } + Event::Leave(_) => { + log::info!("mark: a link of another kind was refused"); + let said = ely_ui::i18n::text(cx, "office.link.refused", &[]).to_string(); + cx.emit(Failed(said)); + } + Event::Failed(why) => { + let lapsed = self.tally.lapse(); + self.end_renders(lapsed, why, cx); + cx.emit(Failed(why.clone())); + } Event::Printed(path, _) => { log::error!("mark: an editor page printed {}", path.display()) } } } + /// Renders the old page will never answer end with `why`. + fn end_renders(&mut self, pending: Vec, why: &str, cx: &mut Context) { + if pending.is_empty() { + return; + } + log::warn!("mark: {} renders lost with the page", pending.len()); + let why = why.to_string(); + cx.defer(move |cx| { + for done in pending { + done(Err(why.clone()), cx); + } + }); + } + fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context) { let said: Value = match serde_json::from_str(said) { Ok(said) => said, @@ -287,10 +309,7 @@ impl MarkView { return reply.fail("bad JSON"); } }; - if said["generation"] - .as_u64() - .is_some_and(|load| load != self.load) - { + if !self.tally.current(said["generation"].as_u64()) { log::info!("mark: a report from an earlier page load was dropped"); return reply.ok(""); } @@ -307,6 +326,10 @@ impl MarkView { self.mode = mode; cx.notify(); } + if let Some(what) = said["broke"].as_str() { + log::error!("mark: the visual editor broke ({what}); source shows"); + cx.emit(Failed(ely_ui::i18n::text(cx, "mark.visual.broke", &[("what", what)]).to_string())); + } self.shown = true; if let Some(place) = self.waiting.take() { crate::page::call(self.web.read(cx), "reveal", &place); @@ -316,6 +339,14 @@ impl MarkView { } cx.emit(Reported); } + (Some("failed"), ..) => { + let Some(what) = said["what"].as_str() else { + log::error!("mark: the page sent a failure without its name"); + return reply.fail("bad failure"); + }; + log::error!("mark: the page built no editor ({what})"); + cx.emit(Failed(ely_ui::i18n::text(cx, "mark.page.broke", &[("what", what)]).to_string())); + } (Some("outline"), ..) => match headings(&said["items"]) { Some(outline) => { self.outline = outline; @@ -351,7 +382,7 @@ impl MarkView { (Some("table" | "inserted"), ..) => {} (Some("rendered"), ..) => { let ask = said["ask"].as_u64().unwrap_or_default(); - let Some(done) = self.renders.remove(&ask) else { + let Some(done) = self.tally.answered(ask) else { log::error!("mark: a render nobody asked for came back"); return reply.fail("unasked render"); }; @@ -379,16 +410,18 @@ impl MarkView { } }, (Some("changed"), Some(version), _) => { - self.changed = self.changed.max(version); + self.tally.change(version); cx.emit(Reported); } // Only the newest text counts. - (Some("text"), Some(version), Some(text)) if version >= self.reported => { - self.doc.lock().expect("the text lock").current = text.to_owned(); - self.reported = version; - cx.emit(Reported); + (Some("text"), Some(version), Some(text)) => { + if !self.tally.text(version) { + log::info!("mark: an older text was dropped"); + } else { + self.doc.lock().expect("the text lock").current = text.to_owned(); + cx.emit(Reported); + } } - (Some("text"), Some(_), Some(_)) => log::info!("mark: an older text was dropped"), _ => { log::error!("mark: the page asked something unknown"); return reply.fail("unknown request"); diff --git a/apps/ely-mark/web/src/main.js b/apps/ely-mark/web/src/main.js index b3d2d70..5cb9d8d 100644 --- a/apps/ely-mark/web/src/main.js +++ b/apps/ely-mark/web/src/main.js @@ -81,7 +81,14 @@ async function build(text, mode) { const place = document.createElement('div') holder.append(place) const edited = (write) => changed(() => front + write()) - const made = await visual(place, body, mode === 'visual', texts, edited, edge) + let made + try { + made = await visual(place, body, mode === 'visual', texts, edited, edge) + } catch (error) { + // A broken editor falls back to source and says so. + console.error(`ely: the visual editor failed: ${error.name}`) + entry.broke = error.name + } if (made) { // Lines count from the file's top, front matter included. const above = front ? front.split('\n').length - 1 : 0 @@ -119,13 +126,20 @@ async function show(text, want) { asked = null if (current) drop(current) current = null - const entry = await build(text, want) + let entry + try { + entry = await build(text, want) + } catch (error) { + // Source failed too: tell the app, never blank. + if (turn === latest) tell({ kind: 'failed', generation, what: error.name }) + return + } if (turn !== latest) return drop(entry) current = entry entry.holder.hidden = false document.getElementById('document').dataset.mode = entry.mode entry.focus() - await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback }) + await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback, broke: entry.broke ?? null }) // A newer request owns what is queued now. if (turn !== latest) return outline() @@ -176,10 +190,12 @@ async function open(text, want, words, load, folder, place) { } /** Changed on disk: the caret and scroll stay near. */ -async function refresh(text) { +async function refresh(text, load) { const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop } clearTimeout(timer) pending = null + generation = load + version = 0 times = [text] at = 0 await show(text, current?.mode ?? 'visual') @@ -243,7 +259,8 @@ function reveal(place) { } const shown = 'heading' in place ? current.heading(place.heading) : current.line(place.line, place.query) - if (shown === false) console.warn(`ely: nothing at ${JSON.stringify(place)}`) + // Logs keep no search text; a line number at most. + if (shown === false) console.warn('heading' in place ? 'ely: no such heading' : `ely: nothing at line ${place.line}`) } /** The app's find box hears where the search stands. */ diff --git a/apps/ely-pdf/src/links.rs b/apps/ely-pdf/src/links.rs index a50fc24..ba28748 100644 --- a/apps/ely-pdf/src/links.rs +++ b/apps/ely-pdf/src/links.rs @@ -5,17 +5,12 @@ use gpui::Context; use crate::view::{Failed, PdfView}; -/// Addresses that may leave the document. -pub fn outward(uri: &str) -> bool { - ["https://", "http://", "mailto:"].iter().any(|scheme| uri.starts_with(scheme)) -} - /// A target as typed: a page number or an address. pub fn parse(typed: &str, pages: usize) -> Option { let typed = typed.trim(); match typed.parse::() { Ok(n) => (1..=pages).contains(&n).then(|| Target::Page(n - 1, 0.0)), - Err(_) => outward(typed).then(|| Target::Web(typed.to_string())), + Err(_) => ely_office::outward(typed).then(|| Target::Web(typed.to_string())), } } @@ -35,12 +30,12 @@ impl PdfView { log::info!("pdf: follows a link to page {}", page + 1); self.show(page, link.y, cx) } - None if outward(&link.uri) => { + None if ely_office::outward(&link.uri) => { log::info!("pdf: opens a link outside"); cx.open_url(&link.uri) } None => { - let text = crate::viewer::Viewer::text(cx, "pdf.link.refused", &[]); + let text = crate::viewer::Viewer::text(cx, "office.link.refused", &[]); cx.emit(Failed(text.to_string())) } } diff --git a/apps/ely-show/src/insert.rs b/apps/ely-show/src/insert.rs index d897d29..8683745 100644 --- a/apps/ely-show/src/insert.rs +++ b/apps/ely-show/src/insert.rs @@ -31,7 +31,8 @@ actions!( /// Insert and View; View checks what is on. pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2] { let label = |key: &str| Deck::text(cx, key, &[]); - let insert = Menu::new(label("menu.insert")).items([ + let media = ely_platform::media::BUILT.then(|| MenuItem::action(label("show.insert.media"), InsertMedia)); + let insert = [ MenuItem::action(label("show.insert.text"), InsertTextBox), MenuItem::separator(), MenuItem::action(label("show.insert.rectangle"), InsertRectangle), @@ -42,12 +43,16 @@ pub fn menus((grid, guides, remarks): (bool, bool, bool), cx: &App) -> [Menu; 2] MenuItem::action(label("show.insert.connector"), InsertConnector), MenuItem::separator(), MenuItem::action(label("show.insert.picture"), InsertPicture), - MenuItem::action(label("show.insert.media"), InsertMedia), + ] + .into_iter() + .chain(media) + .chain([ MenuItem::action(label("show.insert.table"), InsertTable), MenuItem::action(label("show.insert.chart"), InsertChart), MenuItem::separator(), MenuItem::action(label("show.insert.remark"), InsertRemark), ]); + let insert = Menu::new(label("menu.insert")).items(insert); let view = Menu::new(label("menu.view")).items([ MenuItem::action(label("show.view.remarks"), ShowRemarks).checked(remarks), MenuItem::separator(), @@ -150,3 +155,26 @@ impl Deck { self.object_command("guide.add", &[kind, &x.to_string(), &y.to_string()], cx); } } + +#[cfg(test)] +mod tests { + #[test] + fn insert_media_shows_only_where_it_plays() { + let cx = gpui::TestAppContext::single(); + cx.update(ely_gpui_component::init_for_tests); + let (offered, total) = cx.update(|cx| { + ely_ui::settings::install_defaults(cx); + ely_ui::i18n::install(cx); + let media = crate::deck::Deck::text(cx, "show.insert.media", &[]); + let [insert, _] = super::menus((false, false, false), cx); + let names = insert.items.iter().filter_map(|item| match item { + gpui::MenuItem::Action { name, .. } => Some(name.clone()), + _ => None, + }); + let names: Vec<_> = names.collect(); + (names.iter().any(|name| *name == media), names.len()) + }); + assert_eq!(offered, ely_platform::media::BUILT); + assert!(total >= 11, "the rest stay: {total}"); + } +} diff --git a/apps/ely-tree/src/asks.rs b/apps/ely-tree/src/asks.rs index 064555f..eb20ef3 100644 --- a/apps/ely-tree/src/asks.rs +++ b/apps/ely-tree/src/asks.rs @@ -128,6 +128,18 @@ pub enum Choice { } impl Tree { + /// Opens a read's dialog unless another opened since. + pub fn offer(&mut self, ask: Ask, cx: &mut Context) { + match (&self.ask, ask) { + (None, ask) => self.ask = Some(ask), + (Some(_), Ask::Failed { title, detail }) => { + self.toast(format!("{title}: {detail}").into(), Severity::Danger, cx) + } + (Some(_), _) => log::info!("tree: a later dialog stands; read dropped"), + } + cx.notify(); + } + pub fn dialog(&self, cx: &mut Context) -> Option { let this = cx.weak_entity(); let dismiss = { diff --git a/apps/ely-tree/src/branches.rs b/apps/ely-tree/src/branches.rs index bea0fbe..3d9a863 100644 --- a/apps/ely-tree/src/branches.rs +++ b/apps/ely-tree/src/branches.rs @@ -18,7 +18,8 @@ pub struct Ref { /// The commit it names, through an annotated tag. pub commit: String, pub subject: String, - pub at: i64, + /// When made; a tag on a blob has none. + pub at: Option, /// An annotated tag's own message. pub message: Option, } @@ -103,7 +104,10 @@ pub fn parse(bytes: &[u8]) -> io::Result> { track: (!upstream.is_empty()).then(|| track(tracking)).flatten(), commit: if annotated { peeled } else { object }.to_string(), subject: if annotated { peeled_subject } else { subject }.to_string(), - at: at.parse().map_err(|_| bad(record))?, + at: match at { + "" => None, + at => Some(at.parse().map_err(|_| bad(record))?), + }, message: annotated.then(|| subject.to_string()), }); } diff --git a/apps/ely-tree/src/conflict.rs b/apps/ely-tree/src/conflict.rs index 0385a7d..2f31a3f 100644 --- a/apps/ely-tree/src/conflict.rs +++ b/apps/ely-tree/src/conflict.rs @@ -215,10 +215,12 @@ impl Tree { }; let result = self.merge_editor.read(cx).text().to_string(); let open = merging.takes.iter().filter(|taken| taken.is_none()).count(); + if merging.takes.get(at).copied().flatten().is_some() { + let words = text(cx, "tree.conflict.taken", &[]); + return self.toast(words, Severity::Warning, cx); + } // Hand edits that moved the markers end block choices. - if merging.takes.get(at).copied().flatten().is_some() - || conflicts(&result, merging.size()).len() != open - { + if conflicts(&result, merging.size()).len() != open { let words = text(cx, "tree.conflict.hand", &[]); return self.toast(words, Severity::Warning, cx); } diff --git a/apps/ely-tree/src/conflict_pane.rs b/apps/ely-tree/src/conflict_pane.rs index 11ddbcb..efca2ae 100644 --- a/apps/ely-tree/src/conflict_pane.rs +++ b/apps/ely-tree/src/conflict_pane.rs @@ -29,6 +29,7 @@ pub fn sides(doing: Option<&Doing>, cx: &App) -> [SharedString; 2] { Op::Rebase => ("tree.ours.rebase", "tree.theirs.rebase"), Op::CherryPick => ("tree.ours.pick", "tree.theirs.pick"), Op::Revert => ("tree.ours.revert", "tree.theirs.revert"), + Op::Am => ("tree.ours.am", "tree.theirs.am"), }; [ text(cx, ours, &[("name", &doing.ours)]), @@ -59,8 +60,14 @@ fn button( impl Tree { /// A stopped operation: continue, skip or abort. pub fn op_alert(&self, cx: &mut Context) -> Option { - let Some(Some(Ok(Some(doing)))) = self.active().map(|opened| opened.doing.as_ref()) else { - return None; + let doing = match self.active().map(|opened| opened.doing.as_ref()) { + Some(Some(Ok(Some(doing)))) => doing, + // A read that failed says so in Git's own words. + Some(Some(Err(error))) => { + let title = text(cx, "tree.op.unread", &[("error", error)]); + return Some(Alert::new("tree-op", Severity::Danger, title).into_any_element()); + } + _ => return None, }; let op = doing.op; let key = match op { @@ -68,6 +75,7 @@ impl Tree { Op::Rebase => "tree.op.rebasing", Op::CherryPick => "tree.op.picking", Op::Revert => "tree.op.reverting", + Op::Am => "tree.op.applying", }; let title = text(cx, key, &[("ours", &doing.ours), ("theirs", &doing.theirs)]); let mut alert = Alert::new("tree-op", Severity::Warning, title) diff --git a/apps/ely-tree/src/git.rs b/apps/ely-tree/src/git.rs index a9433a0..9c3a3ac 100644 --- a/apps/ely-tree/src/git.rs +++ b/apps/ely-tree/src/git.rs @@ -74,6 +74,13 @@ impl Git { checked(args, output) } + /// HEAD names a commit; a new repository has none yet. + pub fn born(&self, dir: &Path) -> io::Result { + Ok(!self + .run_or_one(dir, &["rev-parse", "--verify", "-q", "HEAD"])? + .is_empty()) + } + /// Exit 1 is an answer: files differ, key unset. pub fn run_or_one(&self, dir: &Path, args: &[&str]) -> io::Result> { let output = self.command(dir, args).output()?; diff --git a/apps/ely-tree/src/hist.rs b/apps/ely-tree/src/hist.rs index b776392..08dec4a 100644 --- a/apps/ely-tree/src/hist.rs +++ b/apps/ely-tree/src/hist.rs @@ -95,6 +95,7 @@ fn graph(logged: &[Logged]) -> Vec { impl Tree { pub fn set_side(&mut self, side: crate::tree::Side, cx: &mut Context) { + self.look = None; self.side = side; self.ensure_side(cx); cx.notify(); @@ -278,6 +279,7 @@ impl Tree { /// Reads a commit, or a stash with its untracked files. pub fn pick_in(&mut self, whose: Whose, commit: &str, cx: &mut Context) { + self.look = None; let Some(git) = self.git.as_ref().ok().cloned() else { return; }; diff --git a/apps/ely-tree/src/menu.rs b/apps/ely-tree/src/menu.rs index 59c532d..bf14468 100644 --- a/apps/ely-tree/src/menu.rs +++ b/apps/ely-tree/src/menu.rs @@ -34,7 +34,9 @@ pub fn bind(cx: &mut App) { } pub fn menus(cx: &App) -> Vec { - vec![Menu::new(text(cx, "tree.menu.repository", &[])).items([ + let terminal = crate::tools::TERMINAL + .then(|| MenuItem::action(text(cx, "tree.tool.terminal", &[]), OpenTerminal)); + let items = [ MenuItem::action(text(cx, "tree.clone", &[]), CloneRepository), MenuItem::action(text(cx, "tree.close", &[]), CloseRepository), MenuItem::separator(), @@ -43,11 +45,15 @@ pub fn menus(cx: &App) -> Vec { MenuItem::action(text(cx, "tree.net.push", &[]), Push), MenuItem::action(text(cx, "tree.force.open", &[]), ForcePush), MenuItem::separator(), - MenuItem::action(text(cx, "tree.tool.terminal", &[]), OpenTerminal), - MenuItem::action(text(cx, "tree.tool.reveal", &[]), Reveal), + ] + .into_iter() + .chain(terminal) + .chain([ + MenuItem::action(text(cx, crate::tools::REVEAL, &[]), Reveal), MenuItem::action(text(cx, "tree.clean.open", &[]), CleanUntracked), MenuItem::action(text(cx, "tree.tool.editor", &[]), ChooseEditor), - ])] + ]); + vec![Menu::new(text(cx, "tree.menu.repository", &[])).items(items)] } impl Tree { diff --git a/apps/ely-tree/src/moves.rs b/apps/ely-tree/src/moves.rs index 905a1e7..3f5e4a6 100644 --- a/apps/ely-tree/src/moves.rs +++ b/apps/ely-tree/src/moves.rs @@ -66,6 +66,9 @@ impl Tree { let task = cx.background_spawn(async move { ops::doing(&git, &root, &dir) }); cx.spawn(async move |this, cx| { let read = task.await.map_err(|error| error.to_string()); + if let Err(error) = &read { + log::error!("tree: what Git is doing did not read: {error}"); + } this.update(cx, |tree, cx| { if let Some(opened) = tree .opened_mut(id) diff --git a/apps/ely-tree/src/network.rs b/apps/ely-tree/src/network.rs index 21be4aa..1678261 100644 --- a/apps/ely-tree/src/network.rs +++ b/apps/ely-tree/src/network.rs @@ -123,7 +123,7 @@ impl Tree { }; net.stopping = true; if let Err(error) = net.stop.now() { - self.failed("tree.error.stop", &error.to_string(), cx); + self.failed("tree.error.stop_remote", &error.to_string(), cx); } cx.notify(); } @@ -183,7 +183,7 @@ impl Tree { if tree.active != Some(repo) { return; } - tree.ask = Some(match read { + let ask = match read { Ok(list) if list.is_empty() => { let words = text(cx, "tree.push.no_remote", &[]); return tree.toast(words, Severity::Warning, cx); @@ -198,8 +198,8 @@ impl Tree { title: text(cx, "tree.remotes.unread", &[]), detail: error.to_string().into(), }, - }); - cx.notify(); + }; + tree.offer(ask, cx); }) .ok(); }) @@ -234,14 +234,14 @@ impl Tree { if tree.active != Some(repo) { return; } - tree.ask = Some(match plan { + let ask = match plan { Ok(plan) => Ask::Force { repo, plan }, Err(error) => Ask::Failed { title: text(cx, "tree.force.unready", &[]), detail: error.to_string().into(), }, - }); - cx.notify(); + }; + tree.offer(ask, cx); }) .ok(); }) @@ -262,11 +262,10 @@ impl Tree { .on_click(move |_, _, cx| { this.update(cx, |tree, cx| tree.stop_remote(id, cx)).ok(); }); - Some( - Alert::new("tree-net", Severity::Info, verb) - .body(net.said.clone()) - .action(stop.into_any_element()) - .into_any_element(), - ) + let alert = Alert::new("tree-net", Severity::Info, verb).body(net.said.clone()); + if !crate::running::STOPS { + return Some(alert.into_any_element()); + } + Some(alert.action(stop.into_any_element()).into_any_element()) } } diff --git a/apps/ely-tree/src/ops.rs b/apps/ely-tree/src/ops.rs index 406d387..9f84d51 100644 --- a/apps/ely-tree/src/ops.rs +++ b/apps/ely-tree/src/ops.rs @@ -10,6 +10,8 @@ pub enum Op { Rebase, CherryPick, Revert, + /// `git am`, applying mailed patches. + Am, } impl Op { @@ -20,6 +22,7 @@ impl Op { Op::Rebase => "rebase", Op::CherryPick => "cherry-pick", Op::Revert => "revert", + Op::Am => "am", } } @@ -78,7 +81,18 @@ pub fn doing(git: &Git, root: &Path, git_dir: &Path) -> io::Result let read = |name: &str| { std::fs::read_to_string(git_dir.join(name)).map(|text| text.trim().to_string()) }; - let doing = if has("rebase-merge") || has("rebase-apply") { + // `git am` keeps rebase-apply too, without an onto. + let doing = if has("rebase-apply/applying") { + Doing { + op: Op::Am, + ours: head(git, root)?, + theirs: format!( + "{}/{}", + read("rebase-apply/next")?, + read("rebase-apply/last")? + ), + } + } else if has("rebase-merge") || has("rebase-apply") { let dir = if has("rebase-merge") { "rebase-merge" } else { diff --git a/apps/ely-tree/src/pick.rs b/apps/ely-tree/src/pick.rs index 00abef7..3c0528c 100644 --- a/apps/ely-tree/src/pick.rs +++ b/apps/ely-tree/src/pick.rs @@ -45,6 +45,8 @@ impl Tree { /// Shows `path` as listed in `section`. pub fn show(&mut self, path: &str, section: ChangeSection, cx: &mut Context) { let Some(repo) = self.active else { return }; + // A new pick leaves any file history or blame. + self.look = None; self.shown = Some(Shown { repo, section, diff --git a/apps/ely-tree/src/places.rs b/apps/ely-tree/src/places.rs index e46558a..4967385 100644 --- a/apps/ely-tree/src/places.rs +++ b/apps/ely-tree/src/places.rs @@ -94,12 +94,15 @@ impl Tree { } pub fn pick_place(&mut self, place: Place, cx: &mut Context) { + self.look = None; match &place { Place::Stash(oid) => self.pick_in(Whose::Stash, oid, cx), Place::Reflog(moved) => self.pick_in(Whose::Reflog, &moved.oid, cx), _ => {} } - self.picked_place = Some(place); + if let Some(opened) = self.active.and_then(|id| self.opened_mut(id)) { + opened.picked_place = Some(place); + } cx.notify(); } diff --git a/apps/ely-tree/src/places_pane.rs b/apps/ely-tree/src/places_pane.rs index 6987583..50b7c88 100644 --- a/apps/ely-tree/src/places_pane.rs +++ b/apps/ely-tree/src/places_pane.rs @@ -125,7 +125,7 @@ impl Tree { // The picked entry itself, while it still names this commit. let moved = match ( self.active().and_then(|o| o.places.read.as_ref()), - &self.picked_place, + self.active().and_then(|o| o.picked_place.as_ref()), ) { ( Some(Read { @@ -187,7 +187,7 @@ impl Tree { let Some(opened) = self.active() else { return none(); }; - let (Some(read), Some(place)) = (&opened.places.read, &self.picked_place) else { + let (Some(read), Some(place)) = (&opened.places.read, &opened.picked_place) else { return none(); }; let label = |key: &str| text(cx, key, &[]); diff --git a/apps/ely-tree/src/recover.rs b/apps/ely-tree/src/recover.rs index 7014741..bbde5cb 100644 --- a/apps/ely-tree/src/recover.rs +++ b/apps/ely-tree/src/recover.rs @@ -48,6 +48,9 @@ impl Moved { const KEEP: usize = 200; pub fn reflog(git: &Git, root: &Path) -> io::Result> { + if !git.born(root)? { + return Ok(Vec::new()); + } // With a date mode, `%gd` carries when HEAD moved. let format = "--format=%H%x00%gd%x00%gs"; let count = format!("-n{KEEP}"); diff --git a/apps/ely-tree/src/recovering.rs b/apps/ely-tree/src/recovering.rs index eb8ebd7..c74a87c 100644 --- a/apps/ely-tree/src/recovering.rs +++ b/apps/ely-tree/src/recovering.rs @@ -113,14 +113,14 @@ impl Tree { if tree.active != Some(repo) { return; } - tree.ask = Some(match plan { + let ask = match plan { Ok(plan) => Ask::Move { repo, plan }, Err(error) => Ask::Failed { title: text(cx, "tree.op.failed", &[]), detail: error.to_string().into(), }, - }); - cx.notify(); + }; + tree.offer(ask, cx); }) .ok(); }) diff --git a/apps/ely-tree/src/refs_pane.rs b/apps/ely-tree/src/refs_pane.rs index 0e276cd..47f5359 100644 --- a/apps/ely-tree/src/refs_pane.rs +++ b/apps/ely-tree/src/refs_pane.rs @@ -24,7 +24,7 @@ impl Tree { pub fn refs_pane(&self, cx: &mut Context) -> AnyElement { let found = self .active() - .and_then(|opened| match (&opened.refs, &self.picked_ref) { + .and_then(|opened| match (&opened.refs, &opened.picked_ref) { (Some(Ok(refs)), Some((kind, name))) => refs .iter() .find(|r| r.kind == *kind && r.name == *name) @@ -50,7 +50,10 @@ impl Tree { found.subject ), ) - .text(label("tree.ref.when"), when(found.at)); + .text( + label("tree.ref.when"), + found.at.map_or_else(|| "—".into(), when), + ); if let Some(upstream) = &found.upstream { let track = match found.track { Some((ahead, behind)) => format!("{upstream} ↑{ahead} ↓{behind}"), diff --git a/apps/ely-tree/src/repo.rs b/apps/ely-tree/src/repo.rs index f178fd7..5d5108f 100644 --- a/apps/ely-tree/src/repo.rs +++ b/apps/ely-tree/src/repo.rs @@ -7,8 +7,6 @@ use std::{ use crate::{git::Git, running::Running}; -/// How many repositories the recent list keeps. - /// A work tree and its Git directory. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Repo { diff --git a/apps/ely-tree/src/running.rs b/apps/ely-tree/src/running.rs index 5a63d1c..7cf3841 100644 --- a/apps/ely-tree/src/running.rs +++ b/apps/ely-tree/src/running.rs @@ -154,6 +154,9 @@ impl Stop { } } +/// Only Unix stops Git; elsewhere no stop shows. +pub const STOPS: bool = cfg!(unix); + /// Its own session: the group is Git and its helpers. #[cfg(unix)] fn terminate(pid: u32) -> io::Result<()> { diff --git a/apps/ely-tree/src/side_places.rs b/apps/ely-tree/src/side_places.rs index 1cdd5de..97d574c 100644 --- a/apps/ely-tree/src/side_places.rs +++ b/apps/ely-tree/src/side_places.rs @@ -89,7 +89,7 @@ pub fn places(tree: &Entity, cx: &mut Context) -> AnyElement { let Some(read) = &opened.places.read else { return div().into_any_element(); }; - let picked = read_tree.picked_place.clone(); + let picked = opened.picked_place.clone(); let label = |key: &str| text(cx, key, &[]); let pick = |place: Place| { let this = tree.downgrade(); @@ -124,26 +124,33 @@ pub fn places(tree: &Entity, cx: &mut Context) -> AnyElement { Box::new(|t, _, cx| t.ask_force(cx)), ), ]); - let local = div().flex().flex_wrap().gap_1().children([ + let terminal = crate::tools::TERMINAL.then(|| { button( tree, "tree-terminal", label("tree.tool.terminal"), Box::new(|t, _, cx| t.open_terminal(cx)), - ), - button( - tree, - "tree-reveal", - label("tree.tool.reveal"), - Box::new(|t, _, cx| t.reveal(cx)), - ), - button( - tree, - "tree-clean", - label("tree.clean.open"), - Box::new(|t, _, cx| t.ask_clean(cx)), - ), - ]); + ) + }); + let local = div() + .flex() + .flex_wrap() + .gap_1() + .children(terminal) + .children([ + button( + tree, + "tree-reveal", + label(crate::tools::REVEAL), + Box::new(|t, _, cx| t.reveal(cx)), + ), + button( + tree, + "tree-clean", + label("tree.clean.open"), + Box::new(|t, _, cx| t.ask_clean(cx)), + ), + ]); let mut out: Vec = vec![net.into_any_element(), local.into_any_element()]; out.push(heading( label("tree.places.stashes"), diff --git a/apps/ely-tree/src/side_refs.rs b/apps/ely-tree/src/side_refs.rs index 6cae681..591bb46 100644 --- a/apps/ely-tree/src/side_refs.rs +++ b/apps/ely-tree/src/side_refs.rs @@ -28,7 +28,7 @@ fn branch(found: &Ref) -> Branch { ahead, behind, subject: found.subject.clone().into(), - when: when(found.at), + when: found.at.map_or_else(|| "—".into(), when), } } @@ -37,7 +37,7 @@ fn tag(found: &Ref) -> GitTag { name: found.name.clone().into(), message: found.message.clone().map(Into::into), commit: found.commit.clone().into(), - when: when(found.at), + when: found.at.map_or_else(|| "—".into(), when), } } @@ -59,7 +59,7 @@ pub fn branches(tree: &Entity, cx: &mut Context) -> AnyElement { } Some(Ok(refs)) => refs, }; - let picked = read.picked_ref.clone(); + let picked = opened.picked_ref.clone(); // A name shows picked only in its own kind's list. let picked_in = move |kind: Kind| { picked @@ -72,7 +72,10 @@ pub fn branches(tree: &Entity, cx: &mut Context) -> AnyElement { move |name: &gpui::SharedString, _: &mut gpui::Window, cx: &mut gpui::App| { let name = name.to_string(); this.update(cx, |tree, cx| { - tree.picked_ref = Some((kind, name)); + tree.look = None; + if let Some(opened) = tree.active.and_then(|id| tree.opened_mut(id)) { + opened.picked_ref = Some((kind, name)); + } cx.notify(); }) .ok(); diff --git a/apps/ely-tree/src/structure.rs b/apps/ely-tree/src/structure.rs index 3286f2c..9a8e7d0 100644 --- a/apps/ely-tree/src/structure.rs +++ b/apps/ely-tree/src/structure.rs @@ -64,6 +64,14 @@ pub fn worktrees(git: &Git, root: &Path) -> io::Result> { } } found.extend(now); + // Real paths, as the open repository's root is. + for tree in &mut found { + match std::fs::canonicalize(&tree.path) { + Ok(real) => tree.path = real, + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } Ok(found) } @@ -135,6 +143,10 @@ pub struct LfsFile { /// `git lfs ls-files` as JSON; `files` is null with none. pub fn lfs(git: &Git, root: &Path) -> io::Result> { + // LFS reads HEAD first; before a commit there is nothing. + if !git.born(root)? { + return Ok(Vec::new()); + } let out = git.run(root, &["lfs", "ls-files", "--long", "--json"])?; let read: serde_json::Value = serde_json::from_slice(&out).map_err(io::Error::other)?; let files = match &read["files"] { diff --git a/apps/ely-tree/src/tests/branching.rs b/apps/ely-tree/src/tests/branching.rs index 0c75e6a..a44d8fb 100644 --- a/apps/ely-tree/src/tests/branching.rs +++ b/apps/ely-tree/src/tests/branching.rs @@ -428,3 +428,21 @@ fn marker_sizes_git_cannot_read_mean_seven() { ); } } + +#[test] +fn a_tag_on_a_blob_has_no_date_and_spoils_no_list() { + let dir = tempfile::tempdir().expect("a temp dir"); + let root = committed(dir.path()); + sh(&root, &["tag", "blobtag", "HEAD:a.txt"]); + let refs = crate::branches::read(&git(), &root).expect("the list reads"); + let tag = refs + .iter() + .find(|r| r.name == "blobtag") + .expect("the tag is listed"); + assert_eq!(tag.at, None); + assert!( + refs.iter() + .filter(|r| r.name != "blobtag") + .all(|r| r.at.is_some()) + ); +} diff --git a/apps/ely-tree/src/tests/cleaning.rs b/apps/ely-tree/src/tests/cleaning.rs index 1885a9d..bbe4f59 100644 --- a/apps/ely-tree/src/tests/cleaning.rs +++ b/apps/ely-tree/src/tests/cleaning.rs @@ -182,3 +182,60 @@ fn twin_reflog_events_stay_apart() { .collect(); assert_eq!(found, [2], "it moved one down, alone"); } + +#[test] +fn a_shown_file_deleted_since_says_it_changed() { + let dir = tempfile::tempdir().expect("a temp dir"); + let root = committed(dir.path()); + std::fs::write(root.join("gone.txt"), "x").expect("writes"); + let clean = recover::clean_preview(&git(), &root, false).expect("previews"); + std::fs::remove_file(root.join("gone.txt")).expect("removes"); + let write = Write::Clean { + files: clean.files, + dirs: clean.dirs, + ignored: false, + }; + let error = run(&root, write).expect_err("refused"); + assert!( + error + .to_string() + .contains("gone.txt changed since the preview"), + "{error}" + ); +} + +#[test] +fn a_pick_in_one_repository_stays_there() { + let (one, other) = ( + tempfile::tempdir().expect("a temp dir"), + tempfile::tempdir().expect("a temp dir"), + ); + let (first, second) = (committed(one.path()), committed(other.path())); + let mut cx = TestAppContext::single(); + let (tree, cx) = window(&mut cx, one.path()); + for root in [&first, &second] { + cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx))); + } + settle(cx, |app| { + tree.read(app).repos.len() == 2 && branch(tree.read(app)).is_some() + }); + let ids = cx.update(|_, app| { + tree.read(app) + .repos + .iter() + .map(|o| o.id) + .collect::>() + }); + cx.update(|_, app| { + tree.update(app, |tree, cx| { + tree.activate(Some(ids[0]), cx); + tree.pick_place(crate::places::Place::Remote("origin".into()), cx); + tree.activate(Some(ids[1]), cx); + }) + }); + assert!(cx.update(|_, app| { + tree.read(app) + .active() + .is_some_and(|o| o.picked_place.is_none()) + })); +} diff --git a/apps/ely-tree/src/tests/flows.rs b/apps/ely-tree/src/tests/flows.rs index ae38ea0..f34c970 100644 --- a/apps/ely-tree/src/tests/flows.rs +++ b/apps/ely-tree/src/tests/flows.rs @@ -256,3 +256,67 @@ fn a_pick_that_conflicts_stops_without_a_dialog() { }); assert!(cx.update(|_, app| tree.read(app).ask.is_none())); } + +#[test] +fn a_git_am_stopped_on_a_conflict_says_so() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (root, _) = conflicted(dir.path()); + let patch = sh(&root, &["format-patch", "-1", "side", "--stdout"]); + std::fs::write(dir.path().join("p.patch"), patch).expect("writes"); + // `git am` stops on the conflict, as in a terminal. + let stopped = super::git().run( + &root, + &[ + "am", + "--3way", + dir.path().join("p.patch").to_str().expect("utf-8"), + ], + ); + assert!(stopped.is_err(), "the patch conflicts"); + let mut cx = TestAppContext::single(); + let (tree, cx) = window(&mut cx, dir.path()); + cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx))); + let op = |app: &App| match tree.read(app).active().and_then(|o| o.doing.clone()) { + Some(Ok(Some(doing))) => Some((doing.op, doing.theirs)), + _ => None, + }; + settle(cx, |app| op(app).is_some()); + assert_eq!( + cx.update(|_, app| op(app)), + Some((crate::ops::Op::Am, "1/1".to_string())) + ); +} + +#[test] +fn a_settled_conflict_says_so_when_picked_again() { + let dir = tempfile::tempdir().expect("a temp dir"); + let (root, _) = conflicted(dir.path()); + let mut cx = TestAppContext::single(); + let (tree, cx) = window(&mut cx, dir.path()); + let shell = cx.update(|window, app| { + let shell = ely_ui::tool_root( + ely_platform::AppId::Tree, + tree.clone().into(), + Vec::new(), + window, + app, + ); + tree.update(app, |tree, _| tree.root = shell.downgrade()); + shell + }); + cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx))); + settle(cx, |app| branch(tree.read(app)).is_some()); + cx.update(|_, app| tree.update(app, |tree, cx| tree.merge("side", cx))); + settle(cx, |app| conflicts(tree.read(app)) == 1); + cx.update(|_, app| { + tree.update(app, |tree, cx| { + tree.show("a.txt", ChangeSection::Conflicted, cx) + }) + }); + settle(cx, |app| tree.read(app).merging.is_some()); + cx.update(|_, app| tree.update(app, |tree, cx| tree.take(0, Take::Theirs, cx))); + cx.update(|_, app| tree.update(app, |tree, cx| tree.take(0, Take::Ours, cx))); + let want = cx.update(|_, app| ely_ui::i18n::text(app, "tree.conflict.taken", &[])); + let toasts = cx.update(|_, app| shell.read(app).toasts(app)); + assert!(toasts.iter().any(|(text, _)| *text == want), "{toasts:?}"); +} diff --git a/apps/ely-tree/src/tests/places.rs b/apps/ely-tree/src/tests/places.rs index 646f441..0c617f2 100644 --- a/apps/ely-tree/src/tests/places.rs +++ b/apps/ely-tree/src/tests/places.rs @@ -433,3 +433,41 @@ fn a_stash_with_an_empty_message_still_lists() { assert_eq!(shown, ["", "named"]); assert_eq!(list[0].oid, oid); } + +#[test] +fn a_new_repository_lists_no_moves_and_no_lfs_files() { + let dir = tempfile::tempdir().expect("a temp dir"); + let root = dir.path().join("new"); + crate::repo::init(&git(), &root).expect("init"); + assert_eq!( + crate::recover::reflog(&git(), &root).expect("no reflog error"), + Vec::new() + ); + assert_eq!( + crate::structure::lfs(&git(), &root).expect("no LFS error"), + Vec::new() + ); +} + +#[test] +fn a_worktree_registered_through_a_link_lists_its_real_path() { + let dir = tempfile::tempdir().expect("a temp dir"); + let root = committed(dir.path()); + let other = dir.path().join("tree"); + let path = other.to_str().expect("utf-8"); + run( + &root, + Write::Git(words(&["worktree", "add", "-b", "side", "--", path])), + ) + .expect("adds"); + // Other tools may write the path through a link. + std::os::unix::fs::symlink(dir.path(), dir.path().join("link")).expect("links"); + let gitdir = root.join(".git/worktrees/tree/gitdir"); + std::fs::write( + &gitdir, + format!("{}\n", dir.path().join("link/tree/.git").display()), + ) + .expect("writes"); + let list = structure::worktrees(&git(), &root).expect("reads"); + assert_eq!(list[1].path, std::fs::canonicalize(&other).expect("real")); +} diff --git a/apps/ely-tree/src/tests/recovery.rs b/apps/ely-tree/src/tests/recovery.rs index c9efe8f..cfa5031 100644 --- a/apps/ely-tree/src/tests/recovery.rs +++ b/apps/ely-tree/src/tests/recovery.rs @@ -343,7 +343,12 @@ fn a_picked_reflog_event_stays_itself_as_others_arrive() { panic!("a reflog event"); }; assert!(now.same(was), "the event moved to HEAD@{{2}}"); - assert!(cx.update(|_, app| tree.read(app).picked_place.as_ref() == Some(&picked))); + assert!(cx.update(|_, app| { + tree.read(app) + .active() + .and_then(|o| o.picked_place.as_ref()) + == Some(&picked) + })); } #[test] @@ -383,3 +388,29 @@ fn a_held_config_lock_waits_for_a_retry_too() { String::from_utf8_lossy(&read).trim() == "Locked Out" }); } + +#[test] +fn a_read_that_lands_late_leaves_the_users_dialog_open() { + let dir = tempfile::tempdir().expect("a temp dir"); + let root = committed(dir.path()); + let head = sh(&root, &["rev-parse", "HEAD"]).trim().to_string(); + let mut cx = TestAppContext::single(); + let (tree, cx) = window(&mut cx, dir.path()); + cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx))); + settle(cx, |app| branch(tree.read(app)).is_some()); + cx.update(|_, app| { + tree.update(app, |tree, cx| { + tree.ask_move(&head, cx); + tree.ask = Some(Ask::Failed { + title: "mine".into(), + detail: "".into(), + }); + }) + }); + cx.run_until_parked(); + let ask = cx.update(|_, app| match &tree.read(app).ask { + Some(Ask::Failed { title, .. }) => title.to_string(), + _ => "replaced".into(), + }); + assert_eq!(ask, "mine"); +} diff --git a/apps/ely-tree/src/tests/window.rs b/apps/ely-tree/src/tests/window.rs index 7cc9e0e..4e9097d 100644 --- a/apps/ely-tree/src/tests/window.rs +++ b/apps/ely-tree/src/tests/window.rs @@ -320,3 +320,144 @@ fn a_commit_from_the_box_shows_in_history_with_its_files() { }; settle(cx, file); } + +#[test] +fn any_new_pick_leaves_a_file_history() { + use crate::tree::Side; + use ely_gpui_component::git::ChangeSection; + let dir = tempfile::tempdir().expect("a temp dir"); + let root = committed(dir.path()); + let mut cx = TestAppContext::single(); + let (tree, cx) = window(&mut cx, dir.path()); + cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx))); + settle(cx, |app| branch(tree.read(app)).is_some()); + let head = sh(&root, &["rev-parse", "HEAD"]).trim().to_string(); + let looking = |cx: &mut VisualTestContext| cx.update(|_, app| tree.read(app).look.is_some()); + let picks: [(&str, Box)>); 3] = [ + ( + "a tab", + Box::new(|tree, cx| tree.set_side(Side::Branches, cx)), + ), + ( + "a commit", + Box::new(move |tree, cx| tree.pick_commit(&head, cx)), + ), + ( + "a file", + Box::new(|tree, cx| tree.show("a.txt", ChangeSection::Unstaged, cx)), + ), + ]; + for (what, pick) in picks { + cx.update(|_, app| tree.update(app, |tree, cx| tree.file_log("a.txt", cx))); + assert!(looking(cx), "the history opens"); + cx.update(|_, app| tree.update(app, |tree, cx| pick(tree, cx))); + assert!(!looking(cx), "{what} leaves the history"); + } +} + +#[test] +fn a_result_that_did_not_save_says_so() { + use crate::work::Write; + let dir = tempfile::tempdir().expect("a temp dir"); + let root = committed(dir.path()); + let mut cx = TestAppContext::single(); + let (tree, cx) = window(&mut cx, dir.path()); + // The window's toasts live on its root. + let shell = cx.update(|window, app| { + let shell = ely_ui::tool_root( + ely_platform::AppId::Tree, + tree.clone().into(), + Vec::new(), + window, + app, + ); + tree.update(app, |tree, _| tree.root = shell.downgrade()); + shell + }); + cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx))); + settle(cx, |app| branch(tree.read(app)).is_some()); + let id = cx.update(|_, app| tree.read(app).active().expect("open").id); + let save = Write::Save { + path: "a.txt".into(), + text: "merged".into(), + }; + cx.update(|_, app| { + tree.update(app, |tree, cx| { + tree.written( + id, + &save, + Err(std::io::Error::other("disk full")), + false, + cx, + ) + }) + }); + let want = + cx.update(|_, app| ely_ui::i18n::text(app, "tree.error.save", &[("error", "disk full")])); + let toasts = cx.update(|_, app| shell.read(app).toasts(app)); + assert!(toasts.iter().any(|(text, _)| *text == want), "{toasts:?}"); +} + +#[test] +fn unstaging_right_after_a_first_commit_deletes_nothing() { + use crate::work::Write; + let dir = tempfile::tempdir().expect("a temp dir"); + let root = dir.path().join("new"); + crate::repo::init(&super::git(), &root).expect("init"); + std::fs::write(root.join("a.txt"), "one\n").expect("writes"); + sh(&root, &["add", "a.txt"]); + let mut cx = TestAppContext::single(); + let (tree, cx) = window(&mut cx, dir.path()); + cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx))); + settle(cx, |app| { + tree.read(app) + .active() + .is_some_and(|opened| matches!(opened.status, Some(Ok(_)))) + }); + let id = cx.update(|_, app| tree.read(app).active().expect("open").id); + // Committed outside; the status has not read it yet. + sh(&root, &["commit", "-qm", "first"]); + cx.update(|_, app| tree.update(app, |tree, cx| tree.write(id, Write::UnstageAll, cx))); + settle(cx, |app| { + tree.read(app) + .active() + .is_some_and(|opened| !opened.writing && opened.writes.is_empty()) + }); + assert_eq!( + sh(&root, &["status", "--porcelain"]), + "", + "nothing staged for deletion" + ); +} + +#[test] +fn the_repository_menu_offers_only_what_this_platform_builds() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut cx = TestAppContext::single(); + let (_tree, cx) = window(&mut cx, dir.path()); + let names = cx.update(|_, app| { + crate::menu::menus(app)[0] + .items + .iter() + .filter_map(|item| match item { + gpui::MenuItem::Action { name, .. } => Some(name.to_string()), + _ => None, + }) + .collect::>() + }); + let mut said = |key: &str| cx.update(|_, app| ely_ui::i18n::text(app, key, &[]).to_string()); + let (terminal, reveal, clone) = ( + said("tree.tool.terminal"), + said(crate::tools::REVEAL), + said("tree.clone"), + ); + assert_eq!( + names.contains(&terminal), + crate::tools::TERMINAL, + "{names:?}" + ); + assert!( + names.contains(&reveal) && names.contains(&clone), + "{names:?}" + ); +} diff --git a/apps/ely-tree/src/tools.rs b/apps/ely-tree/src/tools.rs index a9d97ea..d5d12e8 100644 --- a/apps/ely-tree/src/tools.rs +++ b/apps/ely-tree/src/tools.rs @@ -97,6 +97,16 @@ fn launch(editor: &Path, file: &Path) -> io::Result<()> { .map_err(|error| io::Error::other(format!("{}: {error}", editor.display()))) } +/// Only macOS opens a terminal; elsewhere it is hidden. +pub const TERMINAL: bool = cfg!(target_os = "macos"); + +/// The reveal entry, named for this platform's file browser. +pub const REVEAL: &str = if cfg!(target_os = "macos") { + "tree.tool.reveal" +} else { + "tree.tool.reveal_folder" +}; + #[cfg(target_os = "macos")] fn terminal_at(dir: &Path) -> io::Result<()> { Command::new("open") diff --git a/apps/ely-tree/src/tree.rs b/apps/ely-tree/src/tree.rs index 9416534..55c1ce3 100644 --- a/apps/ely-tree/src/tree.rs +++ b/apps/ely-tree/src/tree.rs @@ -54,6 +54,9 @@ pub struct Opened { /// Identity reads started; only the latest lands. pub me_asks: u64, pub places: crate::places::Places, + /// The branch or tag picked in the Branches panel. + pub picked_ref: Option<(crate::branches::Kind, String)>, + pub picked_place: Option, pub net: Option, /// Kept alive; dropping it stops the watch. _watcher: Option, @@ -103,9 +106,6 @@ pub struct Tree { pub amend_loading: bool, /// File history and blame opened, numbering each. pub looks: u64, - /// The branch or tag picked in the Branches panel. - pub picked_ref: Option<(crate::branches::Kind, String)>, - pub picked_place: Option, /// The conflicted file being merged by hand. pub merging: Option, pub merge_editor: Entity, @@ -220,8 +220,6 @@ impl Tree { amend_ask: 0, amend_loading: false, looks: 0, - picked_ref: None, - picked_place: None, merging: None, merge_editor, _searching: searching, @@ -364,6 +362,8 @@ impl Tree { me: None, me_asks: 0, places: Default::default(), + picked_ref: None, + picked_place: None, net: None, _watcher: watcher, }); diff --git a/apps/ely-tree/src/view.rs b/apps/ely-tree/src/view.rs index a901e90..d245e3d 100644 --- a/apps/ely-tree/src/view.rs +++ b/apps/ely-tree/src/view.rs @@ -168,16 +168,18 @@ impl Tree { } if let Some(cloning) = &self.cloning { let this = cx.weak_entity(); + let title = text(cx, "tree.cloning", &[("name", &name(&cloning.into))]); + let state = EmptyState::new("tree-cloning", IconName::Download, title) + .body(cloning.said.clone()); + if !crate::running::STOPS { + return state.into_any_element(); + } let stop = Button::new("tree-clone-stop", text(cx, "tree.clone.stop", &[])) .disabled(cloning.stopping) .on_click(move |_, _, cx| { this.update(cx, |tree, cx| tree.stop_clone(cx)).ok(); }); - let title = text(cx, "tree.cloning", &[("name", &name(&cloning.into))]); - return EmptyState::new("tree-cloning", IconName::Download, title) - .body(cloning.said.clone()) - .action(stop) - .into_any_element(); + return state.action(stop).into_any_element(); } let Some(opened) = self.active() else { return self.start_page(cx); diff --git a/apps/ely-tree/src/work.rs b/apps/ely-tree/src/work.rs index afb1b9a..26e04e5 100644 --- a/apps/ely-tree/src/work.rs +++ b/apps/ely-tree/src/work.rs @@ -267,7 +267,17 @@ fn as_shown(root: &std::path::Path, path: &str, folder: bool) -> io::Result<()> let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect(); for (ix, part) in parts.iter().enumerate() { at.push(part); - let kind = std::fs::symlink_metadata(&at)?.file_type(); + // Gone since the preview is a change; others name paths. + let kind = match std::fs::symlink_metadata(&at) { + Ok(meta) => meta.file_type(), + Err(error) if error.kind() == io::ErrorKind::NotFound => return Err(changed()), + Err(error) => { + return Err(io::Error::new( + error.kind(), + format!("{}: {error}", at.display()), + )); + } + }; let last = ix + 1 == parts.len(); if (!last && !kind.is_dir()) || (last && kind.is_dir() != folder) { return Err(changed()); @@ -349,15 +359,18 @@ impl Tree { let Some(write) = opened.writes.pop_front() else { return; }; - let born = matches!(&opened.status, Some(Ok(status)) if status.head.oid.is_some()); opened.writing = true; if let Write::Remote(args) = write { return self.start_remote(id, args, cx); } - let (root, args) = (opened.repo.root.clone(), write.args(born)); - log::info!("tree: {:?} in {}", args.first(), root.display()); + let root = opened.repo.root.clone(); let task = cx.background_spawn(async move { - let done = run(&git, &root, &write, &args); + // Asked now, not cached: a commit may just have landed. + let done = git.born(&root).and_then(|born| { + let args = write.args(born); + log::info!("tree: {:?} in {}", args.first(), root.display()); + run(&git, &root, &write, &args) + }); let stopped = match (&write, &done) { (Write::Git(args), Err(error)) => { ops::stopped(&git, &root, args, &error.to_string()) @@ -411,6 +424,10 @@ impl Tree { } (Write::Config { .. }, Ok(())) => self.read_me(id, cx), (Write::Save { path, text }, Ok(())) => self.merge_saved(id, path, text, cx), + // A file write, not Git: the result did not save. + (Write::Save { .. }, Err(error)) => { + self.failed("tree.error.save", &error.to_string(), cx) + } // A conflict stop is no failure; Git says which. ( Write::Git(_) diff --git a/crates/ely-files/src/crash.rs b/crates/ely-files/src/crash.rs index 301a825..3c02738 100644 --- a/crates/ely-files/src/crash.rs +++ b/crates/ely-files/src/crash.rs @@ -21,7 +21,7 @@ impl Crash { pub fn open(dir: &Path, pid: u32) -> io::Result { std::fs::create_dir_all(dir)?; let file = dir.join("session.lock"); - std::fs::write(&file, format!("open {pid}"))?; + write_whole(&file, &format!("open {pid}"))?; Ok(Self { file }) } @@ -34,28 +34,24 @@ impl Crash { Err(error) => return Err(error), }; let mut parts = text.split_whitespace(); - match (parts.next(), parts.next()) { + match (parts.next(), parts.next().map(str::parse::)) { (Some("discarded"), _) => Ok(Marker::Discarded), - (Some("open"), Some(pid)) => { - let pid: u32 = pid.parse().map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "bad pid in the session lock") - })?; - Ok(if alive(pid) { - Marker::Clean - } else { - Marker::Crashed - }) + (Some("open"), Some(Ok(pid))) => Ok(if alive(pid) { + Marker::Clean + } else { + Marker::Crashed + }), + // A torn lock means no clean end: offer the points. + _ => { + log::warn!("recovery: the session lock is unreadable; treated as a crash"); + Ok(Marker::Crashed) } - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - "bad session lock", - )), } } /// An explicit discard: never a crash next launch (§6.2). pub fn discard(self) -> io::Result<()> { - std::fs::write(&self.file, "discarded 0") + write_whole(&self.file, "discarded 0") } /// A clean close removes the lock. @@ -68,6 +64,15 @@ impl Crash { } } +/// Replaces `file` whole: a power cut leaves old or new. +fn write_whole(file: &Path, text: &str) -> io::Result<()> { + use std::io::Write as _; + let mut staging = tempfile::NamedTempFile::new_in(file.parent().unwrap_or(Path::new(".")))?; + staging.write_all(text.as_bytes())?; + staging.as_file().sync_all()?; + staging.persist(file).map(drop).map_err(|error| error.error) +} + /// Whether process `pid` still runs; a crash leaves none. #[cfg(unix)] pub fn alive(pid: u32) -> bool { diff --git a/crates/ely-files/src/key.rs b/crates/ely-files/src/key.rs index c3c1365..53e235c 100644 --- a/crates/ely-files/src/key.rs +++ b/crates/ely-files/src/key.rs @@ -53,10 +53,13 @@ pub struct RecoveryKey { } impl RecoveryKey { - /// The stored key, else this session's; never a file. + /// The stored key, else this session's; read once a run. pub fn load() -> Self { - let dir = account_home().map(|home| shared_dir(&home)); - Self::from(dir.and_then(|dir| load_with(&KeyringBackend, &dir))) + static LOADED: std::sync::OnceLock = std::sync::OnceLock::new(); + *LOADED.get_or_init(|| { + let dir = account_home().map(|home| shared_dir(&home)); + Self::from(dir.and_then(|dir| load_with(&KeyringBackend, &dir))) + }) } /// The loaded key, or this session's when loading failed. diff --git a/crates/ely-files/src/recovery.rs b/crates/ely-files/src/recovery.rs index 4183362..0d6eafe 100644 --- a/crates/ely-files/src/recovery.rs +++ b/crates/ely-files/src/recovery.rs @@ -163,6 +163,11 @@ impl Recovery { /// Every point on disk, newest first. pub fn points(&self) -> io::Result> { + self.stamps()?.into_iter().map(|stamp| self.point(stamp)).collect() + } + + /// The points' stamps by name alone, newest first. + fn stamps(&self) -> io::Result> { let dir = self.dir.join("points"); let mut stamps = Vec::new(); let entries = match std::fs::read_dir(&dir) { @@ -183,16 +188,13 @@ impl Recovery { } stamps.sort_unstable_by(|a, b| b.cmp(a)); stamps.dedup(); - stamps - .into_iter() - .map(|stamp| self.point(stamp)) - .collect::>() + Ok(stamps) } /// The point one stamp names. fn point(&self, stamp: u128) -> io::Result { let dir = self.dir.join("points"); - let at = SystemTime::UNIX_EPOCH + Duration::from_nanos(stamp as u64); + let at = at_of(stamp); let enc = dir.join(format!("{stamp}.enc")); if enc.try_exists()? { let cursor = match self.key { @@ -223,26 +225,26 @@ impl Recovery { /// Drops the oldest points past age, count and byte limits. fn prune(&self, now: SystemTime) -> io::Result<()> { - let mut points = self.points()?; - while points.len() > self.keep - || points.last().is_some_and(|point| { - now.duration_since(point.at) + // Names only: a sealed point is never opened here. + let mut stamps = self.stamps()?; + while stamps.len() > self.keep + || stamps.last().is_some_and(|stamp| { + now.duration_since(at_of(*stamp)) .map(|age| age > self.max_age) .unwrap_or(false) }) - || self.bytes(&points)? > self.cap + || self.bytes(&stamps)? > self.cap { - let Some(oldest) = points.pop() else { break }; - self.drop(oldest.at)?; + let Some(oldest) = stamps.pop() else { break }; + self.drop(oldest)?; } Ok(()) } /// The total size of every file of every live point. - fn bytes(&self, points: &[RecoveryPoint]) -> io::Result { + fn bytes(&self, stamps: &[u128]) -> io::Result { let mut total = 0; - for point in points { - let stamp = stamp_of(point.at)?; + for stamp in stamps { let dir = self.dir.join("points"); for ext in ["bin", "cursor", "history", "enc"] { let path = dir.join(format!("{stamp}.{ext}")); @@ -257,8 +259,7 @@ impl Recovery { } /// Removes a point's files, its metadata first. - fn drop(&self, at: SystemTime) -> io::Result<()> { - let stamp = stamp_of(at)?; + fn drop(&self, stamp: u128) -> io::Result<()> { let dir = self.dir.join("points"); for ext in ["cursor", "history", "bin", "enc"] { match std::fs::remove_file(dir.join(format!("{stamp}.{ext}"))) { @@ -272,8 +273,8 @@ impl Recovery { /// An explicit discard: points go, the result stays recorded (§6.2). pub fn discard(&mut self) -> io::Result<()> { - for point in self.points()? { - self.drop(point.at)?; + for stamp in self.stamps()? { + self.drop(stamp)?; } std::fs::write(self.dir.join(".discarded"), "discarded")?; self.last_ok = None; @@ -304,6 +305,11 @@ impl Recovery { } } +/// The time a stamp names. +fn at_of(stamp: u128) -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_nanos(stamp as u64) +} + /// The point's timestamp as its file stem. fn stamp_of(at: SystemTime) -> io::Result { Ok(at diff --git a/crates/ely-files/src/recovery/tests.rs b/crates/ely-files/src/recovery/tests.rs index 529d0a4..8555fdd 100644 --- a/crates/ely-files/src/recovery/tests.rs +++ b/crates/ely-files/src/recovery/tests.rs @@ -272,6 +272,18 @@ fn a_discard_drops_points_and_records_it() { assert!(!recovery.discarded().expect("the marker clears")); } +#[test] +fn a_torn_lock_counts_as_a_crash() { + let dir = tempfile::tempdir().expect("a temp dir"); + for torn in ["", "op", "open x"] { + std::fs::write(dir.path().join("session.lock"), torn).expect("writes"); + assert_eq!(Crash::check(dir.path(), |_| true).expect("reads"), Marker::Crashed, "{torn:?}"); + } + // Opening again rewrites it whole. + Crash::open(dir.path(), 7).expect("opens"); + assert_eq!(Crash::check(dir.path(), |pid| pid == 7).expect("reads"), Marker::Clean); +} + #[test] fn the_lock_tells_crash_from_discard() { let dir = tempfile::tempdir().expect("a temp dir"); @@ -393,3 +405,15 @@ fn the_task_status_starts_from_the_recovery() { assert_eq!(task.last_ok(), Some(at(100))); task.stop(); } + +#[test] +fn keeping_sealed_points_opens_none_of_them() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut old = Recovery::sensitive(dir.path().to_path_buf(), key()).with_limits(2, u64::MAX); + old.write_once(b"one", Some("1:1"), at(100)).expect("lands"); + // Another run's key cannot open the older points. + let mut now = Recovery::sensitive(dir.path().to_path_buf(), [9u8; 32]).with_limits(2, u64::MAX); + now.write_once(b"two", Some("2:2"), at(200)).expect("pruning reads names only"); + now.write_once(b"three", None, at(300)).expect("and drops the oldest unread"); + assert_eq!(now.stamps().expect("names").len(), 2); +} diff --git a/crates/ely-ipc/src/relay.rs b/crates/ely-ipc/src/relay.rs index 9dd2ca3..65c5756 100644 --- a/crates/ely-ipc/src/relay.rs +++ b/crates/ely-ipc/src/relay.rs @@ -28,12 +28,18 @@ pub fn relay(stderr: impl Read + Send + 'static, who: &'static str) -> Relay { let kept = Arc::clone(&last); let thread = std::thread::spawn(move || { let mut withheld = 0u64; - for line in BufReader::new(stderr).lines() { - let line = match line { - Ok(line) => line, + let (mut reader, mut bytes) = (BufReader::new(stderr), Vec::new()); + loop { + bytes.clear(); + match reader.read_until(b'\n', &mut bytes) { + Ok(0) => break, + Ok(_) => {} Err(error) => return log::warn!(target: who, "stderr unreadable: {error}"), - }; - match parse(&line) { + } + // Engines may print other encodings; keep reading past them. + let line = String::from_utf8_lossy(&bytes); + let line = line.trim_end_matches(['\n', '\r']); + match parse(line) { Some((level, target, message)) => { if level == log::Level::Error { *kept.lock().expect("the relay lock") = Some(message.to_string()); @@ -41,7 +47,7 @@ pub fn relay(stderr: impl Read + Send + 'static, who: &'static str) -> Relay { log::log!(target: target, level, "{message}") } // A crash stack names code, never documents. - None if frame(&line) => log::error!(target: who, "{line}"), + None if frame(line) => log::error!(target: who, "{line}"), None => { withheld += 1; log::debug!(target: who, "native output withheld ({withheld} lines)"); @@ -79,6 +85,13 @@ mod tests { assert_eq!(parse("warning: syntax error: unknown keyword: 'Zq'"), None); } + #[test] + fn a_line_in_another_encoding_stops_nothing() { + let stderr: &[u8] = b"caf\xe9 opened\n[2026-10-08T12:00:00Z ERROR ely_pdf::worker] it broke\n"; + let mut relay = super::relay(stderr, "test"); + assert_eq!(relay.last_error().as_deref(), Some("it broke")); + } + #[test] fn stack_frames_pass_and_engine_text_does_not() { assert!(frame("3 ely-lok-worker 0x0000000104f3c960 report + 52")); diff --git a/crates/ely-lok/src/events.rs b/crates/ely-lok/src/events.rs index 1f88296..a667911 100644 --- a/crates/ely-lok/src/events.rs +++ b/crates/ely-lok/src/events.rs @@ -15,8 +15,8 @@ pub use cell::{Cell, Function, Mark, Request}; use cell::{functions_of, marks_of, request_of, usage_of}; pub use parse::A11y; use parse::{ - Dialog, a11y_update, dialog_of, fonts_of, formula_text, found_of, rect_of, result_of, state_of, - tabs_of, + Dialog, Unreadable, a11y_update, dialog_of, fonts_of, formula_text, found_of, rect_of, + result_of, state_of, tabs_of, }; /// LOK_CALLBACK kinds Ely reads. @@ -205,22 +205,36 @@ unsafe extern "C" fn on_event(kind: i32, payload: *const c_char, data: *mut c_vo signal().notify_all(); } +/// A rectangle payload; an unreadable one is a fault. +fn rect_in(batch: &mut Events, what: &str, text: &str) -> Option { + rect_of(text).unwrap_or_else(|Unreadable| { + batch.faults.push(format!("an unreadable {what}: {text}")); + None + }) +} + /// Folds one callback in; returns a dialog to close. fn record(slot: &mut Slot, kind: i32, text: &str) -> Option { let batch = &mut slot.batch; match kind { INVALIDATE_TILES => { batch.generation += 1; - match rect_of(text) { + match rect_in(batch, "invalidation", text) { Some(rect) => batch.rects.push(rect), None => batch.all = true, } } - CURSOR => batch.cursor = rect_of(text), - SELECTION => batch.selection = text.split(';').filter_map(rect_of).collect(), + CURSOR => batch.cursor = rect_in(batch, "cursor", text), + SELECTION => { + let parts: Vec<&str> = text.split(';').collect(); + batch.selection = parts + .into_iter() + .filter_map(|part| rect_in(batch, "selection", part)) + .collect(); + } CURSOR_VISIBLE => batch.cursor_visible = text == "true", GRAPHIC => { - batch.graphic = rect_of(text); + batch.graphic = rect_in(batch, "graphic selection", text); batch.cropping = batch.graphic.is_some() && text.contains("\"isCropMode\": true"); } STATE => { @@ -247,7 +261,7 @@ fn record(slot: &mut Slot, kind: i32, text: &str) -> Option { .push(format!("an unreadable search result: {text}")), }, MENU => batch.menu = Some(text.to_string()), - CELL_CURSOR => batch.cell.cursor = rect_of(text), + CELL_CURSOR => batch.cell.cursor = rect_in(batch, "cell cursor", text), CELL_FORMULA => batch.cell.formula = text.to_string(), CELL_ADDRESS => batch.cell.address = text.to_string(), REFERENCE_MARKS => match marks_of(text) { diff --git a/crates/ely-lok/src/events/cell.rs b/crates/ely-lok/src/events/cell.rs index b6ce0f5..d82f655 100644 --- a/crates/ely-lok/src/events/cell.rs +++ b/crates/ely-lok/src/events/cell.rs @@ -209,7 +209,7 @@ pub fn marks_of(text: &str) -> Option> { .iter() .map(|mark| { Some(Mark { - rect: super::parse::rect_of(mark["rectangle"].as_str()?)?, + rect: super::parse::rect_of(mark["rectangle"].as_str()?).ok()??, color: u32::from_str_radix(mark["color"].as_str()?, 16).ok()?, part: mark["part"].as_str()?.parse().ok()?, }) diff --git a/crates/ely-lok/src/events/parse.rs b/crates/ely-lok/src/events/parse.rs index 8fda545..30c5121 100644 --- a/crates/ely-lok/src/events/parse.rs +++ b/crates/ely-lok/src/events/parse.rs @@ -21,16 +21,19 @@ pub enum Dialog { Titled { window: u32, title: String }, } -/// Parses "x, y, w, h" plus optional fields. -pub fn rect_of(text: &str) -> Option { - let parts: Vec = text - .split(',') - .filter_map(|part| part.trim().parse().ok()) - .collect(); - match parts.as_slice() { - [x, y, w, h, ..] => Some((*x, *y, *w, *h)), - _ => None, +/// A payload that is neither a rectangle nor empty. +#[derive(Debug, PartialEq)] +pub struct Unreadable; + +/// "x, y, w, h" and later fields; "EMPTY" is none. +pub fn rect_of(text: &str) -> Result, Unreadable> { + let text = text.trim(); + if text.is_empty() || text.starts_with("EMPTY") { + return Ok(None); } + let mut parts = text.split(',').map(|part| part.trim().parse::()); + let mut next = || parts.next().and_then(Result::ok).ok_or(Unreadable); + Ok(Some((next()?, next()?, next()?, next()?))) } /// A search's matches, each by its first rectangle. @@ -39,7 +42,7 @@ pub fn found_of(text: &str) -> Option> { json["searchResultSelection"] .as_array()? .iter() - .map(|found| rect_of(found["rectangles"].as_str()?.split(';').next()?)) + .map(|found| rect_of(found["rectangles"].as_str()?.split(';').next()?).ok()?) .collect() } @@ -189,6 +192,15 @@ pub fn a11y_update(a11y: &mut A11y, kind: i32, text: &str) { mod tests { use super::*; + #[test] + fn a_rectangle_reads_strictly() { + assert_eq!(rect_of("1, 2, 3, 4, 0, 5"), Ok(Some((1, 2, 3, 4)))); + assert_eq!(rect_of("EMPTY, 0, 0"), Ok(None)); + assert_eq!(rect_of(""), Ok(None)); + assert_eq!(rect_of(r#"{"x": 1}, 2, 3, 4, 5"#), Err(Unreadable)); + assert_eq!(rect_of("1, 2, 3"), Err(Unreadable)); + } + #[test] fn dont_care_states_read_as_mixed() { let mixed = state_of(r#"{"commandName":".uno:Bold","dontcare":true}"#); diff --git a/crates/ely-lok/src/worker.rs b/crates/ely-lok/src/worker.rs index 0df225f..1264967 100644 --- a/crates/ely-lok/src/worker.rs +++ b/crates/ely-lok/src/worker.rs @@ -400,7 +400,12 @@ fn with_doc(id: u64, call: impl FnOnce(&Document) -> T) -> io::Result { /// The shown part, then each part's name and info. fn sheets(doc: &Document) -> io::Result> { let count = doc.parts(); - let mut pack = Pack::new().u64(doc.part() as u64).u64(count as u64); + // -1 means no view shell: no list. + let unread = |what| io::Error::other(format!("the kernel gives no {what}")); + let current = u64::try_from(doc.part()).map_err(|_| unread("current part"))?; + let mut pack = Pack::new() + .u64(current) + .u64(u64::try_from(count).map_err(|_| unread("part count"))?); for part in 0..count { pack = pack.str(&doc.part_name(part)?).str(&doc.part_info(part)?); } diff --git a/crates/ely-lok/src/worker/input.rs b/crates/ely-lok/src/worker/input.rs index 9661d39..4c42314 100644 --- a/crates/ely-lok/src/worker/input.rs +++ b/crates/ely-lok/src/worker/input.rs @@ -17,7 +17,7 @@ fn invalid(what: &str) -> io::Error { io::Error::new(io::ErrorKind::InvalidInput, what.to_string()) } -/// Paints one tile after checking the geometry (LOK-03). +/// One tile and the generation it was painted at (LOK-03). fn paint(unpack: &mut Unpack) -> io::Result<(u64, Vec)> { let id = unpack.u64()?; let (x, y, w, h) = (unpack.i64()?, unpack.i64()?, unpack.i64()?, unpack.i64()?); @@ -36,6 +36,8 @@ fn paint(unpack: &mut Unpack) -> io::Result<(u64, Vec)> { "bad tile geometry", )); }; + // Read first: an invalidation mid-paint must outdate it. + let generation = crate::events::generation(id); let tile = with_doc(id, |doc| { let mut tile = vec![0u8; len as usize]; doc.paint_rect( @@ -45,7 +47,7 @@ fn paint(unpack: &mut Unpack) -> io::Result<(u64, Vec)> { ); tile })?; - Ok((id, tile)) + Ok((generation, tile)) } /// One slide's whole picture at the asked pixels. @@ -74,8 +76,8 @@ pub(super) fn part_painted(unpack: &mut Unpack) -> io::Result> { /// A painted tile behind its generation. pub(super) fn painted(unpack: &mut Unpack) -> io::Result> { - let (id, tile) = paint(unpack)?; - let mut body = Pack::new().u64(crate::events::generation(id)).bytes(); + let (generation, tile) = paint(unpack)?; + let mut body = Pack::new().u64(generation).bytes(); body.extend_from_slice(&tile); Ok(body) } diff --git a/crates/ely-office/src/lib.rs b/crates/ely-office/src/lib.rs index 4a11f7f..e29c058 100644 --- a/crates/ely-office/src/lib.rs +++ b/crates/ely-office/src/lib.rs @@ -265,6 +265,11 @@ pub fn print_later(pdf: std::path::PathBuf, cx: &mut Context) { .detach(); } +/// Addresses a click may open outside: web and mail. +pub fn outward(uri: &str) -> bool { + ["https://", "http://", "mailto:"].iter().any(|scheme| uri.starts_with(scheme)) +} + /// A localized message. pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { ely_ui::i18n::text(cx, key, args) diff --git a/crates/ely-office/src/open.rs b/crates/ely-office/src/open.rs index 887e16e..39fd579 100644 --- a/crates/ely-office/src/open.rs +++ b/crates/ely-office/src/open.rs @@ -238,13 +238,18 @@ pub trait Opening: Host { /// Engine errors become toasts; kernel edits mark the session. fn watch(&mut self, id: u64, view: &Entity>, cx: &mut Context) { + let seen = std::cell::Cell::new(Self::Engine::edited(view.read(cx))); let reported = move |host: &mut Self, view: &Entity>, modified: bool, cx: &mut Context| { + let edited = Self::Engine::edited(view.read(cx)); + let moved = seen.replace(edited) != edited; if let Some(tab) = host.tab_mut(id) { match (modified, tab.modified) { (true, false) => tab.session.edit(), + // An edit landing mid-save outdates its ticket. + (true, true) if moved => tab.session.edit(), (false, true) => tab.session.reverted(), _ => {} } diff --git a/crates/ely-office/src/panel.rs b/crates/ely-office/src/panel.rs index 78a2450..0fff544 100644 --- a/crates/ely-office/src/panel.rs +++ b/crates/ely-office/src/panel.rs @@ -225,7 +225,7 @@ pub fn size_menu(panel: &P, id: &'static str, cx: &Context

) -> AnyE if let Some(size) = size.clone().filter(|size| !sizes.contains(size)) { sizes.push(size); } - sizes.sort_by(|a, b| points(a).total_cmp(&points(b))); + let sizes = ordered(sizes); let mut menu = Select::new( id, sizes @@ -279,7 +279,28 @@ pub fn known(state: &str) -> bool { !state.is_empty() && state != ely_lok::MIXED } -/// A size label's points, for ordering. -fn points(size: &str) -> f64 { - size.parse().expect("sizes are numbers") +/// Sizes by points; one the kernel words oddly shows last. +fn ordered(sizes: Vec) -> Vec { + let (mut sized, mut odd) = (Vec::new(), Vec::new()); + for size in sizes { + match size.parse::() { + Ok(points) => sized.push((points, size)), + Err(_) => odd.push(size), + } + } + sized.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut sizes: Vec = sized.into_iter().map(|(_, size)| size).collect(); + sizes.append(&mut odd); + sizes +} + +#[cfg(test)] +mod tests { + #[test] + fn an_odd_size_shows_last_and_panics_nothing() { + let sizes = ["12", "disabled", "9", "10.5"] + .map(gpui::SharedString::from) + .to_vec(); + assert_eq!(super::ordered(sizes), ["9", "10.5", "12", "disabled"]); + } } diff --git a/crates/ely-office/src/recover.rs b/crates/ely-office/src/recover.rs index 7dd4e09..024b584 100644 --- a/crates/ely-office/src/recover.rs +++ b/crates/ely-office/src/recover.rs @@ -9,16 +9,35 @@ use std::{ use ely_files::{Crash, Marker, Recovery, RecoveryKey}; use ely_gpui_component::primitives::Severity; -use gpui::{AsyncApp, Context, WeakEntity}; +use gpui::{AppContext as _, AsyncApp, Context, WeakEntity}; use serde::{Deserialize, Serialize}; -use crate::{Engine, Host, Opening as _, Source, Tabs as _, text}; +use crate::{Engine, Host, Tabs as _, text}; + +mod reopen; /// A tab's points, and the edit the last holds. struct Slot { kept: Kept, written: Option, writing: bool, + /// The last failure, shown once until it changes. + failed: Option, +} + +impl Slot { + /// Notes a failed point; words to show, once each. + fn fail(&mut self, edited: u64, error: &io::Error) -> Option { + // Too big for the cap: wait for the next edit. + if error.kind() == io::ErrorKind::InvalidInput { + self.written = Some(edited); + } + let said = error.to_string(); + (self.failed.as_ref() != Some(&said)).then(|| { + self.failed = Some(said.clone()); + said + }) + } } /// What reopening a slot needs, kept beside its points. @@ -47,6 +66,9 @@ pub struct Keeping { pub(crate) last: Option, /// The index on disk is behind; the failure shown. behind: Option, + /// An index write runs; another follows it if asked. + flushing: bool, + flush_again: bool, } impl Keeping { @@ -70,7 +92,7 @@ impl Keeping { }; let crash = Crash::open(&dir, std::process::id())?; let (reopening, held) = (Vec::new(), Vec::new()); - Ok(Self { dir, crash: Some(crash), slots: HashMap::new(), crashed, reopening, held, last: None, behind: None }) + Ok(Self { dir, crash: Some(crash), slots: HashMap::new(), crashed, reopening, held, last: None, behind: None, flushing: false, flush_again: false }) } /// A clean quit: the next start finds nothing to recover. @@ -87,17 +109,27 @@ impl Keeping { } fn save_index(&self) -> io::Result<()> { + let (path, bytes) = self.index()?; + write_index(&path, &bytes) + } + + /// The index file and what it should hold now. + fn index(&self) -> io::Result<(PathBuf, Vec)> { let waiting = self.reopening.iter().map(|(_, _, kept)| kept).chain(&self.held); let waiting = waiting.map(|kept| Kept { held: true, ..kept.clone() }); let slots = self.slots.values().map(|slot| slot.kept.clone()); let kept: Vec = slots.chain(self.crashed.iter().cloned()).chain(waiting).collect(); - let path = self.dir.join("recovery").join("index.json"); - std::fs::create_dir_all(self.dir.join("recovery"))?; - // Staged then renamed: a failed write keeps the last index. - ely_files::save_atomic(&path, &serde_json::to_vec(&kept).map_err(io::Error::other)?, 0).map(|_| ()) + let bytes = serde_json::to_vec(&kept).map_err(io::Error::other)?; + Ok((self.dir.join("recovery").join("index.json"), bytes)) } } +/// Staged then renamed: a failed write keeps the last index. +fn write_index(path: &Path, bytes: &[u8]) -> io::Result<()> { + std::fs::create_dir_all(path.parent().expect("the index has a folder"))?; + ely_files::save_atomic(path, bytes, 0).map(|_| ()) +} + fn read_index(dir: &Path) -> io::Result> { match std::fs::read(dir.join("recovery").join("index.json")) { Ok(bytes) => serde_json::from_slice(&bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)), @@ -128,6 +160,9 @@ fn remove(path: &Path) -> io::Result<()> { } } +/// A slot taken from a released tab, waiting to reopen. +pub struct Taken(Kept, String); + /// When recovery points are written and crashes come back. pub trait Recovering: Host { /// From the app's window, never its tests. @@ -140,8 +175,17 @@ pub trait Recovering: Host { } } Self::keep_points_every(cx); - cx.on_app_quit(|host, _| { - if let Some(keeping) = host.core_mut().keeping.as_mut() { + cx.on_app_quit(|host, cx| { + // A write still in flight must not be the last. + if let Some(keeping) = host.core().keeping.as_ref() + && let Err(error) = keeping.save_index() + { + log::error!("recovery: the index is not saved at quit: {error}"); + } + // A system end skips asking: unsaved tabs keep points. + if host.core().tabs.iter().any(|tab| tab.dirty(cx)) { + log::warn!("recovery: quit with unsaved tabs; points stay"); + } else if let Some(keeping) = host.core_mut().keeping.as_mut() { keeping.end(); } async {} @@ -219,6 +263,7 @@ pub trait Recovering: Host { match written { Ok(()) => { slot.written = Some(edited); + slot.failed = None; keeping.last = Some(SystemTime::now()); if keeping.behind.is_some() { host.flush_index(cx); @@ -226,7 +271,8 @@ pub trait Recovering: Host { } Err(error) => { log::error!("recovery: tab {id} point failed ({:?})", error.kind()); - let shown = text(cx, "office.recovery.failed", &[("error", &error.to_string())]); + let Some(said) = slot.fail(edited, &error) else { return }; + let shown = text(cx, "office.recovery.failed", &[("error", &said)]); host.toast_unlogged(shown, Severity::Warning, cx); } } @@ -236,13 +282,40 @@ pub trait Recovering: Host { } } - /// Writes the index; a failure shows once and retries. + /// Writes the index in the background, one at a time. fn flush_index(&mut self, cx: &mut Context) { let Some(keeping) = self.core_mut().keeping.as_mut() else { return }; - let error = match keeping.save_index() { - Ok(()) => return keeping.behind = None, - Err(error) => error.to_string(), + if keeping.flushing { + return keeping.flush_again = true; + } + let (path, bytes) = match keeping.index() { + Ok(index) => index, + Err(error) => return self.index_failed(error.to_string(), cx), }; + keeping.flushing = true; + let task = cx.background_spawn(async move { write_index(&path, &bytes) }); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let written = task.await; + this.update(cx, |host, cx| { + let Some(keeping) = host.core_mut().keeping.as_mut() else { return }; + keeping.flushing = false; + match written { + Ok(()) => keeping.behind = None, + Err(error) => host.index_failed(error.to_string(), cx), + } + let again = host.core_mut().keeping.as_mut().is_some_and(|keeping| std::mem::take(&mut keeping.flush_again)); + if again { + host.flush_index(cx); + } + }) + .ok(); + }) + .detach(); + } + + /// An index failure shows once and retries. + fn index_failed(&mut self, error: String, cx: &mut Context) { + let Some(keeping) = self.core_mut().keeping.as_mut() else { return }; if keeping.behind.as_ref() == Some(&error) { return; } @@ -261,7 +334,7 @@ pub trait Recovering: Host { let Some(keeping) = self.core_mut().keeping.as_mut() else { return }; let slot = format!("{}-{id}", std::process::id()); let kept = Kept { slot, name: name.to_string(), format, sealed, held: false }; - keeping.slots.insert(id, Slot { kept, written: None, writing: false }); + keeping.slots.insert(id, Slot { kept, written: None, writing: false, failed: None }); self.flush_index(cx); } @@ -275,60 +348,50 @@ pub trait Recovering: Host { self.flush_index(cx); } - /// Opens each crashed slot's newest point as unsaved copy. + /// Reopens each crashed slot's newest point as an unsaved copy. fn reopen_crashed(&mut self, cx: &mut Context) { let Some(keeping) = self.core_mut().keeping.as_mut() else { return }; - let crashed = std::mem::take(&mut keeping.crashed); + let crashed: Vec<(Kept, String)> = std::mem::take(&mut keeping.crashed).into_iter().map(|kept| { let name = kept.name.clone(); (kept, name) }).collect(); if crashed.is_empty() { return; } - for kept in crashed { - let folder = self.core().keeping.as_ref().expect("keeping").folder(&kept.slot); - match restore(&kept, &folder, &self.core().work, self.core().next, false) { - Ok(Some((copy, at))) => { - log::info!("recovery: reopening a crashed {}", kept.format); - self.core_mut().keeping.as_mut().expect("keeping").reopening.push((copy.clone(), at, kept)); - self.load(Source::Copy(copy), cx); - } - Ok(None) => { - if let Err(error) = remove(&folder) { - log::error!("recovery: an empty slot stays: {error}"); - } - } - Err(error) => { + reopen::restore_off(self, crashed, false, |host, done, cx| { + for (name, restored) in done { + if let Err(error) = restored { log::error!("recovery: a crashed copy cannot open ({:?})", error.kind()); - let shown = text(cx, "office.recovery.lost", &[("name", &kept.name), ("error", &error.to_string())]); - self.toast_unlogged(shown, Severity::Danger, cx); - // Its points stay for a later start. - self.core_mut().keeping.as_mut().expect("keeping").held.push(kept); + let shown = text(cx, "office.recovery.lost", &[("name", &name), ("error", &error.to_string())]); + host.toast_unlogged(shown, Severity::Danger, cx); } } - } - self.flush_index(cx); + }, cx); } - /// Opens `id`'s newest point as a copy; false if none. - fn reopen_point(&mut self, id: u64, cx: &mut Context) -> io::Result { - let (work, next) = (self.core().work.clone(), self.core().next); - let Some(keeping) = self.core_mut().keeping.as_mut() else { return Ok(false) }; - // Out of the tab's hands: releasing it keeps these points. - let Some(slot) = keeping.slots.remove(&id) else { return Ok(false) }; - let restored = restore(&slot.kept, &keeping.folder(&slot.kept.slot), &work, next, true); - match restored { - Ok(Some((copy, at))) => { - keeping.reopening.push((copy.clone(), at, slot.kept)); - self.load(Source::Copy(copy), cx); - Ok(true) + /// Takes tab `id`'s slot; releasing it keeps the points. + fn take_slot(&mut self, id: u64, name: String) -> Option { + let slot = self.core_mut().keeping.as_mut()?.slots.remove(&id)?; + Some(Taken(slot.kept, name)) + } + + /// Reopens taken slots as copies; names any lost. + fn reopen_taken(&mut self, taken: Vec, mut lost: Vec, cx: &mut Context) { + let taken = taken.into_iter().map(|Taken(kept, name)| (kept, name)).collect(); + reopen::restore_off(self, taken, true, move |host, done, cx| { + for (name, restored) in done { + match restored { + Ok(true) => {} + Ok(false) => lost.push(name), + Err(error) => { + log::error!("office: a point does not open ({:?})", error.kind()); + lost.push(format!("{name} ({error})")); + } + } } - Ok(None) => { - keeping.slots.insert(id, slot); - Ok(false) + log::info!("{}: the engine is back, {} tab(s) lost", Self::KIND.app.slug(), lost.len()); + if !lost.is_empty() { + let shown = text(cx, "office.stopped.lost", &[("names", &lost.join(", "))]); + host.toast_unlogged(shown, Severity::Danger, cx); } - Err(error) => { - keeping.held.push(slot.kept); - Err(error) - } - } + }, cx); } /// A reopened copy takes over its old points, unsaved. @@ -355,28 +418,6 @@ pub trait Recovering: Host { impl Recovering for H {} -/// The newest point copied to the work folder, and when. -fn restore(kept: &Kept, folder: &Path, work: &Path, next: u64, this_run: bool) -> io::Result> { - let recovery = match kept.sealed { - true => { - let key = RecoveryKey::load(); - // This run's points open with this run's key. - if !key.lasting && !this_run { - return Err(io::Error::new(io::ErrorKind::PermissionDenied, "the recovery key is not in the key store")); - } - Recovery::sensitive(folder.to_path_buf(), key.key) - } - false => Recovery::new(folder.to_path_buf()), - }; - let Some(point) = recovery.points()?.into_iter().next() else { - return Ok(None); - }; - let dir = crate::files::work_dir(work, next)?; - let copy = dir.join(&kept.name).with_extension(&kept.format); - recovery.restore(&point, ©)?; - Ok(Some((copy, point.at))) -} - /// A recovery time as this machine's clock reads it. pub(crate) fn clock(at: SystemTime) -> String { match jiff::Timestamp::try_from(at) { @@ -387,7 +428,18 @@ pub(crate) fn clock(at: SystemTime) -> String { #[cfg(test)] mod tests { - use super::{Keeping, Kept}; + use super::{Keeping, Kept, Slot}; + + #[test] + fn a_failing_point_says_so_once_until_the_reason_changes() { + let mut slot = Slot { kept: kept("s", false), written: None, writing: false, failed: None }; + let full = std::io::Error::other("disk full"); + assert_eq!(slot.fail(1, &full).as_deref(), Some("disk full")); + assert_eq!(slot.fail(2, &full), None, "the same reason stays quiet"); + let big = std::io::Error::new(std::io::ErrorKind::InvalidInput, "over the cap"); + assert!(slot.fail(3, &big).is_some(), "a new reason shows"); + assert_eq!(slot.written, Some(3), "too big waits for the next edit"); + } fn kept(slot: &str, held: bool) -> Kept { Kept { slot: slot.into(), name: "a.docx".into(), format: "docx".into(), sealed: false, held } diff --git a/crates/ely-office/src/recover/reopen.rs b/crates/ely-office/src/recover/reopen.rs new file mode 100644 index 0000000..ac2c40b --- /dev/null +++ b/crates/ely-office/src/recover/reopen.rs @@ -0,0 +1,104 @@ +//! Points restored off the UI thread, then opened (§6.3). + +use std::{ + io, + path::{Path, PathBuf}, + time::SystemTime, +}; + +use ely_files::{Recovery, RecoveryKey}; +use gpui::{AppContext as _, AsyncApp, Context, WeakEntity}; + +use super::{Kept, Recovering, remove}; +use crate::{Opening as _, Source}; + +/// Restores each slot's newest point, then opens the copy. +pub(super) fn restore_off( + host: &mut H, + slots: Vec<(Kept, String)>, + this_run: bool, + then: impl FnOnce(&mut H, Vec<(String, io::Result)>, &mut Context) + 'static, + cx: &mut Context, +) { + let Some(keeping) = host.core_mut().keeping.as_mut() else { return }; + // Held meanwhile: a crash now still finds them. + keeping.held.extend(slots.iter().map(|(kept, _)| kept.clone())); + let jobs: Vec<(u64, PathBuf, Kept, String)> = slots + .into_iter() + .map(|(kept, name)| { + let folder = host.core().keeping.as_ref().expect("keeping").folder(&kept.slot); + let id = host.core().next; + // Each copy's folder is its future tab's. + host.core_mut().next += 1; + (id, folder, kept, name) + }) + .collect(); + host.flush_index(cx); + let work = host.core().work.clone(); + let task = cx.background_spawn(async move { + jobs.into_iter() + .map(|(id, folder, kept, name)| { + let restored = restore(&kept, &folder, &work, id, this_run); + // An empty slot has nothing to keep. + let restored = match restored { + Ok(None) => remove(&folder).map(|()| None), + other => other, + }; + (id, kept, name, restored) + }) + .collect::>() + }); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let done = task.await; + this.update(cx, |host, cx| { + let Some(keeping) = host.core_mut().keeping.as_mut() else { return }; + keeping.held.retain(|held| !done.iter().any(|(_, kept, ..)| kept.slot == held.slot)); + let mut said = Vec::new(); + for (id, kept, name, restored) in done { + let keeping = host.core_mut().keeping.as_mut().expect("keeping"); + let opened = match restored { + Ok(Some((copy, at))) => { + log::info!("recovery: reopening a {}", kept.format); + keeping.reopening.push((copy.clone(), at, kept)); + host.load_into(id, Source::Copy(copy), cx); + Ok(true) + } + Ok(None) => Ok(false), + Err(error) => { + // Its points stay for a later start. + keeping.held.push(kept); + Err(error) + } + }; + said.push((name, opened)); + } + host.flush_index(cx); + then(host, said, cx); + }) + .ok(); + }) + .detach(); +} + +/// The newest point copied to the work folder, and when. +fn restore(kept: &Kept, folder: &Path, work: &Path, next: u64, this_run: bool) -> io::Result> { + let recovery = match kept.sealed { + true => { + let key = RecoveryKey::load(); + // This run's points open with this run's key. + if !key.lasting && !this_run { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "the recovery key is not in the key store")); + } + Recovery::sensitive(folder.to_path_buf(), key.key) + } + false => Recovery::new(folder.to_path_buf()), + }; + let Some(point) = recovery.points()?.into_iter().next() else { + return Ok(None); + }; + let dir = crate::files::work_dir(work, next)?; + let copy = dir.join(&kept.name).with_extension(&kept.format); + recovery.restore(&point, ©)?; + Ok(Some((copy, point.at))) +} + diff --git a/crates/ely-office/src/save.rs b/crates/ely-office/src/save.rs index 28b522f..41ee466 100644 --- a/crates/ely-office/src/save.rs +++ b/crates/ely-office/src/save.rs @@ -30,16 +30,27 @@ fn identity(path: &Path) -> io::Result { pub trait Saving: Host { /// The tab whose source file is `target`. fn holder(&self, target: &Path) -> io::Result> { + // Target errors stand; a tab's gone volume does not. + let target = identity(target)?; for tab in &self.core().tabs { - if let Some(open) = tab.session.path() - && ely_files::same_file(open, target)? - { - return Ok(Some(tab.id)); + let Some(open) = tab.session.path() else { continue }; + match ely_files::same_file(open, &target) { + Ok(true) => return Ok(Some(tab.id)), + Ok(false) => {} + Err(error) => log::warn!("office: tab {} has no file to compare: {error}", tab.id), } } Ok(None) } + /// A save as refused unwritten; a waiting close stops. + fn refuse_save(&mut self, then: &Option, words: gpui::SharedString, cx: &mut Context) { + self.toast(words, Severity::Danger, cx); + if then.is_some() { + self.close_stopped(cx); + } + } + /// Saves in place in its own format. fn save(&mut self, id: u64, then: Option, window: &mut Window, cx: &mut Context) { if !self.settled(id, cx) { @@ -216,25 +227,29 @@ pub trait Saving: Host { host.write_as(id, path, then, window, cx) }); } + let name = self.tab(id).map(|tab| tab.name.clone()).unwrap_or_default(); + let unsaved = |error: io::Error, cx: &mut Context| { + text(cx, "office.error.save", &[("name", &name), ("error", &error.to_string())]) + }; let target = match identity(&path) { Ok(target) => target, - Err(error) => return self.report(id, Err(SaveError::Io(error)), cx), + Err(error) => return self.refuse_save(&then, unsaved(error, cx), cx), }; let Some(format) = files::save_as_format(&Self::KIND, &target) else { let formats = Self::KIND.save_as.join(", ."); let text = text(cx, "office.error.format", &[("formats", &formats)]); - return self.toast(text, Severity::Danger, cx); + return self.refuse_save(&then, text, cx); }; let Some(tab) = self.tab(id) else { return log::info!("office: tab {id} closed before its save"); }; let holder = match self.holder(&target) { Ok(holder) => holder, - Err(error) => return self.report(id, Err(SaveError::Io(error)), cx), + Err(error) => return self.refuse_save(&then, unsaved(error, cx), cx), }; if holder.is_some_and(|other| other != id) { let text = text(cx, "office.error.taken", &[("name", &file_name(&target))]); - return self.toast(text, Severity::Danger, cx); + return self.refuse_save(&then, text, cx); } let own_format = tab .session @@ -242,7 +257,7 @@ pub trait Saving: Host { .and_then(|open| files::kept_format(&Self::KIND, open).ok()); if holder == Some(id) && (tab.risk.is_some() || own_format != Some(format)) { let text = text(cx, "office.error.convert", &[]); - return self.toast(text, Severity::Danger, cx); + return self.refuse_save(&then, text, cx); } // The written file differs from the open one; reopen it. let reopen = diff --git a/crates/ely-office/src/stopped.rs b/crates/ely-office/src/stopped.rs index d22e6ef..e306578 100644 --- a/crates/ely-office/src/stopped.rs +++ b/crates/ely-office/src/stopped.rs @@ -5,7 +5,7 @@ use std::{path::PathBuf, sync::Arc}; use ely_gpui_component::{feedback::Banner, primitives::Severity}; use gpui::{AppContext as _, Context}; -use crate::{Closing as _, Dialog, Engine, Host, Opening as _, Recovering as _, Tabs as _, recover::clock, text}; +use crate::{Closing as _, Dialog, Engine, Host, Opening as _, Recovering as _, recover::clock, text}; /// Why the engine ended, and whether it is coming back. pub struct Stopped { @@ -93,7 +93,7 @@ pub trait Stopping: Host { .iter() .map(|tab| (tab.id, tab.name.to_string(), tab.session.path().map(PathBuf::from), tab.dirty(cx))) .collect(); - let mut lost = Vec::new(); + let (mut taken, mut lost) = (Vec::new(), Vec::new()); for (id, name, path, dirty) in tabs { if !dirty { self.release_tab(id, cx); @@ -102,13 +102,9 @@ pub trait Stopping: Host { } continue; } - match self.reopen_point(id, cx) { - Ok(true) => {} - Ok(false) => lost.push(name), - Err(error) => { - log::error!("office: tab {id} point does not open ({:?})", error.kind()); - lost.push(format!("{name} ({error})")); - } + match self.take_slot(id, name.clone()) { + Some(slot) => taken.push(slot), + None => lost.push(name), } self.release_tab(id, cx); } @@ -116,11 +112,7 @@ pub trait Stopping: Host { if matches!(self.core().dialog, Some(Dialog::Unsaved { .. } | Dialog::Conflict { .. })) { self.core_mut().dialog = None; } - log::info!("{}: the engine is back, {} tab(s) lost", Self::KIND.app.slug(), lost.len()); - if !lost.is_empty() { - let shown = text(cx, "office.stopped.lost", &[("names", &lost.join(", "))]); - self.toast_unlogged(shown, Severity::Danger, cx); - } + self.reopen_taken(taken, lost, cx); } } diff --git a/crates/ely-platform/src/media.rs b/crates/ely-platform/src/media.rs index 88489d7..3fc290b 100644 --- a/crates/ely-platform/src/media.rs +++ b/crates/ely-platform/src/media.rs @@ -5,6 +5,9 @@ use std::path::Path; #[cfg(target_os = "macos")] mod macos; +/// Only macOS plays yet; elsewhere nothing offers it. +pub const BUILT: bool = cfg!(target_os = "macos"); + /// What the player knows of its file. #[derive(Clone, Debug, PartialEq)] pub enum State { diff --git a/crates/ely-ui/src/i18n/mark.rs b/crates/ely-ui/src/i18n/mark.rs index f7b03a6..394f537 100644 --- a/crates/ely-ui/src/i18n/mark.rs +++ b/crates/ely-ui/src/i18n/mark.rs @@ -83,6 +83,11 @@ pub(super) const EN: &[(&str, &str)] = &[ ("mark.git.failed", "ElyTree did not open: {error}"), ("mark.large", "This file is {size} MB: it shows as source. Visual and preview are off; saving keeps every byte."), ("mark.large.modes", "Large files stay in source mode"), + ( + "mark.visual.broke", + "The visual editor failed ({what}); the file shows as source", + ), + ("mark.page.broke", "The editor did not open ({what})"), ("mark.mode.visual", "Visual"), ("mark.mode.source", "Source"), ("mark.mode.preview", "Preview"), @@ -179,6 +184,11 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("mark.git.failed", "ElyTree 没能打开:{error}"), ("mark.large", "此文件 {size} MB,以源码显示。可视与预览已关闭;保存时保留全部内容。"), ("mark.large.modes", "大文件只用源码模式"), + ( + "mark.visual.broke", + "可视编辑器出错({what}),文件以源码显示", + ), + ("mark.page.broke", "编辑器没有打开({what})"), ("mark.mode.visual", "可视"), ("mark.mode.source", "源码"), ("mark.mode.preview", "预览"), diff --git a/crates/ely-ui/src/i18n/office.rs b/crates/ely-ui/src/i18n/office.rs index a13e77a..7608ae1 100644 --- a/crates/ely-ui/src/i18n/office.rs +++ b/crates/ely-ui/src/i18n/office.rs @@ -17,12 +17,31 @@ pub(super) const EN: &[(&str, &str)] = &[ ), ("office.error.changed", "The document changed meanwhile; try again"), ("office.print.failed", "Printing failed: {error}"), - ("office.recovery.failed", "A recovery copy was not written: {error}"), - ("office.recovery.off", "Recovery copies are off for this run: {error}"), - ("office.recovery.lost", "{name} could not be recovered: {error}"), - ("office.recovery.back", "{name}: unsaved content from {at} is back; the cursor and undo history are not"), - ("office.recovery.index", "Recovery points cannot be listed, so a crash may lose them: {error}"), - ("office.stopped", "The document engine stopped ({why}). Last recovery point: {at}."), + ("office.link.refused", "Ely opens only web and mail links."), + ( + "office.recovery.failed", + "A recovery copy was not written: {error}", + ), + ( + "office.recovery.off", + "Recovery copies are off for this run: {error}", + ), + ( + "office.recovery.lost", + "{name} could not be recovered: {error}", + ), + ( + "office.recovery.back", + "{name}: unsaved content from {at} is back; the cursor and undo history are not", + ), + ( + "office.recovery.index", + "Recovery points cannot be listed, so a crash may lose them: {error}", + ), + ( + "office.stopped", + "The document engine stopped ({why}). Last recovery point: {at}.", + ), ("office.stopped.never", "none yet"), ("office.stopped.reopen", "Reopen documents"), ("office.stopped.lost", "Not reopened: {names}. Their unsaved changes had no recovery point."), @@ -103,6 +122,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("office.error.convert", "格式转换要写成新文件,请换个名字"), ("office.error.changed", "文档在此间已改动,请重试"), ("office.print.failed", "打印失败:{error}"), + ("office.link.refused", "Ely 只打开网页与邮件链接。"), ("office.recovery.failed", "恢复副本未能写入:{error}"), ("office.recovery.off", "本次运行不写恢复副本:{error}"), ("office.recovery.lost", "{name} 未能恢复:{error}"), diff --git a/crates/ely-ui/src/i18n/pdf.rs b/crates/ely-ui/src/i18n/pdf.rs index c17ab7b..672e969 100644 --- a/crates/ely-ui/src/i18n/pdf.rs +++ b/crates/ely-ui/src/i18n/pdf.rs @@ -184,7 +184,6 @@ pub(super) const EN: &[(&str, &str)] = &[ "pdf.link.bad", "Type a page number, or a web or mail address.", ), - ("pdf.link.refused", "Ely opens only web and mail links."), ("pdf.side.files", "Attachments"), ("pdf.files.none", "No attachments"), ("pdf.file.attach", "Attach File…"), @@ -391,7 +390,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("pdf.tool.link", "添加链接"), ("pdf.link.target", "目标"), ("pdf.link.bad", "请输入页码,或网址、邮件地址。"), - ("pdf.link.refused", "Ely 只打开网页与邮件链接。"), ("pdf.side.files", "附件"), ("pdf.files.none", "没有附件"), ("pdf.file.attach", "添加附件…"), diff --git a/crates/ely-ui/src/i18n/tree.rs b/crates/ely-ui/src/i18n/tree.rs index 9fba88c..12c82d0 100644 --- a/crates/ely-ui/src/i18n/tree.rs +++ b/crates/ely-ui/src/i18n/tree.rs @@ -52,6 +52,7 @@ pub(super) const EN: &[(&str, &str)] = &[ ("tree.error.write", "Git failed: {error}"), ("tree.error.clone", "The clone failed: {error}"), ("tree.error.stop", "The clone did not stop: {error}"), + ("tree.error.stop_remote", "Git did not stop: {error}"), ("tree.error.panel", "The folder panel failed: {error}"), ("tree.space.exact", "Exact"), ("tree.space.changes", "Ignore Space Changes"), @@ -179,6 +180,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("tree.error.write", "Git 失败:{error}"), ("tree.error.clone", "克隆失败:{error}"), ("tree.error.stop", "克隆未能停止:{error}"), + ("tree.error.stop_remote", "Git 未能停止:{error}"), ("tree.error.panel", "文件夹面板失败:{error}"), ("tree.space.exact", "精确"), ("tree.space.changes", "忽略空白变化"), diff --git a/crates/ely-ui/src/i18n/tree_ops.rs b/crates/ely-ui/src/i18n/tree_ops.rs index 6f864cc..ffd2694 100644 --- a/crates/ely-ui/src/i18n/tree_ops.rs +++ b/crates/ely-ui/src/i18n/tree_ops.rs @@ -75,6 +75,8 @@ pub(super) const EN: &[(&str, &str)] = &[ ), ("tree.op.picking", "Cherry-picking {theirs} onto {ours}"), ("tree.op.reverting", "Reverting {theirs} on {ours}"), + ("tree.op.applying", "Applying patch {theirs} onto {ours}"), + ("tree.op.unread", "What Git is doing did not read: {error}"), ( "tree.op.next", "Resolve the conflicts and mark each file resolved, then continue.", @@ -98,6 +100,8 @@ pub(super) const EN: &[(&str, &str)] = &[ ("tree.theirs.pick", "Picked, {name}"), ("tree.ours.revert", "Current, {name}"), ("tree.theirs.revert", "Reverting, {name}"), + ("tree.ours.am", "Current, {name}"), + ("tree.theirs.am", "Patch {name}"), ("tree.conflict.save", "Save"), ("tree.conflict.resolved", "Mark Resolved"), ("tree.conflict.use_ours", "Use Current File"), @@ -107,6 +111,10 @@ pub(super) const EN: &[(&str, &str)] = &[ "tree.conflict.hand", "The result was edited by hand; settle the rest in the result.", ), + ( + "tree.conflict.taken", + "This conflict is settled; change it in the result.", + ), ("tree.conflict.unsaved", "Save the result first."), ( "tree.conflict.kept", @@ -215,6 +223,8 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("tree.op.rebasing", "正在变基到 {ours},应用 {theirs}"), ("tree.op.picking", "正在把 {theirs} 拣选到 {ours}"), ("tree.op.reverting", "正在 {ours} 上撤销 {theirs}"), + ("tree.op.applying", "正在 {ours} 上应用补丁 {theirs}"), + ("tree.op.unread", "读不出 Git 正在做什么:{error}"), ("tree.op.next", "解决冲突并把每个文件标为已解决,然后继续。"), ("tree.op.continue", "继续"), ("tree.op.skip", "跳过…"), @@ -232,6 +242,8 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("tree.theirs.pick", "拣选的 {name}"), ("tree.ours.revert", "当前 {name}"), ("tree.theirs.revert", "撤销的 {name}"), + ("tree.ours.am", "当前 {name}"), + ("tree.theirs.am", "补丁 {name}"), ("tree.conflict.save", "保存"), ("tree.conflict.resolved", "标为已解决"), ("tree.conflict.use_ours", "用当前侧的文件"), @@ -241,6 +253,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ "tree.conflict.hand", "结果已手动改过,其余冲突请在结果里处理。", ), + ("tree.conflict.taken", "此处冲突已选定,请在结果里修改。"), ("tree.conflict.unsaved", "请先保存结果。"), ("tree.conflict.kept", "文件在磁盘上变了,未保存的结果保留。"), ("tree.conflict.marked.title", "把 {name} 标为已解决?"), diff --git a/crates/ely-ui/src/i18n/tree_places.rs b/crates/ely-ui/src/i18n/tree_places.rs index 66fc8aa..b915807 100644 --- a/crates/ely-ui/src/i18n/tree_places.rs +++ b/crates/ely-ui/src/i18n/tree_places.rs @@ -147,6 +147,7 @@ pub(super) const EN: &[(&str, &str)] = &[ ("tree.locked.retry", "Retry"), ("tree.tool.terminal", "Open in Terminal"), ("tree.tool.reveal", "Show in Finder"), + ("tree.tool.reveal_folder", "Show in Folder"), ("tree.tool.editor", "Choose Editor…"), ("tree.tool.in_editor", "Open in Editor"), ("tree.tool.in_mark", "Open in ElyMark"), @@ -283,6 +284,7 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("tree.locked.retry", "重试"), ("tree.tool.terminal", "在终端中打开"), ("tree.tool.reveal", "在访达中显示"), + ("tree.tool.reveal_folder", "在文件夹中显示"), ("tree.tool.editor", "选择编辑器…"), ("tree.tool.in_editor", "在编辑器中打开"), ("tree.tool.in_mark", "在 ElyMark 中打开"), diff --git a/crates/ely-web-content/src/handlers.rs b/crates/ely-web-content/src/handlers.rs index bd24e4d..199ed4e 100644 --- a/crates/ely-web-content/src/handlers.rs +++ b/crates/ely-web-content/src/handlers.rs @@ -349,6 +349,16 @@ wrap_life_span_handler! { fn on_after_created(&self, browser: Option<&mut Browser>) { let Some(browser) = browser.map(|b| b.clone()) else { return }; log::info!("web: browser {} made", browser.identifier()); + // Its view went before it came: close it, keep nothing. + if self.surface.lock().expect("the surface lock").closed { + log::info!("web: browser {} closes: its view is gone", browser.identifier()); + crate::engine::later(move || { + if let Some(host) = browser.host() { + host.close_browser(true as _); + } + }); + return; + } adopt(&browser, self.surface.clone()); self.surface.lock().expect("the surface lock").browser = Some(browser); } diff --git a/tasks/reviews/M-02-r1.md b/tasks/reviews/M-02-r1.md new file mode 100644 index 0000000..7bff6c6 --- /dev/null +++ b/tasks/reviews/M-02-r1.md @@ -0,0 +1,5 @@ +FAIL + +apps/ely-mark/src/view.rs:194 — `refresh` 换代时保留 `renders` 中的回调,随后 `query` 的代号检查会直接丢弃旧代 `rendered` 回报,导出永久悬空。原生复现:发起 Plain Text 导出,在桥接处暂留已生成的 generation=1 回报;外部写入触发 generation=2 的 `refresh` 后交付该回报。日志确认旧回报被丢弃,目标文件未生成,界面没有成功或失败提示;同一标签随后再次导出成功。证据:`/tmp/ely-m02-native.log`、`/tmp/ely-m02-native-result.log`。这违反 PRD MARK-11 与规则 2。修复方向:刷新换代时结束被作废的渲染回调并向原请求显式返回错误,补充该交错的回归测试。 + +tests/scripts/mark-outside.sh:81 — 当前测试等外部重载完成并额外暂停一秒才键入,缺少 M-02 在途消息的回归断言;18 个 ElyMark 单元测试也未覆盖这条协议。真实浏览器变异探针恢复旧版 `refresh`,并按旧版 Rust 行为保持代号:旧版与修复版均通过干净重载断言;将刷新前的 `changed` 延后至 Rust 计数重置后交付,旧版稳定留下 `changed=1、reported=0`,修复版保持落定。证据:`/tmp/ely-m02-mutation.log`;本轮 `cargo check -p ely-mark`、`cargo test -p ely-mark`、`mark-outside.sh` 均通过。修复方向:增加可控消息交付顺序的测试,覆盖两端换代、版本重置、旧代编辑回报丢弃,以及刷新后保存和关闭能够完成。 diff --git a/tests/scripts/mark-output.sh b/tests/scripts/mark-output.sh index 841f4a1..732f067 100755 --- a/tests/scripts/mark-output.sh +++ b/tests/scripts/mark-output.sh @@ -190,6 +190,17 @@ for _ in $(seq 60); do [ "$(count "web: printed")" -gt "$printed" ] && break; sl [ "$(count "web: printed")" -gt "$printed" ] || fail "print made no PDF" sleep 2 key 'key code 53' +# Two prints at once: the second never cancels the first. +sleep 1 +printed=$(count "web: printed") +osascript -e 'tell application "System Events" to keystroke "p" using command down' -e 'tell application "System Events" to keystroke "p" using command down' >/dev/null +# Panels are modal: close one, then the next prints. +for want in 1 2; do + for _ in $(seq 80); do [ "$(count "web: printed")" -ge $((printed + want)) ] && break; sleep 0.25; done + [ "$(count "web: printed")" -ge $((printed + want)) ] || fail "a second print cancelled the first: $(grep -E "web: printed|print-" "$work/log" | tail -n 6)" + sleep 2 + key 'key code 53' +done # Rich copy carries HTML; it overwrites the clipboard. if [ "${ELY_CLIPBOARD_TEST:-}" = 1 ]; then key 'keystroke "f" using command down' diff --git a/tests/scripts/mark-outside.sh b/tests/scripts/mark-outside.sh index eaefe61..c32883c 100755 --- a/tests/scripts/mark-outside.sh +++ b/tests/scripts/mark-outside.sh @@ -438,4 +438,20 @@ waits 'mark: shown as "source"' kill "$app" wait "$app" 2>/dev/null || true app= +# A clean tab with the disk's text closes without asking. +clean=$work/notes/c.md +printf 'clean\n' >"$clean" +launch "$clean" +waits 'mark: shown as "visual"' +changes=$(count "unsaved false") +printf 'outside\n' >"$clean.tmp" +mv "$clean.tmp" "$clean" +more "unsaved false" "$changes" +sleep 1 +[ "$("$work/ax-press" "$app" Close AXButton)" = pressed ] || fail "no tab Close" +sleep 2 +! seen "Don't Save" || fail "a clean tab that took the disk's text asked to save" +kill "$app" +wait "$app" 2>/dev/null || true +app= echo "ok: mark outside"