diff --git a/Cargo.lock b/Cargo.lock index 2aea062..67de381 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -246,7 +246,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -257,7 +257,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1782,7 +1782,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.3", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1966,7 +1966,7 @@ dependencies = [ [[package]] name = "ely-gpui-component" version = "0.1.1" -source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=3eb4d346fe88efe475daa88e0e042ca3680a386c#3eb4d346fe88efe475daa88e0e042ca3680a386c" +source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=bd014707fd47cd9d0651a9af8c1db6033718a6ea#bd014707fd47cd9d0651a9af8c1db6033718a6ea" dependencies = [ "alacritty_terminal", "anyhow", @@ -2188,6 +2188,7 @@ dependencies = [ name = "ely-tree" version = "0.1.3" dependencies = [ + "dunce", "ely-files", "ely-gpui-component", "ely-platform", @@ -2389,7 +2390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4498,7 +4499,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4663,7 +4664,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6258,7 +6259,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6851,7 +6852,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7197,7 +7198,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7532,7 +7533,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8239,7 +8240,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e086216..f7f34de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,8 @@ encoding_rs = "0.8" jiff = "0.2" anyhow = "1" directories = "6" -ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "3eb4d346fe88efe475daa88e0e042ca3680a386c" } +dunce = "1" +ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "bd014707fd47cd9d0651a9af8c1db6033718a6ea" } 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"] } diff --git a/agents.md b/agents.md index 4cd3bae..8573e59 100644 --- a/agents.md +++ b/agents.md @@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木 ## 组件映射 -依赖 `ely-gpui-component`(pin 3eb4d34,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 bd01470,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。 | 需要 | 用 | | --- | --- | @@ -191,7 +191,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)、组件库 3eb4d34(2026-10-11 起)。 +- 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)、组件库 bd01470(2026-10-11 起)。 - 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"`、effort xhigh,每条最多五轮;herdr 直接启动 codex 程序,不经 shell 别名。 @@ -228,7 +228,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;CEF 内容宿主随 ElyMark 先建(C14.0),邮件编号依次前移。 - 2026-10-06(用户定):`ely-office` 的外壳不再只服务 LOK:`Host` 带关联类型 `Engine`(`ely_office::Engine`:后台载入、视图、撤销历史的标记、写出、关闭,视图的落定、编辑计数、待落地、焦点与事件),`Tab` 与 `Core` 按引擎取文档、视图与 worker 类型。LOK 的实现在 `ely_office::lok`(`Lok`、`start`、只对 LOK 的 `Commands` 即 `run_active`/`run_in`;查找替换、面板、图表仍只限 LOK);MuPDF 的在 `apps/ely-pdf/src/engine.rs`(`Pdf`;worker 首次载入才起,`Mupdf::get`)。`Kind::macros` 为 `None` 表示该格式无宏,不扫文件。ElyPDF 每个标签一个 `PdfView` 实体(页面、图块、文字、选择、找到的词都在里面,自己绘制),`Viewer` 只管标签条、书签栏、查找框与状态栏;打开、密码、关闭询问与保存走外壳。外壳的密码框:对话框开着时后到的加锁文件排队(`Core::locked`),对话框关闭(含取消、提交)后下一个再问;每次询问一个身份(按输入框实体),连续询问的 Esc 各归各的;提交时对话框先关、再载入。ElyDoc 的加锁测试原先清的是应用自己的对话框字段,改为清外壳的。 - 2026-10-07:本地 Web 内容宿主(C14.0,§17.3)。`crates/ely-web-content`:CEF 154.0.32(Chromium 154.0.8037.58,cef-rs a712ad9,`default-features = false`),`scripts/fetch-cef.sh` 取 minimal 包到 `engines/cef`,`.cargo/config.toml` 的 `CEF_PATH` 让构建用它。进程:应用的 `main` 先调 `ely_web_content::subprocess()`(参数里有 `--type=` 即为 CEF 子进程,载入框架后 `execute_process`,不碰 GPUI);开发构建子进程即应用本身的二进制;macOS 包里是五个辅助应用(`<二进制名> Helper`、`(GPU)`、`(Renderer)`、`(Plugin)`、`(Alerts)`,`Contents/Frameworks`,`LSUIElement`,可执行文件为 `ely-web-helper`),框架在 `Contents/Frameworks`,`packaging/macos/bundle.sh` 对依赖 ely-web-content 的应用自动放入并先签内层;查找次序 `ELY_CEF`、包内、辅助应用所在、构建时的 `engines/cef`。启动:`start` 在 GPUI 里只做准备(载入框架、给 NSApp 的类补上 Chromium 要的 `isHandlingSendEvent`、`setHandlingSendEvent:` 与包住 `sendEvent:`,并加 `CrAppProtocol`、`CrAppControlProtocol`),`initialize` 与建浏览器都在消息泵任务里、GPUI 的更新之外进行(实测:在更新里同步调 CEF,CEF 转动 AppKit 的循环,GPUI 报 RefCell 已借用、整窗不画);浏览器在泵里同步建(`browser_host_create_browser_sync`;原为异步,建好之前视图已释放时浏览器无人关闭),视图要么在建之前已走(不建),要么有浏览器可关。凡在 GPUI 里对 CEF 的调用(输入、焦点、尺寸、脚本、缩放、重新载入、关闭)都排进主线程的队列,由泵在下一轮、更新之外依次执行(实测:在实体释放时同步关浏览器,CEF 转动 AppKit 的循环去校验菜单,GPUI 的菜单回调遇已借用的应用状态而中止,约三成复现)。泵:外部消息泵,按 `on_schedule_message_pump_work` 的时间,另照 cefclient 每轮后以 1/30 秒兜底(实测:只按请求时第一轮后 CEF 再不请求,页面不载入);空闲时 CPU 约 1.5%。离屏渲染须用 Alloy 风格(`runtime_style`;Chrome 风格不出画面)。同一缓存目录只允许一个实例,第二个启动时报出占用它的进程号。画面:`on_paint` 的 BGRA 直接成 GPUI 图像,上一帧在下一次绘制时从图集释放(`drop_image`);光标随页面。输入:鼠标点击、移动、滚轮(行按 40 点),键照 cefclient 的 macOS 做法:键盘按下带字符与系统键码发 `KEYDOWN` 再发 `CHAR`(退格、回车、方向等用 AppKit 的字符),可打出字的键等 GPUI 的输入法回调后以所打的字发同样两个事件,多字或组字经 `ime_commit_text`/`ime_set_composition`(无替换范围时用 CEF 的“无范围”,两端为最大值;传空指针 CEF 不收);松开带同一字符(不带时 Chromium 当作又按一次,实测退格删两个字);拷贝、剪切、粘贴、全选走框架命令。焦点随 GPUI 的焦点给页面。页面与应用之间:CEF 的消息路由,页面调 `window.elyQuery({request, onSuccess, onFailure})`(`onSuccess` 与 `onFailure` 都必须有,缺一路由即不发;页面载入时路由未就绪,早到的查询丢失),应用收到 `Event::Query(请求, 回复)`;应用以 `run_js` 调页面。主题:每次载入与主题变化时把调色板、代码色、字体与字号比例、深浅写成 `:root` 的 `--ely-*` 变量与 `color-scheme`。缩放 `set_zoom(倍数)`。页面只许载入所在内容文件夹里的文件:按真实路径判定(`..`、百分号转义、指向外面的符号链接都算外面,文件须存在),导航与每个资源请求(脚本、图片、样式、请求等)都过这道门,资源另许 `data:` 与本页的 `blob:`,其余一律取消;另在每个 CEF 进程以 `--proxy-server=127.0.0.1:9` 与 `--proxy-bypass-list=<-loopback>` 把一切网络(含本机回环与 WebSocket,后者不经资源回调)送往不通的代理;WebRTC 走 UDP 不经代理,故另以 `--force-webrtc-ip-handling-policy=disable_non_proxied_udp` 只许它经代理,并在每个页面上下文建立时、页面脚本之前删去 `RTCPeerConnection` 等构造(渲染进程的 `on_context_created`);页面的地址由真实路径逐字节转义生成(文件名含 `#`、`?` 照样载入);用户点出的链接以 `Event::Leave` 交给应用;`WebView::open_inert` 开的页面不运行脚本(`BrowserSettings.javascript` 关),ElyMark 的打印与导出 PDF 暂存页用它,文档里的 HTML 脚本不运行(M-15);页面的警告与错误只记行号(警告文字与来源都可由文档伪造,不进日志);页面自己的诊断经 `elyQuery` 交应用记录。载入失败(主框架,停止不算)、渲染进程退出、CEF 起不来都报 `Event::Failed`,页面区改显示组件库的错误视图与“重新载入”(浏览器在即重载,否则重建;CEF 起不来时只说明),载入成功才报 `Event::Loaded`。浏览器关闭时断开它与表面的互相持有,页面的内存随之释放(释放时写日志)。真窗口(macOS,开发构建与 `dist/ElyMark.app`):页面以应用深色主题画出,点选、键入中英文、回车、退格、方向键、拷贝粘贴、`elyQuery` 往返;`tests/scripts/webview.sh` 以输入法回调组“你好”、键入、读回焦点、主题变量与缩放 1.25,核对页面跳不出所在文件夹、外面的脚本不执行、本机 HTTP 收不到请求、`#` 名的页面载入、载入前被删的页面报失败、开关 20 个页面、5 个在建浏览器之前丢弃、10 个在打开后 0 至 27 毫秒内丢弃,都在保持前释放内存、杀掉渲染进程后页面区不再显示旧画面、经辅助功能按下“Reload”后页面重新画出(截窗口量标记色),WebRTC 不存在且本机 UDP 收不到 STUN,并截窗口量页面画出的标记色(去掉绘制、去掉断开持有、去掉代理或不显示故障时该脚本都失败);需屏幕录制与辅助功能权限,缺即报失败。未验证:Windows 与 Linux;辅助技术(见缺口);树莓派(R-06)。 -- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1);状态栏显示所用 Git 的路径与版本。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。 +- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1)。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。 - 2026-10-07:ElyTree 储藏、远程、工作树、子模块、LFS(C13.5,TREE-10 至 12,§13.3)。左栏第四页“仓库”:上方取回(`fetch --progress --all`)、拉取(`pull --progress`,照用户的 pull 设置)、推送、强制推送…;储藏(`stash list -z`,新的在前;“储藏改动…”问说明与是否包括未跟踪的文件,`stash push`),远程(名称与地址,`remote` 加 `config --get-regexp` 读出取回与推送地址),工作树(`worktree list --porcelain -z`,行名为文件夹名,有失效的可“清理”即 `worktree prune`),子模块(索引里的 gitlink 与文件夹自身 `.git` 的 HEAD:未检出、在记录的提交上、在别的提交上、冲突),LFS 文件(`git lfs ls-files --long --json`:工作树里是内容、对象在本机而工作树是指针、只有指针而对象不在本机);没有子模块或 LFS 文件时不列。各列表分别读出,一个读不出只在该处显示 Git 原话;切到此页时读,此后每次状态重读时重读,只取最新一次。选中一项,中间显示:储藏为提交详情的同一套(`hist::Picked`,历史与储藏共用读取与面板):改动的文件(对第一个父提交)与储藏的未跟踪文件(第三个父提交,各自对空树),选文件看差异,上方应用、弹出、删除…;远程为地址与推送地址,取回、修改…(改名 `remote rename`、改地址 `remote set-url`)、移除…(先问);工作树为路径与分支或提交、锁定与失效原因,打开(作为标签)、移除…(先问,主工作树与当前的不给);子模块为记录的与检出的提交,“检出记录的提交”(`submodule update --init`,经远程运行)与打开。用户输入的名称、地址、路径前一律加 `--`(推送的远程名同样)。表单与选择对话框(新建分支、标签、改名、身份、储藏、远程、工作树、选远程与上游)记下打开时的仓库,提交时写进那个仓库,不随当前标签变;储藏列表按每条三个字段读(说明可为空)。储藏的应用、弹出、删除按 `stash@{n}` 执行,执行前在写队列里核对它仍是所见的那个储藏(另有储藏插入即拒绝,什么也不做);弹出遇冲突时 Git 保留储藏,对话框显示 Git 原话。删除储藏、移除远程与工作树每次都问。分支页的本地分支多出“设置上游…”(列出远程分支,`branch --set-upstream-to=refs/remotes/名`)与“取消上游”。推送:有上游即 `push --progress`;没有上游时后台读出远程,问推到哪个远程(`push --set-upstream -- 远程 refs/heads/分支:refs/heads/分支`),没有远程或 HEAD 游离即说明。强制推送(§13.3):后台读出当前分支、上游的远程与远端分支、上次取回时远端的提交(租约)、此刻 HEAD 的提交(源)与远端有而源没有的提交,对话框写明远程、分支与将被覆盖的提交(或写明没有),确认后推送的就是所示的源提交(`push --force-with-lease=远端分支:租约提交 -- 远程 源提交:远端分支`),确认前本地分支再动也不改变推送内容;远端自上次取回后变了即被拒绝;没有上游或游离即说明不能。远程命令(取回、拉取、推送、子模块更新)是写队列里的一种(`Write::Remote`),经 `running::Running` 运行:Git 在自己的会话里(`setsid`,SSH 找不到终端,不会挂在终端提示上;凭据照用 credential helper 与 ssh-agent),进度逐行显示在中间上方的提示条,可停止(向整个进程组发 SIGTERM,Git 自行清理;已结束的不再发);用户停止时提示“已停止”、清掉其后的排队写,不当作失败;失败时对话框显示 Git 的 stdout 与 stderr(以回车结尾的进度行被下一行改写,只留最后一次;以换行结尾的行全部保留,钩子的每一行都在);拉取遇冲突照合并类命令判定为停下。关窗与退出时停下所有远程命令与克隆。克隆改用同一运行器。真窗口(macOS):仓库页五类列表、储藏详情与文件、工作树详情、分支行。未验证:Windows 与 Linux(停止远程命令在非 Unix 上报未构建);真实网络与 HTTPS、SSH 凭据;LFS 对象的下载(只显示状态)。 - 2026-10-07(用户定):跟随组件库最新的 main。组件库 K01 把 gpui 从 zed 仓库的提交换成 crates.io 的 `gpui-pre` =0.3.8(`gpui-pre-platform` 同版本),K02 让 Tab 绑在 FocusScope 的 ElyFocus 上下文下、更深的上下文先得;本项目工作区的 `gpui`、`gpui_platform` 随之改为这两个包(以 `package =` 保留原名,源码不变),组件库 pin 到 dabc1eb(其上加本项目一个提交:树与树行的无障碍角色)。升级后全工作区编译无改动。 - 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据、CEF 与页面的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);CEF 的 LICENSE 与 Chromium 的 CREDITS、页面的第三方通知;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。正式签名(hardened runtime)时 CEF 的渲染与 GPU 辅助程序带 `allow-jit`,插件辅助程序带 `allow-unsigned-executable-memory` 与 `disable-library-validation`(`packaging/macos/entitlements`;原先不带,V8 保留不到代码区,页面载入失败);`tests/scripts/hardened-macos.sh` 以 `ELY_SIGN_IDENTITY` 签 ElyMark 并核对页面画出。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。 @@ -260,7 +260,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-07:ElyTree 提交、历史、Blame(C13.3,TREE-05、06)。左栏分“改动”与“历史”两页(`navigation::Tabs`)。提交:改动页下方是 `git::CommitInput`(摘要与说明两个框,摘要满 50 变黄、72 变红),上面一行是 Git 设置里的身份(每次状态重读与窗口回到前台时重读,只取最新一次;`user.name `,未设即写明,点它开身份对话框:姓名、邮箱(两个输入框以其标签为无障碍名),可勾“用于所有仓库”写 `--global`,否则写本仓库;空着即提示不写)与签名(`commit.gpgsign` 为真时写明以 `gpg.format` 签名,默认 openpgp),签名照用户自己的 Git 与工具,Ely 不另设。提交框按仓库各存一份(换标签时存下当前的、换上另一个的:摘要、说明、修改最近提交与其草稿),提交为 `commit --cleanup=whitespace -F -`(信息经标准输入,`#` 开头的行照留,用户的 `commit.cleanup` 不删它们,不经 shell),“修改最近一次提交”勾上即显示为勾选、读出上次的信息填入(读回前不能提交;先存下已键入的,取消勾选时放回;每次勾选或取消、换仓库都使在途的读取作废,读回时须仍是发起的仓库与那一次),提交成功后只在提交框仍是所提交的内容时清空(提交期间又键入的留着,别的仓库的提交框不动);钩子或签名失败时以对话框显示 Git 的全部原话,不只说失败。提交同其他写操作排队。历史:`log --topo-order --exclude=refs/stash --all`,每页 2000 个,滚到距末尾 20 行内读下一页;每次状态重读后比对 `show-ref --head`,引用变了(含外部提交、切分支、取回)即从头重读;读取期间来的比对记下,读完再比;引用读不出时历史栏显示 Git 的原话,之后读通即重读并清掉;提交图由 `git::lanes` 按已读的全部提交算出;时间按本机时区写到分钟(不用相对时间,长列表不必随时刷新)。搜索:在“提交信息”(`--grep`)、“作者”(`--author`)、“改动的文字”(`-S`)中按字面、不分大小写找,或按提交号(`rev-parse --verify`,前缀可);搜索时不画图。选中一个提交:中间上方是摘要、说明、作者与提交者(不同时才列)、完整提交号、父提交(可点,转到父提交)、签名状态(`%G?` 按字母译出,带签名者)、引用;下方左列是相对第一个父提交改动的文件(`diff --name-status -M` 与 `--numstat`,根提交对空树,空树由 `hash-object -t tree` 算,不写死 SHA-1),右边是所选文件的差异(与改动区同一读法:Git 自己的差异,图片、二进制、非 UTF-8 同样处理,无块的纯重命名显示为无改动)。改动区差异与历史页所选文件的标题栏都有“文件历史”与“逐行追溯”(改动区限已跟踪的文件):文件历史为 `log --follow --numstat -z`,跨重命名,每个提交带当时的路径与行数,选中即显示该提交里这个文件的差异(状态按两侧是否存在判定);逐行追溯为工作树文件的 `blame --porcelain`,未提交的行写“尚未提交”,每段首行可按,转到历史页的该提交;条色按提交时间新旧。读取都在后台,按发起时的仓库、提交与文件核对;文件历史与逐行追溯每次打开有窗口内唯一的序号,迟到的结果即弃。提交详情的作者、提交者、提交号、签名、引用、摘要与说明在 AX 树里有文字。真窗口(macOS)经 AX:历史页、提交图、引用、提交详情、文件列表与差异。未验证:Windows 与 Linux;GPG 与 SSH 实际签名(只读设置,签名由用户的 Git 完成)。 - 2026-10-07:ElyTree 差异与暂存(C13.2,TREE-03、04)。差异一律取 Git 自己的输出(`-c diff.suppressBlankEmpty=false diff --no-color --no-ext-diff --no-textconv -U3`,前缀写明 `a/`、`b/`,覆盖用户的 noprefix 等设置;空白的上下文行照样带前导空格):已暂存为 `--cached -M`(HEAD 对索引,即提交前的实际索引内容),改动为索引对工作树,未跟踪为 `--no-index` 对 `/dev/null`(退出码 1 即有差异);Git 的清理过滤(换行转换等)由它自己做,按块、按行暂存的补丁对得上索引。块之间未改动的行从旧侧的 blob 取(`cat-file blob`,已暂存为 `HEAD:旧路径`,改动为 `:路径`),可展开;行号按块头推算,旧文件比差异短即报错。改动的词按每对删去与加入的行标出(Unicode 词)。空白:精确、忽略空白变化(`-b`)、忽略全部空白(`-w`);行内与并排。按块或按行:只在精确比较、且是普通内容改动时(无新建、删除、重命名、复制、权限变化的头),已暂存区为取消暂存(`apply --cached --reverse`),改动区为暂存(`apply --cached`);未跟踪、重命名等整份暂存,界面写明。补丁由所选行切出:暂存时未选的删去行留作上下文、未选的加入行不写;取消暂存时反之;块头按“范围前的行数”换算,空范围照 Git 的写法;“无换行结尾”按切出后两侧的实际行尾:无换行的行在哪一侧后面还有行,那一侧就带上换行(所选的删去或加入行同样),两侧不同的上下文行拆成一删一加;`apply` 带 `--whitespace=nowarn`,用户的 `apply.whitespace` 不改所选的字节。写入前在后台重读同一条差异,与所选时读到的字节不同即拒绝(“文件在显示后改了,未暂存”),索引不动;同一仓库的写仍排队。所选行按住 Shift 取同一块内的范围;差异一变(任何重读得到不同的输出)所选即清空;每次读取有窗口内唯一的序号,换文件后旧文件迟到的读取即弃。图片(png、jpg、gif、webp、bmp、tiff、ico)以 `BeforeAfter` 左右拖动对比,一侧缺失时只显示另一侧;其他二进制文件,以及两侧任一侧不全是 UTF-8 的文件(差异以外的字节也算),显示两侧大小,整份暂存;文件与符号链接互换时 Git 为同一路径打出两段(删去与新增),两段都显示(不折叠:两段没有共同的旧文),整份暂存;工作树侧照 Git 读:符号链接读其自身的路径,不跟随;未跟踪的文件夹与冲突文件(冲突在 C13.4 处理)只说明。单击文件即在中间显示其差异(已忽略的文件以系统程序打开),标题栏另有“打开文件”。外部改动后状态重读,正显示的差异随之重读;文件不再列在原分区即收起。真窗口(macOS):改动区选两行暂存,索引恰为所选,已暂存区显示实际索引内容,并排视图;AX 树中各行、块按钮与分段可按。 - 2026-10-07(用户定):ElyTree 的 Git。随包自建钉住的 Git v2.56.0(a018953)加 git-lfs v3.8.0(各平台散列钉在 `scripts/fetch-git-lfs.sh`),`scripts/build-git.sh` 以 RUNTIME_PREFIX 构建到 `engines/git`,LFS 放在 `libexec/git-core`。查找次序:`ELY_GIT`(系统 Git,低于 2.40 即拒绝),否则应用旁或 `Resources/git`,否则本构建的 `engines/git`;没有即报错,不退回别的 Git。每次运行都是参数数组,带 `GIT_OPTIONAL_LOCKS=0`(读取不抢索引锁)、`GIT_TERMINAL_PROMPT=0`(从不在终端问凭据)、`GIT_LITERAL_PATHSPECS=1`(路径不当通配)。 -- 2026-10-07:ElyTree 仓库与工作区(C13.1,TREE-01、02)。窗口经 `ely_ui::tool_root`:“文件”只有新建(选文件夹 `git init`)与打开(可多选文件夹),`commands::menus` 改收各应用自己的文件命令(文档应用照旧六项),保存、另存、导出、打印的动作在工具窗口里不挂;“仓库”菜单:克隆…、关闭仓库(Cmd+W)。每个仓库一个标签,同一工作树(按 `--show-toplevel` 的真实路径)只开一个,再开即切过去;裸仓库拒绝。最近列表在数据目录的 `recent.json`(新的在前,留 20 个),读不出即报错且不覆盖;最近项打不开即移出并说明。克隆:先问地址,再在最近仓库旁问新文件夹(名取地址末段去 `.git`),文件夹非空即拒绝;Git 的进度逐行显示在正中(回车与换行都断行,读到即显示),可停止(SIGTERM,Git 自己删去半成品;已结束的进程不再发信号,免误伤重用的进程号);关窗或退出时停下进行中的克隆。状态读 `status --porcelain=v2 -z --branch --untracked-files=normal --ignored`,未知字母即报错;行数取 `diff --numstat -z`(已暂存与未暂存各一次,二进制不计),未跟踪、冲突、忽略的不计行数。左栏 `git::ChangesList` 分冲突、已暂存、改动、未跟踪、已忽略;单击文件以系统程序打开;暂存(`add --all`)、取消暂存(有提交时 `restore --staged`,首个提交前 `rm --cached -f`:索引与文件不同也照取消,文件不动)、全部暂存(`add --update`)、全部取消(`reset -q`);取消暂存重命名连旧路径,暂存只用新路径(已暂存的重命名再改时旧路径已不在);放弃改动每次都问(已跟踪的 `restore --worktree`,未跟踪的 `clean -f -d` 并写明会删除)。同一仓库的写操作排队、一次一条,失败即清掉其后的排队并报出 Git 原话,关闭标签要等排队写完。外部改动:`notify` 监视工作树、Git 目录与共用目录(`--git-common-dir`;链接的 worktree 的分支与引用在主仓库里),250 毫秒内的一串改动合成一次重读,读取中再来的合成一次;空闲不读。状态栏:分支(游离显示短提交号,无提交写明)、上游与领先落后(上游引用已删即写明不存在,不显示为 0)、真实路径、Git 版本、随附或系统与所用 Git 的路径。中间区在 C13.2 前只显示改动数或“没有要提交的改动”。组件库 4f8f3bd 与 37c5187:`Changed::lines` 可缺。真窗口(macOS):五个分区、行数、状态栏。未验证:Windows 与 Linux(停止克隆在非 Unix 上报未构建);LFS 仓库的克隆。 +- 2026-10-07:ElyTree 仓库与工作区(C13.1,TREE-01、02)。窗口经 `ely_ui::tool_root`:“文件”只有新建(选文件夹 `git init`)与打开(可多选文件夹),`commands::menus` 改收各应用自己的文件命令(文档应用照旧六项),保存、另存、导出、打印的动作在工具窗口里不挂;“仓库”菜单:克隆…、关闭仓库(Cmd+W)。每个仓库一个标签,同一工作树(按 `--show-toplevel` 的真实路径)只开一个,再开即切过去;裸仓库拒绝。最近列表在数据目录的 `recent.json`(新的在前,留 20 个),读不出即报错且不覆盖;最近项打不开即移出并说明。克隆:先问地址,再在最近仓库旁问新文件夹(名取地址末段去 `.git`),文件夹非空即拒绝;Git 的进度逐行显示在正中(回车与换行都断行,读到即显示),可停止(SIGTERM,Git 自己删去半成品;已结束的进程不再发信号,免误伤重用的进程号);关窗或退出时停下进行中的克隆。状态读 `status --porcelain=v2 -z --branch --untracked-files=normal --ignored`,未知字母即报错;行数取 `diff --numstat -z`(已暂存与未暂存各一次,二进制不计),未跟踪、冲突、忽略的不计行数。左栏 `git::ChangesList` 分冲突、已暂存、改动、未跟踪、已忽略;单击文件以系统程序打开;暂存(`add --all`)、取消暂存(有提交时 `restore --staged`,首个提交前 `rm --cached -f`:索引与文件不同也照取消,文件不动)、全部暂存(`add --update`)、全部取消(`reset -q`);取消暂存重命名连旧路径,暂存只用新路径(已暂存的重命名再改时旧路径已不在);放弃改动每次都问(已跟踪的 `restore --worktree`,未跟踪的 `clean -f -d` 并写明会删除)。同一仓库的写操作排队、一次一条,失败即清掉其后的排队并报出 Git 原话,关闭标签要等排队写完。外部改动:`notify` 监视工作树、Git 目录与共用目录(`--git-common-dir`;链接的 worktree 的分支与引用在主仓库里),250 毫秒内的一串改动合成一次重读,读取中再来的合成一次;空闲不读。状态栏:分支(游离显示短提交号,无提交写明)、上游与领先落后(上游引用已删即写明不存在,不显示为 0)、真实路径(所用 Git 的版本与路径只记日志,2026-10-11 用户定从状态栏去掉)。中间区在 C13.2 前只显示改动数或“没有要提交的改动”。组件库 4f8f3bd 与 37c5187:`Changed::lines` 可缺。真窗口(macOS):五个分区、行数、状态栏。未验证:Windows 与 Linux(停止克隆在非 Unix 上报未构建);LFS 仓库的克隆。 - 2026-10-07:ElyPDF 输出与文件恢复(C11.8,PDF-13、14,QA-13)。“文件›导出…”(Cmd+E)一个对话框三种:页面图片(PNG,72、150、300 dpi,全部或所选页,总是浅色,选文件夹,`名称-序号.png` 按页数补零,先以 `ely_files::claim_all` 占下全部目标,任一失败即删去本次全部);文字(UTF-8,各页以换页符分开,MuPDF 的 stext);优化的 PDF(可看可改的设置:压缩数据流、字体与图片;图片高于某 dpi 降到某 dpi,双三次重采样到确切目标,重压缩:无损的仍无损、有损的保持原格式,默认 300 降到 150;降到的 dpi 须低于阈值,否则不可导出;完成提示写明原文件与新文件大小)。导出图片与文字要“复制”许可,优化副本要“修改”许可。“打印…”(Cmd+P)把文档此刻写成副本(要“打印”许可,worker 的 `PRINT_COPY`)再交系统打印面板(`ely_office::print_later`)。文件恢复:MuPDF 打开时修复了文件结构(`pdf_was_repaired`),worker 即把修复后的文档整份写成临时副本、改开这份副本(日志与快照从此正常;原先修复过的文件不能保存),关闭文档时删去;打开时 MuPDF 的警告记下作修复报告(`ely_open` 期间接管警告回调,照样转给原回调)。外壳的 `Engine::signed` 改为 `Engine::risk`(宏由外壳判,签名、修复由引擎答),加 `Risk::Repaired`:提示条说明打开的是修复的副本、“另存为”写新文件、原件不动,带“报告…”按钮显示 MuPDF 的原话;保存一律另存,另存到原件被拒;签名或修复的文件另存成功后标签记下“待重开”,到内存与文件一致(已保存、无待落地编辑、不在导出)时才按新文件重开;另存期间又有编辑则留着编辑,下一次保存后再重开。MuPDF 有时读到坏对象才修复(尾部完好、某对象偏移坏):打开与解锁后先把每个对象读一遍,并解码图片以外的每个数据流(`ely_scan`,读不出的对象记入报告);MuPDF 修复了结构或读的过程中有任何警告(如内容流解压失败),都当作损坏文件,改开整份写出的副本、报告列出警告、保存一律另存。图片(用户定)在画出时检查:画图块或缩略图时 MuPDF 有警告即为损坏,报告补上警告,标签转为修复风险(保存一律另存);没画过的图片在原位保存(覆盖文件)之前统一解码检查(外壳的 `Engine::unsound`,有问题即转为另存为)。全部解码放在打开时的代价实测:100 张 2000×2000 的 JPEG(281 MB)打开到首块 2.6 秒(release,本机)。导出图片与文字期间 MuPDF 有警告即报错不写(读不全的内容不当作导出)。打印副本不带加密(已核过打印许可;否则系统打印面板打不开加了打开密码的副本),`print_later` 在面板关闭后删去它。导出对话框的降采样数值随键入生效(不等回车或失焦),直接点导出用的是所见的数。补丁 `0005` 见上(保存打不开输出时的保存中标志)。QA-13:worker 改字、加并填文本字段、移页后保存,以 PDFKit(另一阅读器)读回:页序、新字取代旧字、字段值都对(`tests/independent.rs`,只在 macOS);图片编辑在 PDFKit 里未单独核对(PDFKit 无图片清单接口)。组件库 a6b1bb0 的 `init` 改为返回错误(缺资源时),启动时出错即以 `startup::fail` 报出。真窗口(macOS):损坏文件的提示条与“修复的副本”状态、导出对话框。未验证:Windows 与 Linux 的打印(打印面板只在 macOS 实现);真窗口里实际打印。 - 2026-10-06:ElyPDF 密码与签名(C11.7,PDF-11、12,QA-15,§11.4)。权限:worker 记下每个文档的访问(MuPDF 的认证结果,4 为所有者;未加密即全权),`PERMISSIONS` 按 Ely 的位报出(打印、复制、修改、批注、填表、页面与书签、所有者),每个改动请求先按表核对(`secure::needs`:页面增删移转与书签要“页面与书签”或“修改”,内容、链接、属性、附件、OCR 层、删除与清理要“修改”,批注要“批注”,建改字段要修改加批注,填表要填表或批注,提取要复制,改保护要所有者),不许即以 `restricted:名` 报错,客户端转成 `PermissionDenied`,界面按名说明并提示输入所有者密码;复制文字在应用里按位拦(读文字本身不拦)。“文档”菜单“输入所有者密码…”:对时解除限制;错时 MuPDF 已丢了原密钥(RC4 与 AES-128 实测文字读不出),worker 以原密码重新认证。“密码与权限…”只在所有者权限下:写新文件(AES-256,`ely_protect` 经 `ely_save_copy` 带写出选项),打开密码可空,所有者密码必填且须与打开密码不同,六项许可勾选;或不保护(去掉加密)。只凭用户密码打开的文件改保护即拒绝。签名:MuPDF 以 `HAVE_LIBCRYPTO` 构建,worker 另链 `libmupdf-pkcs7.a` 与 OpenSSL 的静态 `libcrypto.a`(pkg-config 给目录)。`SIGNATURES` 按表单字段树列出全部签名字段(不画在页面上的也列,页与框可无;控件无 /P 时从各页 /Annots 找页):已签的给出摘要核对(`pdf_check_widget_digest`:未改、已改、读不出)、其后有无更新与更新是否越出签名所许(`pdf_signature_incremental_change_since_signing`、`pdf_validate_signature`)、签名者(MuPDF 的识别名与通用名)、签名者自称的时间、原因、地点,以及签名里的证书(签名者在前,DER);单个签名读不出只报该项的原因。证书信任由应用交系统判定(`ely_platform::trust`:macOS 用 `SecTrust`、`SecPolicy::create_x509`、禁网,故从不核验吊销;Windows 与 Linux 未构建,报“此平台未构建”)。界面“文档›签名…”对话框逐个列出完整性、证书、吊销(一律“未核验吊销状态”)、时间(带时区)、原因、地点与所在页,不给笼统的“有效”。新签:“以证书签名…”工具,拖出框或点空的签名字段,对话框选 .p12/.pfx、填证书密码、原因、地点,另存:快照副本上建字段(名 `SignatureN` 避开表单已有名)并签,以增量更新写出(旧签名覆盖的字节不动),写完重开核对新签名的摘要,不过即报错删文件;原文档不动;发起时绑定视图版本与所选位置。补丁 `patches/mupdf/0004-signature-reason-and-location.patch`:原因与地点写进签名字典(MuPDF 原只画进外观;签后再改会落到第二个更新段,字节范围算成负数)。外观只画标签、名字与时间(不画空的识别名各栏,不画 MuPDF 标志)。已签文件(`ely_signed` 数表单里有值的签名字段)打开为 `Risk::Signed`:上方提示条说明修改会使签名失效、“另存为”写出未签名副本,带“签名…”按钮;保存不覆盖原文件。外壳的 `risk` 由布尔改为 `Option`(宏或签名),`Engine::signed` 由引擎回答。Ely 一切整份写出(保存、另存、提取、删除、清理、保护)在 `ely_save_copy` 里去掉签名值与其外观(字段留下、空着,去掉 AppendOnly 标志):整份重写本就使签名失效,不留假签名。签名核对的是文件,不是编辑中的文档:`SIGNATURES` 带路径,worker 以文档的密码另开该文件读出再关闭;签名列表按标签当前的文件路径读、按路径缓存,保存后重读。已签文件另存为成功后,标签按新文件重新打开(外壳的 `reload`,同冲突时;撤销历史随之重新开始),打开着的文档、签名列表与以后的签名都对应这份未签名的副本;另存失败时标签与签名都仍是原文件。签名对话框每次打开都重读文件,磁盘上被改动即显示已改。去掉签名值时一并去掉该签名字段自身的只读位(签名时由它的锁设下),以便再签;它锁住的其他字段的只读位留着(分不出原本是否只读,已知限制)。补丁 `patches/mupdf/0005-failed-save-clears-save-in-progress.patch`:MuPDF 的 `prepare_for_save` 置起保存中标志、只有写出过程清除它;打不开输出文件时标志留着,此后一切改动绕过日志,编辑没有撤销步(实测:存到不存在的目录后旋转,历史标记仍为 0);补丁让打开输出或预排签名失败时清除它。可视签名(用户定三种来源):“添加可视签名…”对话框,手写(组件库 `SignaturePad`,笔画按较长边归一)、键入(姓名加字体:手写体为随包的 Great Vibes,OFL,`crates/ely-pdf-engine/fonts`,取自 google/fonts c1eda92,嵌入 worker 二进制,以 CID 字体写入;或已有的拉丁与中日韩字体;缺字即报出是哪个字)、图片(按原比例);随后单击放下(宽 160 点)或拖框(按比例放进框内),各为一步:手写成墨迹批注,键入成文字,图片成图片内容;对话框写明它不是数字签名。真窗口(macOS):已签文件打开有提示条,状态为已签名,签名对话框三项分列;可视签名对话框。未验证:Windows 与 Linux 的证书信任;时间戳与吊销;带图片外观的数字签名。 - 2026-10-06:ElyPDF 内容删除与文档清理(C11.6,PDF-09、10,§11.4,QA-14)。标记只在视图里(“标记文字以删除”按所选文字每行一框,“标记区域以删除”拖框,单击已有标记取消;预览为页面上的深色框;任何编辑落地即清空,页码可能已变),文档本身不动。“删除标记的内容…”与“清理文档…”都写新文件:先把文档快照写出、重开副本(`ely_save_copy`,提取与保存同用),在副本上改,再以 `garbage=2` 整份写出(不增量,旧修订不留),写到工作目录、核对通过后经 `ely_files::export` 交到所选位置,工作副本普通删除(对话框写明:原件、旧备份、同步历史与已发出的副本仍可能含有被删内容;不是磁盘级擦除)。删除:先删去碰到区域的批注与字段(MuPDF 的删除只管链接与文本框批注,其他批注的内容、字段的值会留下),字段失去最后一个控件即连同空了的上级一起从表单去掉(否则值留在字段树里);全部区域的删除做完后才建 Redact 批注(否则后一区域会删掉前一区域的标记)并 `pdf_redact_page`(黑框;图片在区域内的像素由 MuPDF 改成白色;碰到区域的线条整条删去,只删被盖满的会留下可复原的路径;碰到的文字删去,含隐藏文字)。有区域的页再过一遍内容(含表单):标记内容的 /ActualText、/Alt、/E 一律去掉,结构树里属于该页的元素(自身或继承的 `Pg`,或子项 MCR、OBJR 的 `Pg` 指向该页,连同其上级)去掉这三项与 /T;整页去掉而不只区域内的,宁多勿漏。页面缩略图 /Thumb 删去(它可引用原图),核对时区域所在页仍有缩略图即失败。写出后重开核对:区域内的字(与删除同一判法:字框各边缩一成后相交)为零;区域内画出来不是黑色的像素为零;逐页以设备重放,落进区域的路径、渐变、图像蒙版除我们的黑框外为零,图片单独画出在区域内只剩白或黑;文件只有一个版本;任一不过即报错并删去输出。确认时绑定标记与文档版本,选完路径后标记或版本变了即不写。清理:先扫出数量(属性与元数据条数、批注、附件含批注里的文件、有隐藏文字的页、脚本入口),勾选后写出:删 Info;其余按一遍遍历全部对象及其中的直接字典与数组:/Metadata 与 /PieceInfo,文件说明的 /EF 与 /RF 及关联文件 /AF,脚本;删批注(链接与字段不算批注,保留),删附件(名称树与文件批注;附件按带 /EF 的文件说明计数,每个一次),隐藏内容为:内容过滤(包住 MuPDF 的 sanitize 过滤器,用途设为 View 才认关着的图层)删去关着的图层里的一切、3 与 7 渲染模式及零透明度的文字,脚本(名称树 JavaScript;任何对象的 /A、/OpenAction、/Next 与 /AA 各项里 S 为 JavaScript 或带 /JS 的动作,从链中摘除,其后的 /Next 链接回原处,非脚本动作保留),带隐藏或不可见标志的批注(含链接与字段控件:链接不在 MuPDF 的批注表里,按页面 /Annots 删;控件删去时字段连同值从表单去掉);有隐藏文字被删的页,标记内容的替代文字照删除的做法去掉。扫描与写出后的核对都在快照副本上跑同一遍清理来计数,所以计数与执行一致;所选类别核对不为零即报错。扫描回来时须仍是当前标签且版本未变,对话框写明文件名。未验证:Windows 与 Linux。 @@ -288,5 +288,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-11(用户报:Windows 启动带黑色命令行窗口、“打开方式”里无图标、点 X 不退出、表格全黑无网格线):四处根因。命令行窗口:应用是控制台子系统;发行构建改为 `windows_subsystem = "windows"`(开发构建照旧有控制台看日志),子进程(两个 worker、Git、`cmd start`)一律经 `ely_ipc::windowless` 以 `CREATE_NO_WINDOW` 启动。图标:exe 里只有 gpui 的清单资源;各应用共用构建脚本 `packaging/windows/icon.rs`,把 `assets/icons/<应用>.ico` 编成资源 1(gpui 的窗口图标也取资源 1)。点 X 不退出:窗口其实弹了“保存更改”询问。`ely.xcd` 深色方案的 `CalcNotesBackground` 与浅色不同,Calc 在配色变化时改写批注样式并记一步撤销(`ScDetectiveFunc::UpdateAllComments`),未动的表格切到深色即算已修改;两套方案的批注底色改为同一值(LibreOffice 默认的浅黄),切换主题不再改文档,三端同此(`sheet.rs` 的 `a_theme_switch_is_no_edit`)。全黑:Win32 的虚拟设备没有 `SetSizeUsingBuffer`(基类默认返回假,断言在发行构建里不在),给客户缓冲区的设备停在 1×1 像素,Calc 图块只画了一个像素,放映图层同空;补丁 `0044-lok-win32-paints-then-copies.patch` 让 Windows 走 macOS 的路:按全尺寸画进设备,再拷进客户缓冲区(`doc_paintTile` 与 `SlideshowLayerRenderer`)。Writer 自设设备尺寸,原本不受影响。`tests/scripts/lo-patches.sh` 的临时库也设 `core.autocrlf false`(原先在 Windows 上恒失败)。 - 2026-10-11(用户报:Windows 上标题栏的最小化、最大化、关闭三个按钮都按不动):gpui 回答系统的命中测试(`WM_NCHITTEST`)时按绘制次序取第一个含指针的窗口控制区,标题栏的拖动区先于其中的按钮画出,按钮上一律答“标题栏”,系统把按下当作拖动窗口的开始,点击到不了按钮。修复在 `vendor/gpui-pre`(原样一个提交,其后是修复):按命中测试的次序取最上层的控制区,且只看未被遮挡的部分(同 `is_hovered`,盖在标题栏上的对话框挡住其下的按钮)。实测(发行构建,DPI 感知的探针移动真实指针):自右向左依次答关闭、最大化、最小化、拖动;按下最小化即最小化,最大化即最大化,关闭即退出、worker 随之结束;v0.1.3 的安装版同一探针处处答拖动。该回调只有 Windows 平台调用。 - 2026-10-11:Linux 标题栏按钮(核查 Windows 按钮问题时查出)。macOS 的红绿灯由系统画,不受影响;Linux 为客户端装饰,按钮由组件库画在标题栏的拖动区里,拖动区对任何按下都准备移动窗口,按住按钮时指针动一像素即开始移动窗口,合成器接管指针,松开到不了按钮,点击落空。组件库 3eb4d34:`drag_region` 只在按下未被里面的按钮认领(按钮以 `prevent_default` 认领)时才准备移动;`shell/title_tests.rs` 核对晃动的按下点得动 Linux 的关闭、标题栏空处照样移动窗口(去掉修复时前者失败)。未在真 Linux 桌面上点过。 +- 2026-10-11(用户报:ElyTree 改动列表里文件名长时盖住行尾的打开按钮):组件库 bd01470:`ChangesList` 行里的文件名原为不缩的盒子,长名越过行宽压在按钮下;现可缩、以省略号结尾,先于按钮让位;`git/tests/narrow.rs` 核对 260 宽时文件名止于按钮之前(原布局时失败)。 +- 2026-10-11(用户定:ElyTree 状态栏去掉 Git 的版本与路径那一项):所用 Git 的版本与路径只记日志。同时查出 Windows 上 `std::fs::canonicalize` 给出 `\?\C:\…` 形式,仓库根、Git 目录、工作树与文件的真实路径一律改用 `dunce::canonicalize`(能写成普通路径即普通路径,同一文件仍是同一真实路径),状态栏的仓库路径随之为普通形式;ElyTree 的单测在 Windows 上由 44 个失败降到 8 个,余下 8 个改前同样失败(如写入 Windows 不许的文件名),未处理。 - 2026-10-11:ElyMark 的 AppImage(0.1.3 的 Linux 发布查出)。`linuxdeploy` 把可执行文件的 RUNPATH 改成 `$ORIGIN/../lib`,并把它找得到的 `libcef.so` 拷进 `usr/lib`:原先 `libcef.so` 与 CEF 的资料在 `usr/bin`,结果是两份 1.45 GB 的内核、程序载入 `usr/lib` 那份、CEF 在它旁边找不到 ICU 数据而退出(从未在 AppImage 里启动过)。现在 `appimage.sh` 把从 `engines/cef` 分装来的全部条目移进 `usr/lib`(按名字对照 CEF 文件夹,清单仍只在 `stage.sh`),以 `LD_LIBRARY_PATH=AppDir/usr/lib` 运行 `linuxdeploy`,`libcef.so` 只此一份、依赖照样带上,CEF 按所载 `libcef.so` 的文件夹取资料(`ely-web-content` 的 `open`)。ElyMark 的 AppImage 由 715 MB 降到 423 MB;WSL 里启动后 CEF 载入、窗口打开(WSL 里各应用都不出首帧,未在真桌面上验证)。 - 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-tree/Cargo.toml b/apps/ely-tree/Cargo.toml index 103d362..60880e7 100644 --- a/apps/ely-tree/Cargo.toml +++ b/apps/ely-tree/Cargo.toml @@ -11,6 +11,7 @@ build = "../../packaging/windows/icon.rs" embed-resource.workspace = true [dependencies] +dunce.workspace = true ely-files.workspace = true ely-gpui-component.workspace = true ely-platform.workspace = true diff --git a/apps/ely-tree/src/git.rs b/apps/ely-tree/src/git.rs index 9a4340b..f153c68 100644 --- a/apps/ely-tree/src/git.rs +++ b/apps/ely-tree/src/git.rs @@ -1,155 +1,148 @@ -//! Git as a program: argument arrays, never a shell (§13.1). - -use std::{ - io::{self, Write as _}, - path::{Path, PathBuf}, - process::{Command, Output, Stdio}, -}; - -/// A Git binary and its version. -#[derive(Clone, Debug)] -pub struct Git { - pub path: PathBuf, - pub version: String, - /// Ely's own build, not a system Git. - pub bundled: bool, -} - -impl Git { - /// `ELY_GIT` or the setting picks Git; else Ely's own. - pub fn find() -> io::Result { - let (path, bundled) = ely_platform::git::locate()?; - if !path.is_file() { - let hint = if bundled { - "run scripts/build-git.sh" - } else { - "check ELY_GIT or the Git setting" - }; - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!("no Git at {} ({hint})", path.display()), - )); - } - let version = match bundled { - true => bundled_version(&path)?, - false => ely_platform::git::system_version(&path)?, - }; - log::info!( - "tree: {version} at {} ({})", - path.display(), - if bundled { "bundled" } else { "system" } - ); - Ok(Git { - path, - version, - bundled, - }) - } - - /// Runs Git in `dir`; failure carries Git's words. - pub fn run(&self, dir: &Path, args: &[&str]) -> io::Result> { - let output = self.command(dir, args).output()?; - checked(args, output) - } - - /// Runs Git with `input` on its standard input. - pub fn run_input(&self, dir: &Path, args: &[&str], input: &[u8]) -> io::Result> { - let mut child = self - .command(dir, args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - let mut stdin = child - .stdin - .take() - .ok_or_else(|| io::Error::other("no stdin"))?; - let input = input.to_vec(); - // Writing apart from reading cannot fill both pipes. - let writer = std::thread::spawn(move || stdin.write_all(&input)); - let output = child.wait_with_output()?; - writer - .join() - .map_err(|_| io::Error::other("the stdin writer panicked"))??; - checked(args, output) - } - - /// HEAD names a commit; a new repository has none yet. - pub fn born(&self, dir: &Path) -> io::Result { - if !self.run_or_one(dir, &["rev-parse", "--verify", "-q", "HEAD"])?.is_empty() { - return Ok(true); - } - // Unborn: HEAD names a branch with no ref at all. - let branch = String::from_utf8(self.run(dir, &["symbolic-ref", "-q", "HEAD"])?) - .map_err(|_| io::Error::other("HEAD names a ref that is not UTF-8"))?; - // Only Git's line end goes; a name keeps every byte. - let branch = branch.strip_suffix('\n').unwrap_or(&branch).to_owned(); - let found = self.command(dir, &["for-each-ref", "--format=%(refname)", &branch]).output()?; - let unborn = branch.starts_with("refs/heads/") && found.status.success(); - if unborn && found.stdout.is_empty() && found.stderr.is_empty() { - return Ok(false); - } - let mut why = format!("HEAD names {branch}: no commit, and no branch yet to begin"); - let said = String::from_utf8_lossy(&found.stderr); - if !said.trim().is_empty() { - why = format!("{why}; Git says {}", said.trim()); - } - Err(io::Error::other(why)) - } - - /// 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()?; - match output.status.code() { - Some(1) => Ok(output.stdout), - _ => checked(args, output), - } - } - - /// `merge-file` exits with its conflict count; past 127 fails. - pub fn run_counted(&self, dir: &Path, args: &[&str]) -> io::Result> { - let output = self.command(dir, args).output()?; - match output.status.code() { - Some(0..=127) => Ok(output.stdout), - _ => checked(args, output), - } - } - - pub(crate) fn command(&self, dir: &Path, args: &[&str]) -> Command { - ely_platform::git::command(&self.path, dir, args) - } -} - -fn checked(args: &[&str], output: Output) -> io::Result> { - // The verb follows any `-c name=value` pairs. - let verb = args - .iter() - .copied() - .find(|arg| !arg.starts_with('-') && !arg.contains('=')) - .unwrap_or(""); - match output.status.success() { - true => { - log::debug!("tree: git {verb} done"); - Ok(output.stdout) - } - false => { - // Conflicts go to stdout, hooks to stderr. - let said: Vec = [&output.stdout, &output.stderr] - .into_iter() - .map(|bytes| String::from_utf8_lossy(bytes).trim().to_string()) - .filter(|said| !said.is_empty()) - .collect(); - log::warn!("tree: git {verb} failed, {}", output.status); - Err(io::Error::other(format!( - "git {verb} ({}):\n{}", - output.status, - said.join("\n") - ))) - } - } -} - -/// Ely's own Git, whatever version it was pinned at. -fn bundled_version(path: &Path) -> io::Result { - ely_platform::git::version_of(path).map(|(version, _)| version) -} +//! Git as a program: argument arrays, never a shell (§13.1). + +use std::{ + io::{self, Write as _}, + path::{Path, PathBuf}, + process::{Command, Output, Stdio}, +}; + +/// A Git binary whose version was checked. +#[derive(Clone, Debug)] +pub struct Git { + pub path: PathBuf, +} + +impl Git { + /// `ELY_GIT` or the setting picks Git; else Ely's own. + pub fn find() -> io::Result { + let (path, bundled) = ely_platform::git::locate()?; + if !path.is_file() { + let hint = if bundled { + "run scripts/build-git.sh" + } else { + "check ELY_GIT or the Git setting" + }; + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("no Git at {} ({hint})", path.display()), + )); + } + let version = match bundled { + true => bundled_version(&path)?, + false => ely_platform::git::system_version(&path)?, + }; + log::info!( + "tree: {version} at {} ({})", + path.display(), + if bundled { "bundled" } else { "system" } + ); + Ok(Git { path }) + } + + /// Runs Git in `dir`; failure carries Git's words. + pub fn run(&self, dir: &Path, args: &[&str]) -> io::Result> { + let output = self.command(dir, args).output()?; + checked(args, output) + } + + /// Runs Git with `input` on its standard input. + pub fn run_input(&self, dir: &Path, args: &[&str], input: &[u8]) -> io::Result> { + let mut child = self + .command(dir, args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| io::Error::other("no stdin"))?; + let input = input.to_vec(); + // Writing apart from reading cannot fill both pipes. + let writer = std::thread::spawn(move || stdin.write_all(&input)); + let output = child.wait_with_output()?; + writer + .join() + .map_err(|_| io::Error::other("the stdin writer panicked"))??; + checked(args, output) + } + + /// HEAD names a commit; a new repository has none yet. + pub fn born(&self, dir: &Path) -> io::Result { + if !self.run_or_one(dir, &["rev-parse", "--verify", "-q", "HEAD"])?.is_empty() { + return Ok(true); + } + // Unborn: HEAD names a branch with no ref at all. + let branch = String::from_utf8(self.run(dir, &["symbolic-ref", "-q", "HEAD"])?) + .map_err(|_| io::Error::other("HEAD names a ref that is not UTF-8"))?; + // Only Git's line end goes; a name keeps every byte. + let branch = branch.strip_suffix('\n').unwrap_or(&branch).to_owned(); + let found = self.command(dir, &["for-each-ref", "--format=%(refname)", &branch]).output()?; + let unborn = branch.starts_with("refs/heads/") && found.status.success(); + if unborn && found.stdout.is_empty() && found.stderr.is_empty() { + return Ok(false); + } + let mut why = format!("HEAD names {branch}: no commit, and no branch yet to begin"); + let said = String::from_utf8_lossy(&found.stderr); + if !said.trim().is_empty() { + why = format!("{why}; Git says {}", said.trim()); + } + Err(io::Error::other(why)) + } + + /// 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()?; + match output.status.code() { + Some(1) => Ok(output.stdout), + _ => checked(args, output), + } + } + + /// `merge-file` exits with its conflict count; past 127 fails. + pub fn run_counted(&self, dir: &Path, args: &[&str]) -> io::Result> { + let output = self.command(dir, args).output()?; + match output.status.code() { + Some(0..=127) => Ok(output.stdout), + _ => checked(args, output), + } + } + + pub(crate) fn command(&self, dir: &Path, args: &[&str]) -> Command { + ely_platform::git::command(&self.path, dir, args) + } +} + +fn checked(args: &[&str], output: Output) -> io::Result> { + // The verb follows any `-c name=value` pairs. + let verb = args + .iter() + .copied() + .find(|arg| !arg.starts_with('-') && !arg.contains('=')) + .unwrap_or(""); + match output.status.success() { + true => { + log::debug!("tree: git {verb} done"); + Ok(output.stdout) + } + false => { + // Conflicts go to stdout, hooks to stderr. + let said: Vec = [&output.stdout, &output.stderr] + .into_iter() + .map(|bytes| String::from_utf8_lossy(bytes).trim().to_string()) + .filter(|said| !said.is_empty()) + .collect(); + log::warn!("tree: git {verb} failed, {}", output.status); + Err(io::Error::other(format!( + "git {verb} ({}):\n{}", + output.status, + said.join("\n") + ))) + } + } +} + +/// Ely's own Git, whatever version it was pinned at. +fn bundled_version(path: &Path) -> io::Result { + ely_platform::git::version_of(path).map(|(version, _)| version) +} diff --git a/apps/ely-tree/src/repo.rs b/apps/ely-tree/src/repo.rs index 5d5108f..16fa2b9 100644 --- a/apps/ely-tree/src/repo.rs +++ b/apps/ely-tree/src/repo.rs @@ -36,9 +36,9 @@ pub fn open(git: &Git, path: &Path) -> io::Result { let common = ["rev-parse", "--path-format=absolute", "--git-common-dir"]; let common_dir = text(git.run(path, &common)?)?; let repo = Repo { - root: std::fs::canonicalize(root)?, - git_dir: std::fs::canonicalize(git_dir)?, - common_dir: std::fs::canonicalize(common_dir)?, + root: dunce::canonicalize(root)?, + git_dir: dunce::canonicalize(git_dir)?, + common_dir: dunce::canonicalize(common_dir)?, }; log::info!("tree: opened {}", repo.root.display()); Ok(repo) diff --git a/apps/ely-tree/src/structure.rs b/apps/ely-tree/src/structure.rs index 9a8e7d0..ac0fd99 100644 --- a/apps/ely-tree/src/structure.rs +++ b/apps/ely-tree/src/structure.rs @@ -66,7 +66,7 @@ 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) { + match dunce::canonicalize(&tree.path) { Ok(real) => tree.path = real, Err(error) if error.kind() == io::ErrorKind::NotFound => {} Err(error) => return Err(error), diff --git a/apps/ely-tree/src/tests.rs b/apps/ely-tree/src/tests.rs index fc2fc3d..a0edab6 100644 --- a/apps/ely-tree/src/tests.rs +++ b/apps/ely-tree/src/tests.rs @@ -70,7 +70,7 @@ fn a_repository_opens_from_inside_and_a_bare_one_does_not() { let opened = repo::open(&git(), &root.join("deep")).expect("opens"); assert_eq!( opened.root, - std::fs::canonicalize(&root).expect("real"), + dunce::canonicalize(&root).expect("real"), "the real top" ); assert!(opened.git_dir.ends_with(".git")); @@ -324,7 +324,7 @@ fn a_gone_upstream_has_no_counts() { #[test] fn a_linked_file_keeps_its_own_name() { let dir = tempfile::tempdir().expect("a temp dir"); - let root = std::fs::canonicalize(dir.path()).expect("real"); + let root = dunce::canonicalize(dir.path()).expect("real"); std::fs::create_dir(root.join("docs")).expect("a folder"); std::fs::write(root.join("real.md"), "a").expect("a file"); let outside = tempfile::tempdir().expect("another dir"); diff --git a/apps/ely-tree/src/tests/places.rs b/apps/ely-tree/src/tests/places.rs index 3fe1b8c..a9af365 100644 --- a/apps/ely-tree/src/tests/places.rs +++ b/apps/ely-tree/src/tests/places.rs @@ -252,10 +252,10 @@ fn worktrees_list_branches_and_prunable_folders() { .expect("adds"); let list = structure::worktrees(&git(), &root).expect("reads"); assert_eq!(list.len(), 2); - assert_eq!(list[0].path, std::fs::canonicalize(&root).expect("real")); + assert_eq!(list[0].path, dunce::canonicalize(&root).expect("real")); assert_eq!( (list[1].path.clone(), list[1].branch.as_deref()), - (std::fs::canonicalize(&other).expect("real"), Some("side")) + (dunce::canonicalize(&other).expect("real"), Some("side")) ); std::fs::remove_dir_all(&other).expect("removes"); let list = structure::worktrees(&git(), &root).expect("reads"); @@ -497,5 +497,5 @@ fn a_worktree_registered_through_a_link_lists_its_real_path() { ) .expect("writes"); let list = structure::worktrees(&git(), &root).expect("reads"); - assert_eq!(list[1].path, std::fs::canonicalize(&other).expect("real")); + assert_eq!(list[1].path, dunce::canonicalize(&other).expect("real")); } diff --git a/apps/ely-tree/src/tests/window.rs b/apps/ely-tree/src/tests/window.rs index b8d2dec..1b1a221 100644 --- a/apps/ely-tree/src/tests/window.rs +++ b/apps/ely-tree/src/tests/window.rs @@ -101,7 +101,7 @@ fn one_tab_per_repository_and_reopening_selects_it() { let recent = cx.update(|_, app| tree.read(app).recent.clone()); assert_eq!( recent[0], - std::fs::canonicalize(&first).expect("real"), + dunce::canonicalize(&first).expect("real"), "newest first" ); cx.update(|_, app| tree.update(app, |tree, cx| tree.close_active(cx))); diff --git a/apps/ely-tree/src/tree.rs b/apps/ely-tree/src/tree.rs index 55c1ce3..8ec83af 100644 --- a/apps/ely-tree/src/tree.rs +++ b/apps/ely-tree/src/tree.rs @@ -156,7 +156,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity { /// `file` as Git names it; a symlink stays itself. pub(crate) fn inside(root: &Path, file: &Path) -> io::Result { let name = file.file_name().ok_or_else(|| io::Error::other("a path without a name"))?; - let dir = std::fs::canonicalize(file.parent().unwrap_or(Path::new(".")))?; + let dir = dunce::canonicalize(file.parent().unwrap_or(Path::new(".")))?; let rel = dir.join(name); let rel = rel.strip_prefix(root).map_err(io::Error::other)?; rel.to_str().map(str::to_owned).ok_or_else(|| io::Error::other("a path that is not UTF-8")) diff --git a/apps/ely-tree/src/view.rs b/apps/ely-tree/src/view.rs index d245e3d..c56f069 100644 --- a/apps/ely-tree/src/view.rs +++ b/apps/ely-tree/src/view.rs @@ -1,283 +1,268 @@ -//! What the window draws. - -use ely_gpui_component::{ - buttons::{Button, ButtonVariant}, - feedback::{EmptyState, ErrorView}, - lists::{List, ListItem}, - navigation::{EditorTab, EditorTabs}, - primitives::IconName, - shell::{StatusBar, StatusBarItem}, -}; -use ely_platform::AppId; -use ely_ui::{Shell, commands}; -use gpui::{ - AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, - SharedString, Styled as _, Window, div, -}; - -use crate::{ - menu, - status::{Head, State}, - tree::{Side, Tree, text}, -}; - -fn name(path: &std::path::Path) -> String { - path.file_name().map_or_else( - || path.display().to_string(), - |name| name.to_string_lossy().into_owned(), - ) -} - -/// Where HEAD is, in words. -pub fn branch(head: &Head, cx: &App) -> SharedString { - match (&head.branch, &head.oid) { - (Some(branch), Some(_)) => branch.clone().into(), - (Some(branch), None) => text(cx, "tree.branch.unborn", &[("branch", branch)]), - (None, oid) => { - let short = oid.as_deref().map_or("", |oid| &oid[..oid.len().min(7)]); - text(cx, "tree.branch.detached", &[("oid", short)]) - } - } -} - -impl Tree { - fn tab_strip(&self, cx: &mut Context) -> Option { - if self.repos.is_empty() { - return None; - } - let tabs = self - .repos - .iter() - .map(|opened| EditorTab::new(opened.id.to_string(), name(&opened.repo.root))); - let (select, close) = (cx.weak_entity(), cx.weak_entity()); - let strip = EditorTabs::new("tree-tabs", tabs.collect::>()) - .on_select(move |id, _, cx| { - let id = id.parse().expect("tab ids are numbers"); - select - .update(cx, |tree, cx| tree.activate(Some(id), cx)) - .ok(); - }) - .on_close(move |id, _, cx| { - let id = id.parse().expect("tab ids are numbers"); - close.update(cx, |tree, cx| tree.close(id, cx)).ok(); - }); - Some(match self.active { - Some(id) => strip.selected(id.to_string()), - None => strip, - }) - } - - fn status_bar(&self, cx: &mut Context) -> StatusBar { - let mut bar = StatusBar::new(); - if let Some(opened) = self.active() { - if let Some(Ok(status)) = &opened.status { - let head = &status.head; - bar = bar.left( - StatusBarItem::new("tree-branch") - .icon(IconName::GitBranch) - .label(branch(head, cx)), - ); - if let Some(upstream) = &head.upstream { - let words = match head.counts { - Some((ahead, behind)) => text( - cx, - "tree.ahead_behind", - &[ - ("upstream", upstream), - ("ahead", &ahead.to_string()), - ("behind", &behind.to_string()), - ], - ), - None => text(cx, "tree.upstream_gone", &[("upstream", upstream)]), - }; - bar = bar.left(StatusBarItem::new("tree-upstream").label(words)); - } - } - bar = bar.left( - StatusBarItem::new("tree-path").label(opened.repo.root.display().to_string()), - ); - } - if let Ok(git) = &self.git { - let key = if git.bundled { - "tree.git.bundled" - } else { - "tree.git.system" - }; - bar = bar.right(StatusBarItem::new("tree-git").label(text( - cx, - key, - &[ - ("version", &git.version), - ("path", &git.path.display().to_string()), - ], - ))); - } - bar - } - - fn start_page(&self, cx: &mut Context) -> AnyElement { - let this = cx.weak_entity(); - let button = |id: &'static str, key: &str, run: fn(&mut Window, &mut App)| { - Button::new(id, text(cx, key, &[])).on_click(move |_, window, cx| run(window, cx)) - }; - let empty = EmptyState::new( - "tree-start", - IconName::GitBranch, - text(cx, "tree.empty", &[]), - ) - .action( - button("tree-open", "tree.open", |window, cx| { - window.dispatch_action(Box::new(commands::Open), cx) - }) - .variant(ButtonVariant::Primary), - ) - .action(button("tree-new", "tree.new", |window, cx| { - window.dispatch_action(Box::new(commands::New), cx) - })) - .action(button("tree-clone", "tree.clone", |window, cx| { - window.dispatch_action(Box::new(menu::CloneRepository), cx) - })); - let recent = self.recent.iter().enumerate().map(|(at, path)| { - let (this, path) = (this.clone(), path.clone()); - ListItem::new(("tree-recent", at), name(&path)) - .description(path.display().to_string()) - .on_click(move |_, _, cx| { - this.update(cx, |tree, cx| tree.open_path(path.clone(), cx)) - .ok(); - }) - .into_any_element() - }); - div() - .flex() - .flex_col() - .items_center() - .child(empty) - .child(div().w_96().child(List::new().children(recent))) - .into_any_element() - } - - fn body(&self, cx: &mut Context) -> AnyElement { - if let Err(error) = &self.git { - return ErrorView::new( - "tree-no-git", - IconName::TriangleAlert, - text(cx, "tree.no_git", &[]), - ) - .detail(error.clone()) - .into_any_element(); - } - 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(); - }); - return state.action(stop).into_any_element(); - } - let Some(opened) = self.active() else { - return self.start_page(cx); - }; - if let Some(look) = self.look.as_ref().filter(|look| look.repo() == opened.id) { - return self.look_pane(look, cx); - } - match self.side { - Side::History => return self.commit_pane(&opened.hist, cx), - Side::Branches => return self.refs_pane(cx), - Side::Places => return self.places_pane(cx), - Side::Changes => {} - } - if let Some(shown) = self.shown.as_ref().filter(|shown| shown.repo == opened.id) { - return self.pane(shown, cx); - } - match &opened.status { - None => div().into_any_element(), - Some(Err(error)) => ErrorView::new( - "tree-unread", - IconName::TriangleAlert, - text(cx, "tree.unreadable", &[]), - ) - .detail(error.clone()) - .into_any_element(), - Some(Ok(status)) => { - let changed = status - .entries - .iter() - .filter(|entry| entry.state != State::Ignored) - .count(); - let (icon, title) = summary(changed, cx); - EmptyState::new(("tree-summary", opened.id), icon, title).into_any_element() - } - } - } -} - -impl Render for Tree { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let title: SharedString = self.active().map_or_else( - || AppId::Tree.name().into(), - |opened| name(&opened.repo.root).into(), - ); - let left = self - .active() - .filter(|opened| matches!(opened.status, Some(Ok(_)))) - .map(|_| self.changes.clone().into()); - let mut shell = Shell::new(title, &self.focus) - .left(left) - .status(self.status_bar(cx)) - .children(self.net_alert(cx)) - .children(self.op_alert(cx)) - .child(self.body(cx)); - if let Some(strip) = self.tab_strip(cx) { - shell = shell.tabs(strip); - } - div() - .size_full() - .key_context(menu::CONTEXT) - .track_focus(&self.focus) - .on_action(cx.listener(Tree::on_open)) - .on_action(cx.listener(Tree::on_new)) - .on_action(cx.listener(Tree::on_clone)) - .on_action(cx.listener(Tree::on_close)) - .on_action(cx.listener(|tree, _: &menu::Fetch, _, cx| tree.fetch_all(cx))) - .on_action(cx.listener(|tree, _: &menu::Pull, _, cx| tree.pull(cx))) - .on_action(cx.listener(|tree, _: &menu::Push, _, cx| tree.push(cx))) - .on_action(cx.listener(|tree, _: &menu::ForcePush, _, cx| tree.ask_force(cx))) - .on_action(cx.listener(|tree, _: &menu::OpenTerminal, _, cx| tree.open_terminal(cx))) - .on_action(cx.listener(|tree, _: &menu::Reveal, _, cx| tree.reveal(cx))) - .on_action(cx.listener(|tree, _: &menu::CleanUntracked, _, cx| tree.ask_clean(cx))) - .on_action( - cx.listener(|tree, _: &menu::ChooseEditor, _, cx| tree.pick_editor(None, cx)), - ) - .child(shell) - .children(self.dialog(cx)) - } -} - -/// The work tree's line: clean, or how many files changed. -pub(crate) fn summary(changed: usize, cx: &App) -> (IconName, SharedString) { - match changed { - 0 => (IconName::CircleCheck, text(cx, "tree.clean", &[])), - n => (IconName::GitBranch, ely_ui::i18n::counted(cx, "tree.changed", n as u64, &[("n", &n.to_string())])), - } -} - -#[cfg(test)] -mod tests { - #[gpui::test] - fn the_summary_counts_files_in_both_languages(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - ely_ui::i18n::install_as("en-US", cx); - let said = |n: usize, cx: &gpui::App| super::summary(n, cx).1.to_string(); - assert_eq!([said(0, cx), said(1, cx), said(2, cx)], ["Nothing to commit", "1 file changed", "2 files changed"]); - ely_ui::i18n::install_as("zh-CN", cx); - assert_eq!([said(1, cx), said(2, cx)], ["1 个文件有改动", "2 个文件有改动"]); - }); - } -} +//! What the window draws. + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + feedback::{EmptyState, ErrorView}, + lists::{List, ListItem}, + navigation::{EditorTab, EditorTabs}, + primitives::IconName, + shell::{StatusBar, StatusBarItem}, +}; +use ely_platform::AppId; +use ely_ui::{Shell, commands}; +use gpui::{ + AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, + SharedString, Styled as _, Window, div, +}; + +use crate::{ + menu, + status::{Head, State}, + tree::{Side, Tree, text}, +}; + +fn name(path: &std::path::Path) -> String { + path.file_name().map_or_else( + || path.display().to_string(), + |name| name.to_string_lossy().into_owned(), + ) +} + +/// Where HEAD is, in words. +pub fn branch(head: &Head, cx: &App) -> SharedString { + match (&head.branch, &head.oid) { + (Some(branch), Some(_)) => branch.clone().into(), + (Some(branch), None) => text(cx, "tree.branch.unborn", &[("branch", branch)]), + (None, oid) => { + let short = oid.as_deref().map_or("", |oid| &oid[..oid.len().min(7)]); + text(cx, "tree.branch.detached", &[("oid", short)]) + } + } +} + +impl Tree { + fn tab_strip(&self, cx: &mut Context) -> Option { + if self.repos.is_empty() { + return None; + } + let tabs = self + .repos + .iter() + .map(|opened| EditorTab::new(opened.id.to_string(), name(&opened.repo.root))); + let (select, close) = (cx.weak_entity(), cx.weak_entity()); + let strip = EditorTabs::new("tree-tabs", tabs.collect::>()) + .on_select(move |id, _, cx| { + let id = id.parse().expect("tab ids are numbers"); + select + .update(cx, |tree, cx| tree.activate(Some(id), cx)) + .ok(); + }) + .on_close(move |id, _, cx| { + let id = id.parse().expect("tab ids are numbers"); + close.update(cx, |tree, cx| tree.close(id, cx)).ok(); + }); + Some(match self.active { + Some(id) => strip.selected(id.to_string()), + None => strip, + }) + } + + fn status_bar(&self, cx: &mut Context) -> StatusBar { + let mut bar = StatusBar::new(); + if let Some(opened) = self.active() { + if let Some(Ok(status)) = &opened.status { + let head = &status.head; + bar = bar.left( + StatusBarItem::new("tree-branch") + .icon(IconName::GitBranch) + .label(branch(head, cx)), + ); + if let Some(upstream) = &head.upstream { + let words = match head.counts { + Some((ahead, behind)) => text( + cx, + "tree.ahead_behind", + &[ + ("upstream", upstream), + ("ahead", &ahead.to_string()), + ("behind", &behind.to_string()), + ], + ), + None => text(cx, "tree.upstream_gone", &[("upstream", upstream)]), + }; + bar = bar.left(StatusBarItem::new("tree-upstream").label(words)); + } + } + bar = bar.left( + StatusBarItem::new("tree-path").label(opened.repo.root.display().to_string()), + ); + } + bar + } + + fn start_page(&self, cx: &mut Context) -> AnyElement { + let this = cx.weak_entity(); + let button = |id: &'static str, key: &str, run: fn(&mut Window, &mut App)| { + Button::new(id, text(cx, key, &[])).on_click(move |_, window, cx| run(window, cx)) + }; + let empty = EmptyState::new( + "tree-start", + IconName::GitBranch, + text(cx, "tree.empty", &[]), + ) + .action( + button("tree-open", "tree.open", |window, cx| { + window.dispatch_action(Box::new(commands::Open), cx) + }) + .variant(ButtonVariant::Primary), + ) + .action(button("tree-new", "tree.new", |window, cx| { + window.dispatch_action(Box::new(commands::New), cx) + })) + .action(button("tree-clone", "tree.clone", |window, cx| { + window.dispatch_action(Box::new(menu::CloneRepository), cx) + })); + let recent = self.recent.iter().enumerate().map(|(at, path)| { + let (this, path) = (this.clone(), path.clone()); + ListItem::new(("tree-recent", at), name(&path)) + .description(path.display().to_string()) + .on_click(move |_, _, cx| { + this.update(cx, |tree, cx| tree.open_path(path.clone(), cx)) + .ok(); + }) + .into_any_element() + }); + div() + .flex() + .flex_col() + .items_center() + .child(empty) + .child(div().w_96().child(List::new().children(recent))) + .into_any_element() + } + + fn body(&self, cx: &mut Context) -> AnyElement { + if let Err(error) = &self.git { + return ErrorView::new( + "tree-no-git", + IconName::TriangleAlert, + text(cx, "tree.no_git", &[]), + ) + .detail(error.clone()) + .into_any_element(); + } + 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(); + }); + return state.action(stop).into_any_element(); + } + let Some(opened) = self.active() else { + return self.start_page(cx); + }; + if let Some(look) = self.look.as_ref().filter(|look| look.repo() == opened.id) { + return self.look_pane(look, cx); + } + match self.side { + Side::History => return self.commit_pane(&opened.hist, cx), + Side::Branches => return self.refs_pane(cx), + Side::Places => return self.places_pane(cx), + Side::Changes => {} + } + if let Some(shown) = self.shown.as_ref().filter(|shown| shown.repo == opened.id) { + return self.pane(shown, cx); + } + match &opened.status { + None => div().into_any_element(), + Some(Err(error)) => ErrorView::new( + "tree-unread", + IconName::TriangleAlert, + text(cx, "tree.unreadable", &[]), + ) + .detail(error.clone()) + .into_any_element(), + Some(Ok(status)) => { + let changed = status + .entries + .iter() + .filter(|entry| entry.state != State::Ignored) + .count(); + let (icon, title) = summary(changed, cx); + EmptyState::new(("tree-summary", opened.id), icon, title).into_any_element() + } + } + } +} + +impl Render for Tree { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let title: SharedString = self.active().map_or_else( + || AppId::Tree.name().into(), + |opened| name(&opened.repo.root).into(), + ); + let left = self + .active() + .filter(|opened| matches!(opened.status, Some(Ok(_)))) + .map(|_| self.changes.clone().into()); + let mut shell = Shell::new(title, &self.focus) + .left(left) + .status(self.status_bar(cx)) + .children(self.net_alert(cx)) + .children(self.op_alert(cx)) + .child(self.body(cx)); + if let Some(strip) = self.tab_strip(cx) { + shell = shell.tabs(strip); + } + div() + .size_full() + .key_context(menu::CONTEXT) + .track_focus(&self.focus) + .on_action(cx.listener(Tree::on_open)) + .on_action(cx.listener(Tree::on_new)) + .on_action(cx.listener(Tree::on_clone)) + .on_action(cx.listener(Tree::on_close)) + .on_action(cx.listener(|tree, _: &menu::Fetch, _, cx| tree.fetch_all(cx))) + .on_action(cx.listener(|tree, _: &menu::Pull, _, cx| tree.pull(cx))) + .on_action(cx.listener(|tree, _: &menu::Push, _, cx| tree.push(cx))) + .on_action(cx.listener(|tree, _: &menu::ForcePush, _, cx| tree.ask_force(cx))) + .on_action(cx.listener(|tree, _: &menu::OpenTerminal, _, cx| tree.open_terminal(cx))) + .on_action(cx.listener(|tree, _: &menu::Reveal, _, cx| tree.reveal(cx))) + .on_action(cx.listener(|tree, _: &menu::CleanUntracked, _, cx| tree.ask_clean(cx))) + .on_action( + cx.listener(|tree, _: &menu::ChooseEditor, _, cx| tree.pick_editor(None, cx)), + ) + .child(shell) + .children(self.dialog(cx)) + } +} + +/// The work tree's line: clean, or how many files changed. +pub(crate) fn summary(changed: usize, cx: &App) -> (IconName, SharedString) { + match changed { + 0 => (IconName::CircleCheck, text(cx, "tree.clean", &[])), + n => (IconName::GitBranch, ely_ui::i18n::counted(cx, "tree.changed", n as u64, &[("n", &n.to_string())])), + } +} + +#[cfg(test)] +mod tests { + #[gpui::test] + fn the_summary_counts_files_in_both_languages(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + ely_ui::i18n::install_as("en-US", cx); + let said = |n: usize, cx: &gpui::App| super::summary(n, cx).1.to_string(); + assert_eq!([said(0, cx), said(1, cx), said(2, cx)], ["Nothing to commit", "1 file changed", "2 files changed"]); + ely_ui::i18n::install_as("zh-CN", cx); + assert_eq!([said(1, cx), said(2, cx)], ["1 个文件有改动", "2 个文件有改动"]); + }); + } +} diff --git a/crates/ely-ui/src/i18n/tree.rs b/crates/ely-ui/src/i18n/tree.rs index 12c82d0..e58810d 100644 --- a/crates/ely-ui/src/i18n/tree.rs +++ b/crates/ely-ui/src/i18n/tree.rs @@ -24,8 +24,6 @@ pub(super) const EN: &[(&str, &str)] = &[ ("tree.branch.unborn", "{branch}, no commits yet"), ("tree.ahead_behind", "{upstream} ↑{ahead} ↓{behind}"), ("tree.upstream_gone", "{upstream} is gone"), - ("tree.git.bundled", "{version}, bundled, {path}"), - ("tree.git.system", "{version}, system, {path}"), ("tree.busy", "Wait for this repository's changes to finish."), ("tree.gone", "{path} is not on disk."), ("tree.discard.title", "Discard changes to {path}?"), @@ -155,8 +153,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[ ("tree.branch.unborn", "{branch},尚无提交"), ("tree.ahead_behind", "{upstream} ↑{ahead} ↓{behind}"), ("tree.upstream_gone", "{upstream} 已不存在"), - ("tree.git.bundled", "{version},随附,{path}"), - ("tree.git.system", "{version},系统,{path}"), ("tree.busy", "请等此仓库的改动完成。"), ("tree.gone", "{path} 不在磁盘上。"), ("tree.discard.title", "放弃 {path} 的改动?"), diff --git a/scripts/fetch-references.sh b/scripts/fetch-references.sh index 58641ed..7f85d9c 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 3eb4d346fe88efe475daa88e0e042ca3680a386c +fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git bd014707fd47cd9d0651a9af8c1db6033718a6ea 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 \