diff --git a/Cargo.lock b/Cargo.lock index 0b3cddb..9fa73c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1922,7 +1922,7 @@ dependencies = [ [[package]] name = "ely-gpui-component" version = "0.1.1" -source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=cd16b204f0ba56473b9ffb831bf2483bc232a6bb#cd16b204f0ba56473b9ffb831bf2483bc232a6bb" +source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=d126a7e7f7977dddae598486dd99aba79ce1f5f0#d126a7e7f7977dddae598486dd99aba79ce1f5f0" dependencies = [ "alacritty_terminal", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index ce6c11a..3185cdc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ jiff = "0.2" anyhow = "1" directories = "6" dunce = "1" -ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "cd16b204f0ba56473b9ffb831bf2483bc232a6bb" } +ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "d126a7e7f7977dddae598486dd99aba79ce1f5f0" } env_logger = "0.11" gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] } gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] } diff --git a/agents.md b/agents.md index 9936273..bf2f31f 100644 --- a/agents.md +++ b/agents.md @@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木 ## 组件映射 -依赖 `ely-gpui-component`(pin cd16b20,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。 +依赖 `ely-gpui-component`(pin d126a7e,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。 | 需要 | 用 | | --- | --- | @@ -283,4 +283,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。 - 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。 - 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。 -- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印删去,在 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 当一级退回;在空白行(含只有空格的行)插入表格即以表格取代该行(上一行有字时先空一行),免成缩进代码(组件库 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` 的大小写、全词、正则;正则写错即框内显示原因),源码里标出全部匹配与当前一处;替换一处后转到替换文字之后的下一处(到尾即回到第一处),全部替换为一步撤销;“表格”菜单插入表格与增删行列,插入点不在表格里即提示。应用测试以整窗核对计数、替换与撤销、正则错误、跳到下一处、表格菜单。 +- 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 当一级退回;在空白行(含只有空格的行)插入表格即以表格取代该行(上一行有字时先空一行),免成缩进代码(组件库 cd16b20);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`。打开的块里 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` 的大小写、全词、正则;正则写错即框内显示原因),源码里标出全部匹配与当前一处;替换一处后转到替换文字之后的下一处(到尾即回到第一处),全部替换为一步撤销;“表格”菜单插入表格与增删行列,插入点不在表格里即提示。应用测试以整窗核对计数、替换与撤销、正则错误、跳到下一处、表格菜单。 diff --git a/apps/ely-mark/src/attach.rs b/apps/ely-mark/src/attach.rs new file mode 100644 index 0000000..71ea851 --- /dev/null +++ b/apps/ely-mark/src/attach.rs @@ -0,0 +1,299 @@ +//! Pictures dropped or pasted, copied beside the file (MARK-07). + +use std::{ + io, + path::{Path, PathBuf}, +}; + +use ely_gpui_component::documents::Attachment; +use ely_ui::i18n::text; +use gpui::{App, Context, ImageFormat}; + +use crate::view::{Failed, MarkView, Mode}; + +const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]; + +/// What goes into `assets/`: a file, or pasted bytes. +enum Source { + File(PathBuf), + Bytes(String, Vec), +} + +fn said(key: &str, cx: &App) -> Failed { + Failed(text(cx, key, &[]).to_string()) +} + +impl MarkView { + /// Copies pictures off the window, then links them. + pub(crate) fn attach(&mut self, attached: &Attachment, cx: &mut Context) { + if self.mode == Mode::Preview { + return cx.emit(said("mark.attach.preview", cx)); + } + let Some(folder) = self.folder.clone() else { + return cx.emit(said("mark.attach.untitled", cx)); + }; + let sources: Vec = match attached { + Attachment::Files(paths) => { + let (pictures, others): (Vec<&PathBuf>, Vec<&PathBuf>) = + paths.iter().partition(|path| picture(path)); + if !others.is_empty() { + cx.emit(said("mark.attach.images", cx)); + } + pictures.into_iter().cloned().map(Source::File).collect() + } + Attachment::Picture(image) => { + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("the clock is past 1970") + .as_millis(); + let name = format!("pasted-{millis}.{}", extension(image.format())); + vec![Source::Bytes(name, image.bytes().to_vec())] + } + }; + if sources.is_empty() { + return; + } + log::info!("mark: {} pictures copy in", sources.len()); + let assets = folder.join("assets"); + cx.spawn(async move |view, cx| { + let copied = cx + .background_executor() + .spawn(async move { + sources + .into_iter() + .map(|source| match source { + Source::File(path) => ely_files::import_attachment(&path, &assets), + Source::Bytes(name, bytes) => { + ely_files::write_attachment(&name, &bytes, &assets) + } + }) + .collect::>>() + }) + .await; + view.update(cx, |view, cx| view.attached(folder, copied, cx)) + .ok(); + }) + .detach(); + } + + /// Links the copies, unless the file moved meanwhile. + fn attached( + &mut self, + folder: PathBuf, + copied: io::Result>, + cx: &mut Context, + ) { + let copied = match copied { + Ok(copied) => copied, + Err(error) => { + log::error!("mark: a picture did not copy: {error}"); + let said = text(cx, "office.error.engine", &[("error", &error.to_string())]); + return cx.emit(Failed(said.to_string())); + } + }; + if self.folder.as_ref() != Some(&folder) { + log::info!("mark: the file moved while pictures copied"); + let assets = folder.join("assets").display().to_string(); + let said = text(cx, "mark.attach.moved", &[("folder", &assets)]); + return cx.emit(Failed(said.to_string())); + } + let links: Vec = copied.iter().map(|copy| link(&folder, copy)).collect(); + let written = self + .document() + .clone() + .update(cx, |document, cx| document.insert(&links.join("\n"), cx)); + if !written { + cx.emit(said("mark.attach.preview", cx)); + } + } +} + +fn picture(path: &Path) -> bool { + path.extension() + .is_some_and(|ext| PICTURES.contains(&ext.to_string_lossy().to_ascii_lowercase().as_str())) +} + +fn extension(format: ImageFormat) -> &'static str { + match format { + ImageFormat::Png => "png", + ImageFormat::Jpeg => "jpg", + ImageFormat::Gif => "gif", + ImageFormat::Webp => "webp", + ImageFormat::Svg => "svg", + ImageFormat::Bmp => "bmp", + ImageFormat::Tiff => "tiff", + ImageFormat::Ico => "ico", + ImageFormat::Pnm => "pnm", + } +} + +/// The Markdown link to `copy`, relative to `folder`. +fn link(folder: &Path, copy: &Path) -> String { + let relative = copy.strip_prefix(folder).expect("copied under the folder"); + let address: Vec = relative + .components() + .map(|part| escaped(&part.as_os_str().to_string_lossy())) + .collect(); + let stem = copy.file_stem().unwrap_or_default().to_string_lossy(); + let alt = stem + .replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]"); + format!("![{alt}]({})", address.join("/")) +} + +/// A path part, every byte but plain ones escaped. +fn escaped(part: &str) -> String { + part.bytes() + .map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + (byte as char).to_string() + } + other => format!("%{other:02X}"), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, path::Path, rc::Rc}; + + use ely_gpui_component::documents::Attachment; + use gpui::{Entity, Image, ImageFormat, TestAppContext, VisualTestContext}; + + use super::link; + use crate::desk::{ + Desk, + tests::{open, start}, + }; + use crate::view::{Failed, MarkView}; + + /// What the view says, in order. + type Said = Rc>>; + + /// A saved file open in ElyMark, and what it says. + fn saved( + cx: &mut TestAppContext, + ) -> ( + Entity, + &'static mut VisualTestContext, + [tempfile::TempDir; 2], + Said, + ) { + let (data, files) = ( + tempfile::tempdir().expect("data"), + tempfile::tempdir().expect("files"), + ); + let path = files.path().join("a.md"); + std::fs::write( + &path, "a +", + ) + .expect("a file"); + let (desk, cx) = start(cx, data.path()); + open(&desk, &path, cx); + let view = desk.read_with(cx, |desk: &Desk, _| desk.active_view().expect("a tab")); + let said = Rc::new(RefCell::new(Vec::new())); + let heard = said.clone(); + cx.update(|_, cx| { + cx.subscribe(&view, move |_, failed: &Failed, _| { + heard.borrow_mut().push(failed.0.clone()) + }) + .detach() + }); + (view, cx, [data, files], said) + } + + fn attach(view: &Entity, attached: Attachment, cx: &mut VisualTestContext) { + let document = view.read_with(cx, |view, _| view.document().clone()); + document.update(cx, |_, cx| cx.emit(attached)); + cx.run_until_parked(); + } + + fn current(view: &Entity, cx: &mut VisualTestContext) -> String { + view.read_with(cx, |view, cx| view.document().read(cx).text(cx)) + } + + #[gpui::test] + fn a_dropped_picture_is_copied_beside_and_linked(cx: &mut TestAppContext) { + let (view, cx, [_, files], said) = saved(cx); + let shot = files.path().join("my shot.png"); + std::fs::write(&shot, "png").expect("a picture"); + attach(&view, Attachment::Files(vec![shot]), cx); + assert!(files.path().join("assets/my shot.png").exists()); + assert!( + current(&view, cx).contains("![my shot](assets/my%20shot.png)"), + "{}", + current(&view, cx) + ); + assert!(said.borrow().is_empty()); + } + + #[gpui::test] + fn a_pasted_picture_is_written_and_linked(cx: &mut TestAppContext) { + let (view, cx, [_, files], _) = saved(cx); + attach( + &view, + Attachment::Picture(Image::from_bytes(ImageFormat::Png, vec![1])), + cx, + ); + let assets: Vec<_> = std::fs::read_dir(files.path().join("assets")) + .expect("assets") + .collect(); + assert_eq!(assets.len(), 1); + assert!(current(&view, cx).contains("](assets/pasted-")); + } + + #[gpui::test] + fn other_files_and_untitled_text_take_no_picture(cx: &mut TestAppContext) { + let (view, cx, [_, files], said) = saved(cx); + let note = files.path().join("note.txt"); + std::fs::write(¬e, "text").expect("a file"); + attach(&view, Attachment::Files(vec![note]), cx); + assert!(!files.path().join("assets").exists()); + assert_eq!(said.borrow().len(), 1, "other files are refused"); + view.update(cx, |view, _| view.folder = None); + attach( + &view, + Attachment::Picture(Image::from_bytes(ImageFormat::Png, vec![1])), + cx, + ); + assert_eq!(said.borrow().len(), 2, "untitled text is refused"); + assert_eq!( + current(&view, cx), + "a +" + ); + } + + #[gpui::test] + fn a_file_moved_while_copying_gets_no_link(cx: &mut TestAppContext) { + let (view, cx, [_, files], said) = saved(cx); + let document = view.read_with(cx, |view, _| view.document().clone()); + document.update(cx, |_, cx| { + cx.emit(Attachment::Picture(Image::from_bytes( + ImageFormat::Png, + vec![1], + ))) + }); + let elsewhere = files.path().join("elsewhere"); + view.update(cx, |view, cx| view.moved(elsewhere, cx)); + cx.run_until_parked(); + assert_eq!( + current(&view, cx), + "a +" + ); + assert_eq!(said.borrow().len(), 1, "the move is said"); + } + + #[test] + fn links_escape_their_address_and_alt() { + let folder = Path::new("/notes"); + let copy = folder.join("assets").join("a b[1].png"); + assert_eq!( + link(folder, ©), + "![a b\\[1\\]](assets/a%20b%5B1%5D.png)" + ); + } +} diff --git a/apps/ely-mark/src/engine.rs b/apps/ely-mark/src/engine.rs index c897105..89094e4 100644 --- a/apps/ely-mark/src/engine.rs +++ b/apps/ely-mark/src/engine.rs @@ -4,7 +4,7 @@ use std::{ convert::Infallible, hash::{DefaultHasher, Hash as _, Hasher as _}, io, - path::{Path, PathBuf}, + path::Path, sync::{Arc, Mutex}, }; @@ -36,12 +36,6 @@ impl Text { pub type Doc = Arc>; -/// A load: the file as read, and where it is. -pub struct Opened { - pub source: Source, - pub path: PathBuf, -} - /// The history mark: this text's length and hash. pub fn mark(text: &str) -> String { let mut hasher = DefaultHasher::new(); @@ -56,10 +50,10 @@ impl Engine for Mark { type Worker = (); type Doc = Doc; type View = MarkView; - type Loaded = Opened; + type Loaded = Source; type Import = Infallible; - fn load(_: &mut (), from: Load) -> io::Result { + fn load(_: &mut (), from: Load) -> io::Result { let path = match from { Load::File(path, _) => path, Load::Import(_, never) => match *never {}, @@ -69,30 +63,19 @@ impl Engine for Mark { io::ErrorKind::PermissionDenied => io::Error::other(error), _ => error, })?; - Ok(Opened { - source: Source::decode(bytes)?, - path: path.to_path_buf(), - }) + Source::decode(bytes) } fn open( _: &Arc>, - loaded: Opened, + loaded: Source, window: &mut Window, cx: &mut App, ) -> io::Result<(Doc, Entity, String)> { - let Opened { - source: loaded, - path, - } = loaded; - let folder = path - .parent() - .expect("a file sits in a folder") - .to_path_buf(); let mark = mark(&loaded.text); let doc = Arc::new(Mutex::new(Text::new(loaded))); let saved = mark.clone(); - let view = cx.new(|cx| MarkView::new(Arc::clone(&doc), saved, folder, window, cx)); + let view = cx.new(|cx| MarkView::new(Arc::clone(&doc), saved, window, cx)); Ok((doc, view, mark)) } diff --git a/apps/ely-mark/src/main.rs b/apps/ely-mark/src/main.rs index 0fc66cd..98cb866 100644 --- a/apps/ely-mark/src/main.rs +++ b/apps/ely-mark/src/main.rs @@ -1,6 +1,7 @@ // Release builds open no console on Windows. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod attach; mod autosave; mod compare; mod desk; diff --git a/apps/ely-mark/src/view.rs b/apps/ely-mark/src/view.rs index 190d1b8..9c34796 100644 --- a/apps/ely-mark/src/view.rs +++ b/apps/ely-mark/src/view.rs @@ -74,8 +74,8 @@ pub struct MarkView { /// The mark at the last load or save. saved: String, pub mode: Mode, - /// The file's folder: pictures resolve here. - pub(crate) folder: PathBuf, + /// A saved file's folder: pictures resolve and go here. + pub(crate) folder: Option, /// The mode first took input (PERF-07). pub(crate) shown: bool, pub focus: FocusHandle, @@ -85,13 +85,7 @@ impl EventEmitter for MarkView {} impl EventEmitter for MarkView {} impl MarkView { - pub fn new( - doc: Doc, - saved: String, - folder: PathBuf, - window: &mut Window, - cx: &mut Context, - ) -> Self { + pub fn new(doc: Doc, saved: String, window: &mut Window, cx: &mut Context) -> Self { let text = doc.lock().expect("the text lock").current.clone(); let mode = match text.len() > LARGEST_READ { true => Mode::Source, @@ -100,13 +94,14 @@ impl MarkView { let view = cx.weak_entity(); let document = cx.new(|cx| { let mut document = MarkdownDocument::new(text, mode.shown(), window, cx); - document.set_base(folder.as_path(), cx); document.on_link(move |url, _, cx| { view.update(cx, |view, cx| view.follow(url, cx)).ok(); }); document }); cx.subscribe_in(&document, window, Self::heard).detach(); + cx.subscribe(&document, |view, _, attached, cx| view.attach(attached, cx)) + .detach(); // The shell hears the load. cx.defer_in(window, |view, _, cx| { view.show_once(cx); @@ -125,7 +120,7 @@ impl MarkView { changed: 0, saved, mode, - folder, + folder: None, shown: false, focus, } @@ -251,14 +246,14 @@ impl MarkView { } } - /// The file moved; pictures follow it. + /// The saved file's folder; pictures follow it. pub fn moved(&mut self, folder: PathBuf, cx: &mut Context) { - if folder == self.folder { + if self.folder.as_ref() == Some(&folder) { return; } self.document .update(cx, |document, cx| document.set_base(folder.as_path(), cx)); - self.folder = folder; + self.folder = Some(folder); } fn heard( diff --git a/apps/ely-mark/src/view/tests.rs b/apps/ely-mark/src/view/tests.rs index 479ee25..9a6fb6b 100644 --- a/apps/ely-mark/src/view/tests.rs +++ b/apps/ely-mark/src/view/tests.rs @@ -18,9 +18,7 @@ fn open<'a>( let source = Source::decode(text.as_bytes().to_vec()).expect("reads"); let saved = mark(&source.text); let doc = Arc::new(Mutex::new(Text::new(source))); - let folder = std::env::temp_dir(); - let (view, cx) = - cx.add_window_view(move |window, cx| MarkView::new(doc, saved, folder, window, cx)); + let (view, cx) = cx.add_window_view(move |window, cx| MarkView::new(doc, saved, window, cx)); cx.update(|window, _| window.activate_window()); cx.run_until_parked(); (view, cx) @@ -166,8 +164,7 @@ fn the_shell_hears_a_load_it_subscribed_to(cx: &mut TestAppContext) { let source = Source::decode(b"# Opened\n".to_vec()).expect("reads"); let saved = mark(&source.text); let doc = Arc::new(Mutex::new(Text::new(source))); - let folder = std::env::temp_dir(); - let view = cx.new(|cx| MarkView::new(doc, saved, folder, window, cx)); + let view = cx.new(|cx| MarkView::new(doc, saved, window, cx)); // As the shell: subscribed after the view is made. let count = count.clone(); cx.subscribe(&view, move |_, _, _: &Reported, _| { diff --git a/crates/ely-files/src/lib.rs b/crates/ely-files/src/lib.rs index 6fe2537..6ef3d91 100644 --- a/crates/ely-files/src/lib.rs +++ b/crates/ely-files/src/lib.rs @@ -19,6 +19,7 @@ pub use task::RecoveryTask; pub use save::{ SaveError, Saved, atomic_capable, claim_all, export, import_attachment, same_file, save, + write_attachment, save_as, save_atomic, save_overwrite, save_with_backup, save_with_probe, }; pub use session::{Fingerprint, Open, SaveState, SaveTicket, Session, Sessions, StaleTicket}; diff --git a/crates/ely-files/src/save.rs b/crates/ely-files/src/save.rs index d027e81..1f576da 100644 --- a/crates/ely-files/src/save.rs +++ b/crates/ely-files/src/save.rs @@ -212,24 +212,38 @@ pub fn same_file(a: &Path, b: &Path) -> io::Result { /// Copies an attachment in; the source is never touched (FILE-07). pub fn import_attachment(source: &Path, dir: &Path) -> io::Result { - std::fs::create_dir_all(dir).map_err(|e| with_path(dir, e))?; let name = source.file_name().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, format!("{} has no file name", source.display()), ) })?; - let mut target = dir.join(name); - let stem = Path::new(name) - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); - let ext = Path::new(name) + let mut from = std::fs::File::open(source).map_err(|e| with_path(source, e))?; + let target = attach(dir, Path::new(name), |to| io::copy(&mut from, to).map(drop))?; + log::info!("files: imported {} to {}", source.display(), target.display()); + Ok(target) +} + +/// Writes `bytes` into `dir` as `name`, or a free variant. +pub fn write_attachment(name: &str, bytes: &[u8], dir: &Path) -> io::Result { + let target = attach(dir, Path::new(name), |to| to.write_all(bytes))?; + log::info!("files: wrote {} bytes to {}", bytes.len(), target.display()); + Ok(target) +} + +/// Claims a free name like `name`, then fills it. +fn attach( + dir: &Path, + name: &Path, + fill: impl FnOnce(&mut std::fs::File) -> io::Result<()>, +) -> io::Result { + std::fs::create_dir_all(dir).map_err(|e| with_path(dir, e))?; + let stem = name.file_stem().unwrap_or_default().to_string_lossy().into_owned(); + let ext = name .extension() .map(|ext| ext.to_string_lossy().into_owned()) .unwrap_or_default(); - let mut from = std::fs::File::open(source).map_err(|e| with_path(source, e))?; + let mut target = dir.join(name); let mut counter = 1; // Claims the name; another writer's file is never replaced. let mut to = loop { @@ -242,17 +256,12 @@ pub fn import_attachment(source: &Path, dir: &Path) -> io::Result { Err(e) => return Err(with_path(&target, e)), } }; - let copied = io::copy(&mut from, &mut to).and_then(|_| to.flush()); - if let Err(e) = copied { + let filled = fill(&mut to).and_then(|()| to.flush()); + if let Err(e) = filled { // A half copy is not left behind. std::fs::remove_file(&target).ok(); return Err(with_path(&target, e)); } - log::info!( - "files: imported {} to {}", - source.display(), - target.display() - ); Ok(target) } diff --git a/crates/ely-files/src/save/tests.rs b/crates/ely-files/src/save/tests.rs index 8669484..ea7d05a 100644 --- a/crates/ely-files/src/save/tests.rs +++ b/crates/ely-files/src/save/tests.rs @@ -180,6 +180,15 @@ fn an_import_copies_and_never_deletes() { assert_eq!(read(&second), "png-bytes"); } +#[test] +fn written_bytes_take_a_free_name() { + let dir = tempfile::tempdir().expect("a temp dir"); + let first = write_attachment("pasted.png", b"one", dir.path()).expect("the first write"); + let second = write_attachment("pasted.png", b"two", dir.path()).expect("the second write"); + assert_ne!(first, second); + assert_eq!((read(&first), read(&second)), ("one".into(), "two".into())); +} + #[test] fn a_numbered_import_name_fits_long_file_names() { let dir = tempfile::tempdir().expect("a temp dir"); diff --git a/scripts/fetch-references.sh b/scripts/fetch-references.sh index 3647035..17f3f37 100755 --- a/scripts/fetch-references.sh +++ b/scripts/fetch-references.sh @@ -36,7 +36,7 @@ fetch() { # name url rev [sparse paths...] echo "fetch $name $rev" } -fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git cd16b204f0ba56473b9ffb831bf2483bc232a6bb +fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git d126a7e7f7977dddae598486dd99aba79ce1f5f0 fetch zed https://github.com/zed-industries/zed.git 1a28cff4b409169bac058bca40dfbfeb7621d19b fetch libreoffice-core https://github.com/LibreOffice/core.git 8221e31b3ac356a1623c672912a3d2b492f7e3d1 \ include/LibreOfficeKit libreofficekit desktop/source/lib desktop/inc \