diff --git a/agents.md b/agents.md index 036d857..62879a0 100644 --- a/agents.md +++ b/agents.md @@ -157,6 +157,7 @@ tests/scripts/mark-modes.sh 真窗口 ElyMark:文档旁图片、三种模 tests/scripts/mark-places.sh 真窗口 ElyMark:文件夹树、大纲跳转、文件夹搜索、最近列表(需辅助功能权限) tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久、四种导出、打印、Git 状态与打开 ElyTree、大文件(需辅助功能权限与 pdftotext;ELY_CLIPBOARD_TEST=1 再测富文本复制) tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限) +tests/scripts/mark-links.mjs ElyMark 页面(Playwright,三端):可视模式单击编辑,Cmd(他处 Ctrl)单击交应用,预览单击交应用,地址取文档原文(需 site 的依赖) tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴) tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错 tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖 @@ -191,7 +192,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-01:上游版本以 `scripts/fetch-references.sh` 为准。LibreOffice 26.2.6(8221e31)、MuPDF 1.28.5(8ad45e9)、gpui 为 `gpui-pre` =0.3.8 与 `gpui-pre-platform` =0.3.8(crates.io,2026-10-07 起,随组件库 K01)、组件库 5d6efd4(2026-10-09 起)。 - 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)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。2026-10-10(用户定):`--yolo`、`service_tier="fast"`,每条最多五轮;herdr 直接启动 codex 程序,不经 shell 别名。 +- 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。2026-10-10(用户定):`--yolo`、`service_tier="fast"`、effort xhigh,每条最多五轮;herdr 直接启动 codex 程序,不经 shell 别名。 - 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 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。 @@ -278,5 +279,6 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。 - 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。 - 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。 -- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);CEF 照 cef-dll-sys 放在可执行文件旁(Windows、Linux 都是),不另载入;`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。 +- 2026-10-10:ElyMark 的链接(M-05,我定,同常见编辑器):可视模式单击链接是编辑,Cmd 单击(Windows、Linux 为 Ctrl)交应用;预览单击即交应用;应用照 ElyPDF:http、https、mailto 交系统打开,其余提示拒绝。 +- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);CEF 照 cef-dll-sys 放在可执行文件旁(Windows、Linux 都是),不另载入;`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。 - 2026-10-09(用户定):代码与发布从 GitHub 搬到自建的 Gitea(git.zacharyzhang.com;GitHub 账号被封,推送与发布都被拒)。公开仓库 `ZacharyZhang-NY/ElyOffice`(原样推上 main、缺陷清单分支与 v0.1.0、v0.1.1 标签),组件库为同站的 `ZacharyZhang-NY/Ely-GPUI-Components`(Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变)。v0.1.1 的六个 dmg 与两个源码包重新发在 Gitea 的 v0.1.1 发布里。官网的下载与源码链接指向 Gitea(`releases/latest` 会跳到最新发布页)。`.github/workflows` 删去(它们靠 GitHub 的 macOS 机器与 `gh`,发布改在本机以 `packaging/macos/release.sh` 构建、经 Gitea API 上传)。发布视频里写的地址已在源文件改为 Gitea,已发布的成片仍是旧地址,重渲染后才更新。上游项目(LibreOffice、MuPDF、git-lfs、tessdata 等)照旧从它们自己的 GitHub 地址取。 diff --git a/apps/ely-mark/src/view.rs b/apps/ely-mark/src/view.rs index 20321b8..c8afa12 100644 --- a/apps/ely-mark/src/view.rs +++ b/apps/ely-mark/src/view.rs @@ -266,16 +266,7 @@ impl MarkView { cx.emit(Reported); } Event::Query(said, reply) => self.query(said, reply, cx), - // 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::Leave(url) => self.follow(url, cx), Event::Failed(why) => { let lapsed = self.tally.lapse(); self.end_renders(lapsed, why, cx); @@ -301,6 +292,17 @@ impl MarkView { }); } + /// A clicked link: web and mail open outside. + fn follow(&mut self, url: &str, cx: &mut Context) { + if ely_office::outward(url) { + log::info!("mark: opens a link outside"); + return cx.open_url(url); + } + 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)); + } + fn query(&mut self, said: &str, reply: &Reply, cx: &mut Context) { let said: Value = match serde_json::from_str(said) { Ok(said) => said, @@ -367,6 +369,13 @@ impl MarkView { return reply.fail("bad search state"); } }, + (Some("link"), ..) => match said["href"].as_str() { + Some(url) => self.follow(url, cx), + None => { + log::error!("mark: the page sent a link without its address"); + return reply.fail("bad link"); + } + }, (Some("table"), ..) if said["done"] == false => { let key = match self.mode { Mode::Visual => "mark.table.none", diff --git a/apps/ely-mark/web/src/main.js b/apps/ely-mark/web/src/main.js index 5cb9d8d..f78fa56 100644 --- a/apps/ely-mark/web/src/main.js +++ b/apps/ely-mark/web/src/main.js @@ -43,6 +43,9 @@ function flush() { pending = null } +/** A link the user opens; the app decides. */ +const linked = (href) => tell({ kind: 'link', generation, href }) + /** The shown editor's headings, for the app's outline. */ function outline() { if (current) tell({ kind: 'outline', generation, items: current.outline() }) @@ -83,7 +86,7 @@ async function build(text, mode) { const edited = (write) => changed(() => front + write()) let made try { - made = await visual(place, body, mode === 'visual', texts, edited, edge) + made = await visual(place, body, mode === 'visual', texts, edited, edge, linked) } catch (error) { // A broken editor falls back to source and says so. console.error(`ely: the visual editor failed: ${error.name}`) diff --git a/apps/ely-mark/web/src/visual.js b/apps/ely-mark/web/src/visual.js index 4959a48..ad9a055 100644 --- a/apps/ely-mark/web/src/visual.js +++ b/apps/ely-mark/web/src/visual.js @@ -39,7 +39,7 @@ import { search } from 'prosemirror-search' import { style } from './style.js' /** An editor over `markdown`; null when blocks cannot map. */ -export async function visual(root, markdown, editable, texts, changed, edge) { +export async function visual(root, markdown, editable, texts, changed, edge, linked) { const owner = diagrams() let write = () => ({ text: markdown, spans: [] }) // Setting up raw blocks is no edit. @@ -71,6 +71,29 @@ export async function visual(root, markdown, editable, texts, changed, edge) { }, }), ) + // Preview: a click opens. Editor: Cmd (Ctrl elsewhere). + const mac = navigator.platform.startsWith('Mac') + const links = $prose( + () => + new Plugin({ + props: { + handleDOMEvents: { + click: (view, event) => { + const anchor = event.target.closest?.('a') + if (!anchor || !view.dom.contains(anchor)) return false + event.preventDefault() + if (editable && !(mac ? event.metaKey : event.ctrlKey)) return true + // The DOM href is sanitized; the mark is not. + const node = view.state.doc.nodeAt(view.posAtDOM(anchor, 0)) + const mark = node?.marks.find((mark) => mark.type.name === 'link') + if (!mark) throw new Error('ely: a link without its mark') + linked(mark.attrs.href) + return true + }, + }, + }, + }), + ) // Past their history, undo and redo reach the page. const ends = $prose(() => keymap({ @@ -103,6 +126,7 @@ export async function visual(root, markdown, editable, texts, changed, edge) { .use(watch) .use(current) .use(ends) + .use(links) .use($prose(() => search())) .create() const editor = await made.catch((error) => { diff --git a/crates/ely-office/src/lib.rs b/crates/ely-office/src/lib.rs index e29c058..bde1731 100644 --- a/crates/ely-office/src/lib.rs +++ b/crates/ely-office/src/lib.rs @@ -267,7 +267,23 @@ pub fn print_later(pdf: std::path::PathBuf, cx: &mut Context) { /// 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)) + // Schemes ignore case. + ["https://", "http://", "mailto:"] + .iter() + .any(|scheme| uri.get(..scheme.len()).is_some_and(|head| head.eq_ignore_ascii_case(scheme))) +} + +#[cfg(test)] +mod outward_tests { + #[test] + fn web_and_mail_go_out_in_any_case() { + for uri in ["https://a.example/", "HTTPS://a.example/", "Http://a.example/", "MAILTO:x@a.example"] { + assert!(super::outward(uri), "{uri}"); + } + for uri in ["ftp://a.example/", "file:///etc/passwd", "javascript:alert(1)", "httpsx://a", "ht", "\u{e9}https://"] { + assert!(!super::outward(uri), "{uri}"); + } + } } /// A localized message. diff --git a/scripts/review.sh b/scripts/review.sh index 8d0db19..a8e20cb 100755 --- a/scripts/review.sh +++ b/scripts/review.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Usage: review.sh [path...] — codex high, fast review. +# Usage: review.sh [path...] — codex xhigh, fast review. set -euo pipefail [ "${HERDR_ENV:-}" = 1 ] || { echo "review.sh: not inside herdr" >&2; exit 2; } @@ -37,7 +37,7 @@ pane=$(herdr pane split --current --direction right --cwd "$PWD" --no-focus | jq echo "$pane" >"$last" started= for _ in 1 2 3 4 5; do - if err=$(herdr agent start "$name" --kind codex --pane "$pane" --timeout 120000 -- --yolo --model gpt-6.1-sol -c 'model_reasoning_effort="high"' -c 'service_tier="fast"' -c check_for_update_on_startup=false 2>&1); then + if err=$(herdr agent start "$name" --kind codex --pane "$pane" --timeout 120000 -- --yolo --model gpt-6.1-sol -c 'model_reasoning_effort="xhigh"' -c 'service_tier="fast"' -c check_for_update_on_startup=false 2>&1); then started=1 break fi diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 503f278..40c2e2c 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -65,7 +65,7 @@ - [x] M-02 `refresh` 归零版本计数却不换代,在途的一键让标签一直“未落定” — 刷新即换代、归零并结束欠下的渲染(tally.rs);R1 渲染回调悬空、无在途消息的测试;R2 测试未证旧代归零;R3 PASS - [x] M-03 干净标签接过磁盘文字后关闭仍问“是否保存” — 接过磁盘文字的各路(同文、干净重载、合并)都经 adopt_disk,标签与视图的保存基线同设;R1 合并采用未同步视图基线;R2 注释超长;R3 PASS - [x] M-04 第二次打印或导出 PDF 悄悄取消第一次,没有文件也没有错误 — 每份打印与导出各有编号与文件,作业按编号保留、互不取消;每份作业完成时记下编号与结果;R1 窗口测试只数日志;R2 PASS(窗口测试只在 macOS,本机未运行) -- [ ] M-05 点页面里的链接什么也不发生 +- [x] M-05 点页面里的链接什么也不发生 — 页面拦下链接单击,地址取链接标记原文,预览单击、可视 Cmd/Ctrl 单击交应用(审查 4 轮) - [ ] M-06 `on_app_quit` 无条件删恢复点与崩溃锁,AppKit 直接终止时丢未保存内容 - [ ] M-07 崩溃恢复的未命名副本按“第一个显示的未命名标签”配对,内容可能落错标签 - [ ] M-08 页面重载后悬着的渲染回调永不完成,导出既不成功也不报错 diff --git a/tasks/reviews/M-05-r1.md b/tasks/reviews/M-05-r1.md new file mode 100644 index 0000000..519a733 --- /dev/null +++ b/tasks/reviews/M-05-r1.md @@ -0,0 +1,3 @@ +FAIL + +- apps/ely-mark/src/view.rs:270: The fix handles only `Event::Leave`, but visual-mode links never produce it. Open `[web](https://example.com/)` or `[mail](mailto:test@example.com)` in visual mode and click the link: Chromium treats the anchor as editable text, so no navigation reaches `Requests::on_before_browse` and neither the browser nor mail client opens. Disallowed links likewise produce no refusal. A WSL probe built the current bundled page and used real Playwright clicks: HTTPS, mailto and FTP each produced zero `Page.frameRequestedNavigation` events in visual mode; all three produced `anchorClick` events in preview. Ctrl-click on the visual HTTPS link also produced no navigation. Handle explicit link activation in the visual editor and forward it through the host's allowed/refused link policy. Add regression coverage that clicks rendered links in both modes and verifies opening/refusal; the current Cargo suites contain no M-05 test and pass on WSL despite this failure. diff --git a/tasks/reviews/M-05-r2.md b/tasks/reviews/M-05-r2.md new file mode 100644 index 0000000..3105934 --- /dev/null +++ b/tasks/reviews/M-05-r2.md @@ -0,0 +1,5 @@ +FAIL + +- apps/ely-mark/web/src/visual.js:82: Link activation uses the marks at a rounded caret position instead of the clicked anchor. Real Playwright clicks against freshly built pages reproduce this on native Windows and WSL: in `Go [website](https://example.com/) now.`, Ctrl-click 1 px inside the link's left edge sends no `link` query; in `[one](https://one.example/)[two](https://two.example/)`, the same click inside `two` sends `https://one.example/`. Clicking just after `website` also activates that link. Resolve the actual clicked anchor, including its descendants, rather than the insertion-position marks. Add regression clicks at both edges and between adjacent links; the current center-click test passes despite these failures. + +- crates/ely-office/src/lib.rs:270: The allowed-scheme check is case-sensitive, while the new visual handler forwards the original Markdown address. For `[upper](HTTPS://example.com/)`, Ctrl-click sends `HTTPS://example.com/` and `MarkView::follow` emits the refusal message; preview click produces the normalized `https://example.com/` and opens outside. Both page paths were verified on Windows and WSL. HTTP, HTTPS and mailto schemes are case-insensitive, so these paths violate the required shared policy. Parse or normalize the scheme consistently before checking it, and test the host's allowed/refused outcome for mixed-case schemes as well as page event delivery. diff --git a/tasks/reviews/M-05-r3.md b/tasks/reviews/M-05-r3.md new file mode 100644 index 0000000..6a78b35 --- /dev/null +++ b/tasks/reviews/M-05-r3.md @@ -0,0 +1,3 @@ +FAIL + +- apps/ely-mark/web/src/visual.js:82: Plain preview clicks still bypass the required refusal policy when Milkdown sanitizes a link's scheme. Open `[local](file:///outside/file.md)` or `[data](data:text/plain,hello)` in preview and click it: the rendered anchor has `href=""`, so Chromium navigates to the bundled `index.html` instead of sending a link query. That URL passes `Surface::navigable`, so `Requests::on_before_browse` never queues `Event::Leave` and `MarkView::follow` never displays the refusal. Fresh-page Playwright probes reproduced the empty href, same-page navigation and zero link queries on native Windows and WSL; a WSL CEF off-screen probe also confirmed a user-initiated navigation to the bundled index. Intercept preview link activation before the browser default, apply the host policy to sanitized/empty addresses too, and add preview regression cases for these schemes; the current FTP-only refusal delivery coverage misses them. diff --git a/tasks/reviews/M-05-r4.md b/tasks/reviews/M-05-r4.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/M-05-r4.md @@ -0,0 +1 @@ +PASS diff --git a/tests/scripts/mark-links.mjs b/tests/scripts/mark-links.mjs new file mode 100644 index 0000000..84dca42 --- /dev/null +++ b/tests/scripts/mark-links.mjs @@ -0,0 +1,76 @@ +// ElyMark's page: which clicks on links reach the app (M-05). +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '../..') +const page = join(root, 'engines/content/ely-mark/index.html') +if (!existsSync(page)) { + console.log('SKIP: no page; run scripts/build-mark-content.sh') + process.exit(0) +} +let chromium +try { + ;({ chromium } = createRequire(join(root, 'site/package.json'))('@playwright/test')) +} catch { + console.log('SKIP: no Playwright; run pnpm install in site') + process.exit(0) +} +const fail = (why) => { + console.error(`FAIL: ${why}`) + process.exit(1) +} + +const browser = await chromium.launch() +const tab = await browser.newPage() +await tab.addInitScript(() => { + window.asked = [] + window.elyQuery = ({ request, onSuccess }) => { + window.asked.push(JSON.parse(request)) + onSuccess('') + } +}) +const left = [] +await tab.route('https://example.com/**', (route) => { + left.push(route.request().url()) + route.abort() +}) +await tab.goto(pathToFileURL(page).href) +const doc = 'Go [web](https://example.com/) or [ftp](ftp://x.example/) and [one](https://one.example/)[two](https://two.example/) end.\n\n' + + '[local](file:///outside/file.md) [data](data:text/plain,hello) [empty]()\n' +const links = async () => (await tab.evaluate(() => window.asked)).filter((a) => a.kind === 'link').map((a) => a.href) +const opener = (await tab.evaluate(() => navigator.platform)).startsWith('Mac') ? 'Meta' : 'Control' +const anchor = (text) => tab.locator('a', { hasText: new RegExp(`^${text}$`) }) +// Clicks `dx` from the anchor's left edge, mid-height. +const at = async (text, dx, modifiers) => { + const box = await anchor(text).boundingBox() + await tab.mouse.move(box.x + dx, box.y + box.height / 2) + for (const key of modifiers) await tab.keyboard.down(key) + await tab.mouse.down() + await tab.mouse.up() + for (const key of modifiers) await tab.keyboard.up(key) +} + +await tab.evaluate((doc) => window.ely.open(doc, 'visual', {}, 1, '', null), doc) +await anchor('web').click() +if ((await links()).length) fail('a plain click in the editor opened a link') +await at('web', 1, [opener]) +await at('web', (await anchor('web').boundingBox()).width + 3, [opener]) +await at('two', 1, [opener]) +await anchor('ftp').click({ modifiers: [opener] }) +const asked = await links() +const want = 'https://example.com/ https://two.example/ ftp://x.example/' +if (asked.join(' ') !== want) fail(`the editor asked ${JSON.stringify(asked)}, not ${want}`) + +await tab.evaluate((doc) => window.ely.open(doc, 'preview', {}, 2, '', null), doc) +const before = (await links()).length +const here = tab.url() +for (const text of ['web', 'local', 'data', 'empty']) await anchor(text).click() +await tab.waitForTimeout(500) +const opened = (await links()).slice(before) +const sent = 'https://example.com/ file:///outside/file.md data:text/plain,hello ' +if (opened.join(' ') !== sent) fail(`preview asked ${JSON.stringify(opened)}, not ${JSON.stringify(sent)}`) +if (left.length || tab.url() !== here) fail(`preview navigated: ${JSON.stringify(left)} ${tab.url()}`) +await browser.close() +console.log('mark-links: an editor click edits, the opener key asks the app, preview asks the app')