From d929a807c492043bfba7d4a24d942ded35123bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 18:56:57 -0400 Subject: [PATCH] R3.1: keep \?\ for file IO; simplify a save target only as a whole Co-Authored-By: Claude Opus 5.5 (1M context) --- agents.md | 2 +- crates/ely-files/src/save.rs | 4 +- crates/ely-files/src/save/tests.rs | 3 ++ crates/ely-files/src/save/tests/long.rs | 49 +++++++++++++++++++++++++ crates/ely-office/src/save.rs | 15 +++++--- crates/ely-office/src/save/tests.rs | 35 ++++++++++++++++++ tasks/reviews/R3.1-r1.md | 5 +++ 7 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 crates/ely-files/src/save/tests/long.rs create mode 100644 crates/ely-office/src/save/tests.rs create mode 100644 tasks/reviews/R3.1-r1.md diff --git a/agents.md b/agents.md index fa202de..f72e56f 100644 --- a/agents.md +++ b/agents.md @@ -284,4 +284,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。 - 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。 - 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印删去,在 C14.9、C14.10、C14.12、C14.14 原生重做。C14.5 删去 CEF:`crates/ely-web-content`、`apps/ely-mark/web`、`scripts/fetch-cef.sh`、`scripts/build-mark-content.sh`、macOS 包里的辅助应用与其授权、Linux AppImage 的 `libcef` 安排、CEF 与网页依赖的许可、存储设置里的网页缓存、`webview.sh`、`hardened-macos.sh`、`linux-mark-idle.sh`、参考源码 cef、cef-rs、milkdown、codemirror-dev;`apps::open` 不再复原信号(只为 Chromium 屏蔽的 SIGTERM 而设)。旧 C14.1 至 C14.7、R2.13 与 ElyMark AppImage 的记录(CEF 实现)删去,在 git 历史;仍在的应用行为(外部变更与比较、会话、改名、Git 状态、最近与文件夹、`look.json`)在 C14.8 至 C14.16 逐条核对后重记。用户以前的 ElyMark 数据目录里可能留着 `web` 缓存文件夹,Ely 不再读写它。组件库 6f0401e:焦点进入空的可视文档即打开末尾的输入框,新文件可直接键入(原先键入无处落下);362a0fc:换回可视时同样打开(预览与可视共用焦点,无焦点事件),全文撤销后若文档成空,读完新文字再打开末尾的输入框;639521e:只有一份撤销历史,块的输入框自己的撤销与重做从不运行:在打开的块里撤销或重做,先把块写回为一步,再走全文的历史(块内逐键的撤销没有了,一次块编辑是一步),重做跨模式、跨多次撤销都在。ElyMark(C14.6):每个标签各记其模式,切换不改一个字节(编码写出与原文相同),撤销跨模式(全文一份撤销历史);视图在模式首次可键入时记一次 `mark: shown as "…"`(源码即刻,可视与预览在块读完后),PERF-07 计到这条。Windows 上实测(Ryzen 9 9950X3D)1 MB 文档 P50 341 毫秒、P95 560 毫秒(`docs/perf/2026-10-12-Ryzen9-9950X3D.md`);macOS、Linux 与树莓派未测。C14.7:语法由 markdown-rs 读(CommonMark 加 GFM 的表格、任务、删除线、自动链接,脚注,公式),front matter 不进可视,以原文块显示在最上(组件库 parse 测试核对各种块画成内容而非原文);链接照 M-05:预览单击、可视 Cmd(他处 Ctrl)按下交应用,可视普通按下编辑该块(组件库 bbb6071 的链接测试);应用只把 http、https、mailto 交系统打开,其余以提示拒绝(应用测试);链接地址与图片读不出的原因不进日志(地址与路径属文档内容)。文档旁图片按文件所在文件夹读(`MarkView` 在打开与改名时交给组件);组件库 4801555:表格单元格也是块,图片在格内照常画出(原只画替代文字),图片取自身宽度、以所在容器为上限(单元格可收缩,窄格里的图片按比例缩小,组件库 2e196a1)。gpui 的图片加载(`vendor/gpui-pre` 的 `img.rs`):GIF 与 WebP 的坏帧即结束加载并报错(`img.rs` 的加载器与 `platform.rs` 的 `Image::to_image_data`,后者给 `data:` 图片用)(原样跳过坏帧,解码器到文件尾仍不停地报错,加载永不结束、占满一个核),错误与日志不带图片路径(原样把路径写进错误,`AssetLogger` 照写进日志);应用测试以坏 GIF 核对两处加载都结束、错误里没有所在文件夹名。C14.8:左栏三页照旧(文件夹树、大纲、搜索);大纲在纵向滚动区里(标题再多也选得到,侧栏测试以 100 个标题滚到最后),取组件按 markdown-rs 读出的标题与行,选一项即 `reveal`(该行在视图里、插入点在其上;可视模式里键入仍须先按下块);多标签与最近列表经外壳与 `recent.json` 照旧。`desk::open_in` 收数据文件夹(`open` 照旧取本机的),应用的 gpui 测试以临时文件夹起整个窗口:打开文件即有标签、大纲行号、跳转落点与最近列表,键入的标题进大纲,两个文件两个标签各有大纲,打开文件夹只列 Markdown;文件监视在真线程上,测试以 `allow_parking` 允许。`ely_ui::install_no_launch_files`(`test-support`)给测试一个空的启动文件队列。随删去的网页页面一起的 `web.*` 文案(`i18n/web.rs`)删去。C14.9(组件库 ce4a1e9):回车照列表续写(无序、有序加一、任务为未勾),空项回车即结束列表,源码与打开的块都如此,只在 markdown-rs 读作列表项且不在代码或公式里时(围栏里的 `- a` 照常换行;长文读完前源码里回车只换行),新项照所在项的引用前缀、缩进(空格或 Tab)、标记后的空白与任务框写出,父项标记处换成空格(组件库 e162082);没写正文的任务框也算空项,回车即结束列表;Shift+Tab 按列退回一级:行首的空格与 Tab 混写时,Tab 补到下一个四格位,去掉合计四列(组件库 b39097c);在空白行(含只有空格的行)插入表格即以表格取代该行(上一行有字时先空一行),免成缩进代码(组件库 cd16b20);打开的块里 Tab、Shift+Tab 把所触的行缩进或退回四格(`MarkdownDocument > ElyInput` 键位,块外 Tab 照旧在块间移动),源码由编辑器自己缩进;`MarkdownDocument::edit_table` 按插入点所在表格增删行列,`insert_table` 在插入点所在行后放一个三列表格,都为一步撤销,表格按规范写法重写(引用与列表里的表格各行照留其前缀,前缀不一即拒绝;表头之外的多余单元格照留,在其上的列操作拒绝;拒绝的原因经 `TableRefusal` 交给应用提示),预览里一切修改(表格、替换)都拒绝;`show_match` 在源码选中、可视打开其所在块并选中、预览转到其行,`replace` 一步替换(自己的写入计入,已打开的块不被关掉);文字未读完时 `show_match` 先读,长文等读完在下一帧落定(记下所等的文字版本:其间文字一变即作废,新的请求取代等着的);围栏代码按其语言着色(`forms::syntax_fenced`,散文名不着色,无名照通用着色)。ElyMark:Cmd/Ctrl+F 与“编辑›查找和替换”打开查找框(组件库 `FindWidget`,`find_all` 的大小写、全词、正则;正则写错即框内显示原因),源码里标出全部匹配与当前一处;替换一处后转到替换文字之后的下一处(到尾即回到第一处),全部替换为一步撤销;“表格”菜单插入表格与增删行列,插入点不在表格里即提示。应用测试以整窗核对计数、替换与撤销、正则错误、跳到下一处、表格菜单。C14.10(组件库 d126a7e):拖到文档上的文件与剪贴板里只有图片(没有文字)时的粘贴,组件以 `Attachment` 事件交给属主(源码、打开的块与可视无块时都接 Cmd/Ctrl+V),`MarkdownDocument::insert` 在插入点写入文字(打开的块里照键入,预览拒绝)。ElyMark 的 `attach.rs`:只收图片(png、jpg、jpeg、gif、webp、svg、bmp,别的文件提示不收),在后台复制到文件旁的 `assets/`(拖入的保留原名、重名加序号,粘贴的名为 `pasted-毫秒.扩展名`;`ely_files::import_attachment` 与新的 `write_attachment` 同一套:以 `create_new` 占名,从不覆盖,写坏即删去),完成时文件仍在原文件夹才在插入点写 `![名](assets/名)`(名中的控制字符换成空格,方括号与反斜杠转义;地址除字母数字与 `-._~` 外逐字节百分号转义;文件名不是规范文字的图片在复制前即拒绝并说明,链接从不改写文件名),否则提示图片留在哪里;未保存过的文档与预览拒绝并说明。视图的文件夹只在外壳告知已存文件的位置时才有(`MarkView::moved`),图片的读取位置随之;未命名文档没有文件夹。`Opened` 去掉(只剩源),引擎直接载入 `Source`。C14.11(组件库 e89fb32):`MarkdownDocument::reset` 换文字时保留插入点与顶行(按新文字夹住,顶行等新文字读完后落定),干净的标签接过磁盘文字即停在原处,撤销不回到旧文字;外部变更、改名跟随、删除与读错照旧(`outside.rs`;`ely_files::Fingerprint` 加上文件身份,保留修改时间的原子替换也认作变更,各应用的冲突核对同受其益;每次读盘编号,只认最新一次,落定前再核对磁盘仍是所读的那份,否则重看),比较后的合并经 `view.adopt` 为一步撤销。应用测试以整窗核对:干净重载保位与撤销止于磁盘,删除后留着文字并标为已删,改名后标签跟到新名,路径变成文件夹即标为读不出,合并为一步撤销,保留时间的替换照样重载,过期的读取不落定。C14.12(组件库 508fee9):`MarkdownDocument::set_look(DocumentLook)` 是书写视图,只管怎么显示:`measure` 给正文栏的最大宽并居中(三种模式),`font` 给可视与预览的正文字体(代码照用等宽),`scale` 乘以正文与标题的字号(可视、预览经渲染的 `Draw`,打开的块同样,源码经 `CodeEditor::set_scale`),`focus` 让可视里读者所在块之外的块、源码里插入点所在行之外的行淡到 0.35(`CodeEditor::set_focus_line`),`typewriter` 让可视里读者所在块(打开的或插入点所在的)、源码里插入点所在行居中(块、文字、键入、块高或可视区一变即重新居中;两端各留半屏余量,首末块与首末行也能居中:可视放在第一块之上与末尾项之下,源码为列表首尾的空行(行数取正文区高度的一半,绘制、输入法与点按的坐标同一原点);模式切换的定位落定后重新居中;源码在渲染时以严格居中滚动,`CodeEditor::set_typewriter`;居中记录含视图与行高;组件库 f102ea0、cf9cc19、7ed4ea8)。ElyMark 的 `look.json` 照旧(专注、打字机、窄中宽 36/48/64rem、无衬线/衬线/等宽、字号 70% 至 200%),新标签首次报到即取用,菜单一改即作用于全部标签并存下(存不下即提示);衬线字体为 Georgia(Linux 为 DejaVu Serif,未在 Linux 上核对是否装有);专注模式另收起标签条、左栏与状态栏(照旧)。搜索(C14.13,MARK-10)照旧:本文件或所开文件夹,不分大小写逐行找,打开着的文件按当前文字;每条结果另记该行第一处命中的字符列(查询与原文都逐字去掉大小写,词尾 `ς` 同 `σ`,照 Unicode 的大小写折叠;逐字对应回原文,`İ` 等变长的字照样对得上),选中即打开或转到该文件,插入点落在该处。输出(C14.14,MARK-11):“文件›导出…”(⌘E)一个对话框选 HTML、HTML 与资源文件夹、PDF、纯文本,再选位置;HTML 由 ElyMark 按 markdown-rs 的语法树逐个节点写出(`output/html.rs`:GFM 加数学,front matter 不进输出,文档里的 HTML 一律转义成文字,链接按浏览器的读法(去掉首尾空白与控制字符、删去制表与换行)判定协议,只放行无协议、网页与邮件地址,脚注照 GFM 放在页末),公式换成 RaTeX 的轮廓 SVG(组件库 `formula_svg`,黑色;RaTeX 没有轮廓而写成文字的字,与图一样经 usvg 以系统字体变成路径,找不到字体即报错;先变路径再按墨迹定边框),Mermaid 图换成浅色 SVG,经 usvg 把箭头标记与文字都变成路径(组件库 `diagram_svg`,与公式共用 `flatten`;有标签或字找不到字体即报错,不让字悄悄消失),画不出的公式与图照原代码保留,按各自所在的行提示;图片:自包含的 HTML 内嵌为 data URL,资源文件夹版拷进新建的 `名称_files`(以 `create_new` 占名,由文件系统判同名,大小写不分的盘上也不互相覆盖;文件夹已在即拒绝,写坏只删本次自己建的(以建成与否为准,不先查是否存在)),网址与 `data:image` 原样保留,读不到的在 HTML 里照原地址、在 PDF 里写成方括号里的替代文字,并都提示;纯文本取自 mdast(块间空行,表格以制表符分格,公式写 TeX)。PDF:同一页面与图片写进标签暂存目录下每次各自的文件夹,`ely-pdf-worker` 的 `HTML_PDF` 以 MuPDF 的 story 排成 A4(边距 2 厘米),MuPDF 读不了的图(WebP、网址)事先列为缺图;story 或绘制有任何警告即整份失败、不写文件(同图片导出);暂存随即删去。打印(⌘P)先写同一份 PDF(每次打印各自的文件)再交系统面板(`ely_office::print_later`)。“编辑›复制为富文本”:所选的 Markdown(源码模式里编辑器的选区,或打开的块的选区,组件库 `MarkdownDocument::selected`;可视与预览没打开块时没有选区,即全文)以同一套生成 HTML(图片内嵌)放进剪贴板,纯文本为 Markdown 本身;缺图、画不出的公式与图同导出一样提示。会话恢复(C14.15,MARK-12)照旧(`restore.rs`:`session.json` 记标签的路径、名称、模式、插入点与滚动,`look.json` 记视图设置,崩溃后未保存的文字以未命名副本回来,原文件不动);恢复点或会话写不进时,窗口以提示写明(恢复点另写明该标签上次存下的时间,未曾存下写“尚无”),同一类错误(按 `io::ErrorKind`,不按含临时文件名的原话)只提示一次,每次计时照样重试;崩溃后恢复的标签以所载恢复点的时间为“上次存下”;所记的模式改为类型化的 `Mode`(serde 按小写名),认不出的模式与其他坏字段一样使会话读不出、启动报错,不再悄悄当作可视;原由已删的 CEF 窗口脚本覆盖的重启与崩溃恢复,改由同一进程里两个 gpui 测试上下文先后开同一数据目录核对。Git 配合(C14.16,MARK-13)照旧:状态栏的 Git 项(未改动、有改动、未跟踪、已忽略、有冲突)在保存(指纹变化)后重问,按下即以该文件开 ElyTree;单测核对各状态,窗口测试核对保存后由未改动变为有改动(测试的提交关掉签名,不受用户 `commit.gpgsign` 影响)。组件库 50a59d2 至 58227cd:图在屏上的尺寸与公式的边框改用带系统字体的 usvg 读取,选字照 gpui 的渲染器(字库为系统字体加组件库资源里 gpui 自带的 IBM Plex Sans,没有字体答应的通用族改指自带字体;指名的字体族找不到即取 sans-serif,再找不到取任一字体,单个字再按字回退),导出也照此(原先用无字体的默认选项量尺寸,每张图的每个标签都记一条“找不到字体”的警告,公式的边框也算不进回退字的墨迹)。验收(2026-10-12):Windows 与 Linux(WSL)的 `packaging/stage.sh ely-mark` 带出 `ely-mark`、`ely-pdf-worker`、Git 与许可,冒烟(`ELY_SMOKE=1`,打开含公式与图的文件)均正常退出、Windows 日志无警告;macOS 只能经发布 CI 构建,本次未验证。已知限制:markdown-rs 的解析时间随块数平方增长(release,本机:短段落 1 万段 0.12 秒、2 万 0.40 秒、4 万 1.44 秒、8 万 5.5 秒,纯 CommonMark 同样),所以短段落极多的大文件导出要数秒;PERF-07 的 1 MB 样本仍在 2 秒内。Linux 上 gpui 的 SVG 渲染器找不到其自带的 Lilex 字体(共用资源里没有),每次启动记一条警告,改用系统字体,不属 ElyMark。导出不写打开着的文件(`ely_files::export` 与外壳的持有者检查)。ElyMark 因此依赖 `ely-pdf-engine`,各端的包随带 `ely-pdf-worker` 与 MuPDF、OpenSSL、Great Vibes 的许可;OCR 语言数据只随 ElyPDF。 -- 2026-10-12(用户报:Windows 上 ElyMark 的最近列表显示 `\\?\C:\…`):凡取真实路径一律 `dunce::canonicalize`(ely-files 的会话登记与导出、ely-office 的另存身份、ely-lok 的内核目录与配置目录、ElyMark 的文件夹、最近列表、会话与标签比对;ElyTree 原已如此):Windows 上能写成普通路径即普通路径,他处同 `std::fs::canonicalize`。`ely_files::recent` 读入时以 `dunce::simplified` 去掉旧版写下的 `\\?\` 并去重,下次写入即为普通路径。 +- 2026-10-12(用户报:Windows 上 ElyMark 的最近列表显示 `\\?\C:\…`):凡取真实路径一律 `dunce::canonicalize`(ely-files 的会话登记、ely-lok 的内核目录与配置目录、ElyMark 的文件夹、最近列表、会话与标签比对;ElyTree 原已如此):Windows 上能写成普通路径即普通路径,他处同 `std::fs::canonicalize`。只供读写的路径照旧保留 `\\?\`(`ely_files::save` 的落盘与同文件判定:在 250 字的文件夹里,普通写法的暂存文件名越过 260 而写不进);ely-office 另存的目标先以完整路径求出,再以 `dunce::simplified` 整体判定能否写成普通路径(先简化文件夹再接文件名时,结尾带空格的名字会被 Windows 削成另一个已打开的文件)。`ely_files::recent` 读入时以 `dunce::simplified` 去掉旧版写下的 `\\?\` 并去重,下次写入即为普通路径。 diff --git a/crates/ely-files/src/save.rs b/crates/ely-files/src/save.rs index a93231c..1f576da 100644 --- a/crates/ely-files/src/save.rs +++ b/crates/ely-files/src/save.rs @@ -183,7 +183,7 @@ pub fn export( /// The canonical form of a possibly missing path. fn resolved(path: &Path) -> io::Result { - match dunce::canonicalize(path) { + match std::fs::canonicalize(path) { Ok(real) => Ok(real), Err(error) if error.kind() == io::ErrorKind::NotFound => { let name = path.file_name().ok_or_else(|| { @@ -192,7 +192,7 @@ fn resolved(path: &Path) -> io::Result { format!("{} has no file name", path.display()), ) })?; - Ok(dunce::canonicalize(parent_dir(path))?.join(name)) + Ok(std::fs::canonicalize(parent_dir(path))?.join(name)) } Err(error) => Err(error), } diff --git a/crates/ely-files/src/save/tests.rs b/crates/ely-files/src/save/tests.rs index ea7d05a..6f76baa 100644 --- a/crates/ely-files/src/save/tests.rs +++ b/crates/ely-files/src/save/tests.rs @@ -507,3 +507,6 @@ fn overwrite_resolves_a_conflict_with_the_local_version() { assert_eq!(read(&own), "local"); assert_eq!(session.state(), SaveState::Saved); } + +#[cfg(windows)] +mod long; diff --git a/crates/ely-files/src/save/tests/long.rs b/crates/ely-files/src/save/tests/long.rs new file mode 100644 index 0000000..bdbf2a4 --- /dev/null +++ b/crates/ely-files/src/save/tests/long.rs @@ -0,0 +1,49 @@ +use std::os::windows::ffi::OsStrExt as _; +use std::path::{Path, PathBuf}; + +use super::super::*; + +/// UTF-16 units Windows counts in `path`. +fn units(path: &Path) -> usize { + path.as_os_str().encode_wide().count() +} + +/// A folder under `root` whose path is `want` units long. +fn folder_of(root: &Path, want: usize) -> PathBuf { + let name = "d".repeat(want - units(root) - 1); + let dir = root.join(name); + std::fs::create_dir(&dir).expect("the long folder"); + assert_eq!(units(&dir), want); + dir +} + +#[test] +fn an_existing_short_file_in_a_long_folder_saves() { + let root = tempfile::tempdir().expect("a temp dir"); + let target = folder_of(root.path(), 250).join("a.md"); + std::fs::write(&target, "old").expect("the file"); + save_atomic(&target, b"new", 1).expect("the staging file fits"); + assert_eq!(std::fs::read(&target).expect("reads"), b"new"); +} + +#[test] +fn a_new_file_past_the_old_limit_saves() { + let root = tempfile::tempdir().expect("a temp dir"); + let dir = folder_of(root.path(), 200); + let target = dir.join(format!("{}.md", "x".repeat(72 - 3))); + assert_eq!(units(&target), 273); + save_atomic(&target, b"long", 1).expect("the long file saves"); + assert_eq!(std::fs::read(&target).expect("reads"), b"long"); +} + +#[test] +fn a_trailing_space_name_never_overwrites_the_open_file() { + let root = tempfile::tempdir().expect("a temp dir"); + let open = std::fs::canonicalize(root.path()).expect("real").join("guard.md"); + std::fs::write(&open, "keep").expect("the open file"); + let literal = PathBuf::from(format!("{} ", open.display())); + assert!(literal.to_string_lossy().starts_with(r"\\?\"), "a verbatim path: {}", literal.display()); + export(&literal, Some(&open), b"exported", 1).expect("the export lands"); + assert_eq!(std::fs::read(&open).expect("reads"), b"keep"); + assert_eq!(std::fs::read(&literal).expect("the literal file"), b"exported"); +} diff --git a/crates/ely-office/src/save.rs b/crates/ely-office/src/save.rs index 9a7c5d5..3811230 100644 --- a/crates/ely-office/src/save.rs +++ b/crates/ely-office/src/save.rs @@ -14,17 +14,19 @@ use crate::{After, Closing, Dialog, Engine, Host, Opening, Tab, Tabs, in_window, /// The file a path names, links resolved. fn identity(path: &Path) -> io::Result { - match dunce::canonicalize(path) { - Ok(real) => Ok(real), + let real = match std::fs::canonicalize(path) { + Ok(real) => real, Err(error) if error.kind() == io::ErrorKind::NotFound => { let parent = path.parent().unwrap_or(Path::new(".")); let name = path .file_name() .ok_or_else(|| io::Error::other("no file name"))?; - Ok(dunce::canonicalize(parent)?.join(name)) + std::fs::canonicalize(parent)?.join(name) } - Err(error) => Err(error), - } + Err(error) => return Err(error), + }; + // Whole path only: a name may need `\\?\`. + Ok(dunce::simplified(&real).to_path_buf()) } pub trait Saving: Host { @@ -478,3 +480,6 @@ pub fn folder(tab: &Tab) -> Option { .map(PathBuf::from) }) } + +#[cfg(all(test, windows))] +mod tests; diff --git a/crates/ely-office/src/save/tests.rs b/crates/ely-office/src/save/tests.rs new file mode 100644 index 0000000..1d803f1 --- /dev/null +++ b/crates/ely-office/src/save/tests.rs @@ -0,0 +1,35 @@ +use std::os::windows::ffi::OsStrExt as _; +use std::path::{Path, PathBuf}; + +use super::identity; + +fn verbatim(path: &Path) -> bool { + path.to_string_lossy().starts_with(r"\\?\") +} + +#[test] +fn a_short_path_reads_plain() { + let root = tempfile::tempdir().expect("a temp dir"); + let new = identity(&root.path().join("new.md")).expect("a target"); + assert!(!verbatim(&new), "{}", new.display()); +} + +#[test] +fn a_trailing_space_name_stays_verbatim() { + let root = tempfile::tempdir().expect("a temp dir"); + let real = std::fs::canonicalize(root.path()).expect("real"); + let spaced = identity(&PathBuf::from(format!("{} ", real.join("guard.md").display()))) + .expect("a target"); + assert!(verbatim(&spaced), "{}", spaced.display()); + assert!(spaced.to_string_lossy().ends_with("guard.md ")); +} + +#[test] +fn a_long_new_name_stays_verbatim() { + let root = tempfile::tempdir().expect("a temp dir"); + let dir = root.path().join("d".repeat(150)); + std::fs::create_dir(&dir).expect("a folder"); + let long = identity(&dir.join(format!("{}.md", "x".repeat(120)))).expect("a target"); + assert!(long.as_os_str().encode_wide().count() > 260); + assert!(verbatim(&long), "{}", long.display()); +} diff --git a/tasks/reviews/R3.1-r1.md b/tasks/reviews/R3.1-r1.md new file mode 100644 index 0000000..91e0d5a --- /dev/null +++ b/tasks/reviews/R3.1-r1.md @@ -0,0 +1,5 @@ +FAIL + +1. [P1] crates/ely-files/src/save.rs:195; crates/ely-office/src/save.rs:24 — Simplifying the parent before joining a missing filename can change which file the completed path names. On Windows, with `guard.md` open, exporting to the nonexistent extended path `\\?\C:\…\guard.md ` returns success and overwrites `guard.md`: the parent loses its prefix, Win32 trims the filename's trailing space, and `same_file` incorrectly returns false. The intended literal target never exists. This violates FILE-06. The same branch also fails to save a new 273-UTF-16-unit path whose parent is short enough to simplify, violating FILE-09. Both scenarios reproduced against the rebuilt library; the original canonical-parent algorithm preserves the open file and successfully writes the long target. Preserve extended-path semantics until the complete prospective target is formed, then simplify only when the complete path is safe, in both helpers. Add Windows regression tests for the overwrite and new long target. Evidence: `.work/R3.1-probe.log`. + +2. [P2] crates/ely-files/src/save.rs:186 — Simplifying an existing target also breaks atomic saves when its sibling staging filename crosses Windows' path limit. Reproduced with a 250-UTF-16-unit directory and existing `a.md` (255 units total): `save_atomic` returns OS error 3, although the original algorithm succeeds. The target is short enough for dunce to strip its prefix, but tempfile's `.tmp` plus six random characters makes the staging path 261 units; tempfile passes that unprefixed path directly to `SetFileAttributesW` during persistence. This affects ordinary saves and autosaves of existing documents (FILE-04/FILE-09), independently of the missing-target branch. Ensure the complete staging and destination paths used by Win32 persistence retain extended syntax whenever required, while keeping displayed paths simplified. Add a Windows regression test with an existing short filename in a directory near this limit. Evidence: `.work/R3.1-probe.log`, `existing_short_file_in_long_directory_keeps_saving`.