Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fde97a01a9
|
||
|
|
b03c9f2853
|
||
|
|
fa273aff13
|
||
|
|
096b7f8fd4
|
||
|
|
9b1b64884b | ||
|
|
623bc085f6
|
||
|
|
383a1ea467
|
||
|
|
6bf5d6c7e6
|
||
|
|
c9bfccc91c
|
||
|
|
4d8d5a6ef5
|
||
|
|
04040a2a03
|
||
|
|
139e8f6250
|
||
|
|
1f4807c81c
|
||
|
|
ec87e28a46
|
||
|
|
8a915ea354
|
||
|
|
943411fb1a
|
||
|
|
ab85673eea
|
||
|
|
f9bf82e3f8
|
||
|
|
a6d8a1de43
|
||
|
|
ecf977364b
|
||
|
|
c52e1a3b16
|
||
|
|
63bf0cc828
|
||
|
|
380e6783d6
|
No files matched your search
@@ -1,77 +0,0 @@
|
||||
# Builds the patched engines once; release.yml downloads them.
|
||||
name: engines
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15
|
||||
arch: arm64
|
||||
- runner: macos-15-intel
|
||||
arch: x86_64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 355
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CCACHE_DIR: ${{ github.workspace }}/.ccache
|
||||
CCACHE_MAXSIZE: 6G
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Key
|
||||
id: key
|
||||
run: echo "key=$(scripts/engines-key.sh)" >> "$GITHUB_OUTPUT"
|
||||
- name: Already built?
|
||||
id: done
|
||||
run: |
|
||||
if gh release view "engines-${{ steps.key.outputs.key }}" --json assets \
|
||||
--jq '.assets[].name' 2>/dev/null | grep -qx "engines-macos-${{ matrix.arch }}.tar.zst"; then
|
||||
echo "built=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Tools
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: brew install make gnu-sed bison flex gettext autoconf automake libtool ccache nasm zstd
|
||||
- name: Compiler cache
|
||||
if: steps.done.outputs.built != 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: .ccache
|
||||
key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }}
|
||||
restore-keys: ccache-macos-${{ matrix.arch }}-
|
||||
- name: Upstream sources
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: scripts/fetch-references.sh
|
||||
- name: LibreOffice (patched)
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: scripts/build-libreoffice.sh
|
||||
- name: MuPDF (patched), Git, git-lfs, OCR data, CEF
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: |
|
||||
scripts/build-mupdf.sh
|
||||
scripts/build-git.sh
|
||||
scripts/fetch-git-lfs.sh
|
||||
scripts/fetch-tessdata.sh
|
||||
scripts/fetch-cef.sh
|
||||
- name: Keep the compiler cache, even after a timeout
|
||||
if: always() && steps.done.outputs.built != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: .ccache
|
||||
key: ccache-macos-${{ matrix.arch }}-${{ steps.key.outputs.key }}-${{ github.run_id }}
|
||||
- name: Publish
|
||||
if: steps.done.outputs.built != 'true'
|
||||
run: |
|
||||
archive=engines-macos-${{ matrix.arch }}.tar.zst
|
||||
tar -C engines -cf - lo-src/instdir mupdf-build mupdf-src git git-src/COPYING git-src/LGPL-2.1 cef tessdata |
|
||||
zstd -19 -T0 -o "$archive"
|
||||
tag=engines-${{ steps.key.outputs.key }}
|
||||
gh release view "$tag" >/dev/null 2>&1 ||
|
||||
gh release create "$tag" --prerelease --title "$tag" --notes "Pinned, patched engines; built by engines.yml."
|
||||
gh release upload "$tag" "$archive" --clobber
|
||||
@@ -1,58 +0,0 @@
|
||||
# Builds macOS apps for a release tag and attaches them.
|
||||
name: release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to attach the packages to, such as v0.1.0
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15
|
||||
arch: arm64
|
||||
- runner: macos-15-intel
|
||||
arch: x86_64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
- name: Engines
|
||||
run: |
|
||||
brew install zstd
|
||||
tag=engines-$(scripts/engines-key.sh)
|
||||
gh release download "$tag" --pattern "engines-macos-${{ matrix.arch }}.tar.zst" ||
|
||||
{ echo "::error::no $tag for ${{ matrix.arch }}; run the engines workflow first"; exit 1; }
|
||||
mkdir -p engines
|
||||
zstd -dc "engines-macos-${{ matrix.arch }}.tar.zst" | tar -C engines -xf -
|
||||
- name: Upstream sources and ElyMark's page
|
||||
run: |
|
||||
scripts/fetch-references.sh
|
||||
scripts/build-mark-content.sh
|
||||
- name: Tests that need no window
|
||||
run: |
|
||||
tests/scripts/types.sh
|
||||
tests/scripts/icons.sh
|
||||
python3 tests/scripts/comments.py
|
||||
- name: Packages
|
||||
run: packaging/macos/release.sh 1
|
||||
- name: Attach to the release
|
||||
run: |
|
||||
version=${{ inputs.tag }}
|
||||
gh release view "$version" >/dev/null 2>&1 || gh release create "$version" --title "Ely Office $version" --notes "Built by release.yml."
|
||||
gh release upload "$version" dist/*.dmg --clobber
|
||||
if [ "${{ matrix.arch }}" = arm64 ]; then
|
||||
gh release upload "$version" dist/ElyOffice-*-source.tar.gz dist/ElyOffice-*-upstream-source.tar.gz --clobber
|
||||
fi
|
||||
@@ -0,0 +1,15 @@
|
||||
# Contributing
|
||||
|
||||
## Opening a pull request on git.zacharyzhang.com
|
||||
|
||||
Forking is disabled on this server. Open pull requests with AGit instead:
|
||||
|
||||
1. Register an account at https://git.zacharyzhang.com
|
||||
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
|
||||
|
||||
```bash
|
||||
git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git
|
||||
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
|
||||
```
|
||||
|
||||
3. To update the pull request, push again with the same `topic`.
|
||||
Generated
+18
-17
@@ -1919,7 +1919,7 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "ely-agent"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-platform",
|
||||
"ely-ui",
|
||||
@@ -1927,7 +1927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-doc"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"csv",
|
||||
@@ -1948,7 +1948,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-files"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"ely-platform",
|
||||
@@ -1964,7 +1964,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ely-gpui-component"
|
||||
version = "0.1.1"
|
||||
source = "git+https://github.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=b0dbfd429c17819f6c143fd365852244d497514a#b0dbfd429c17819f6c143fd365852244d497514a"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=5d6efd46ec0c93b381ec7578561a1273c366025b#5d6efd46ec0c93b381ec7578561a1273c366025b"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -2001,14 +2001,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-ipc"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-lok"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"ely-gpui-component",
|
||||
@@ -2028,7 +2028,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-mail"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-platform",
|
||||
"ely-ui",
|
||||
@@ -2036,7 +2036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-mark"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.23.1",
|
||||
@@ -2058,7 +2058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-office"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"directories",
|
||||
@@ -2078,7 +2078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-pdf"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-files",
|
||||
"ely-gpui-component",
|
||||
@@ -2094,7 +2094,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-pdf-engine"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"ely-ipc",
|
||||
@@ -2110,7 +2110,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-platform"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -2143,7 +2143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-show"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-files",
|
||||
"ely-gpui-component",
|
||||
@@ -2162,7 +2162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-table"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-gpui-component",
|
||||
"ely-lok",
|
||||
@@ -2178,7 +2178,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-tree"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"ely-files",
|
||||
"ely-gpui-component",
|
||||
@@ -2196,7 +2196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ely-ui"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"directories",
|
||||
@@ -2207,13 +2207,14 @@ dependencies = [
|
||||
"gpui-pre-platform",
|
||||
"log",
|
||||
"notify",
|
||||
"semver",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-web-content"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cef",
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ members = ["apps/*", "crates/*"]
|
||||
exclude = ["engines", "reference"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "AGPL-3.0-or-later"
|
||||
@@ -17,7 +17,7 @@ encoding_rs = "0.8"
|
||||
jiff = "0.2"
|
||||
anyhow = "1"
|
||||
directories = "6"
|
||||
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "b0dbfd429c17819f6c143fd365852244d497514a" }
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "5d6efd46ec0c93b381ec7578561a1273c366025b" }
|
||||
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"] }
|
||||
|
||||
@@ -529,7 +529,7 @@ AI 或脚本修改文件与其他外部修改使用同一套处理规则。ElyMa
|
||||
|
||||
### 15.2 接入基线
|
||||
|
||||
后端以 Kigi CLI(https://github.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP(用户 2026-10-05 定)。提供商登录(订阅账号或 API 密钥)、模型与端点配置沿用 Kigi 的实现,不另写一套。不解析终端装饰文本来伪造结构化事件。
|
||||
后端以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP(用户 2026-10-05 定)。提供商登录(订阅账号或 API 密钥)、模型与端点配置沿用 Kigi 的实现,不另写一套。不解析终端装饰文本来伪造结构化事件。
|
||||
|
||||
会话恢复只在 Kigi 的会话存储支持并通过验证时启用。不能把本地历史回放当成后端会话已经恢复。
|
||||
|
||||
@@ -870,10 +870,10 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
核对日期:2026-10-01。来源用于确认接口、项目结构和许可条件,不证明 Ely 已完成接入。产品功能、默认设置与性能目标由本文定义。正式实现应记录所采用版本的固定提交和许可证,而不是只引用默认分支。
|
||||
|
||||
[S1] Ely GPUI Components,README,组件库使用方式、主题及许可证。
|
||||
`https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/README.md`
|
||||
`https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/README.md`
|
||||
|
||||
[S2] Ely GPUI Components,TASKS,组件分类与 host/prove 等状态定义。
|
||||
`https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/TASKS.md`
|
||||
`https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/TASKS.md`
|
||||
|
||||
[S3] LibreOfficeKit 官方文档,C/C++ 接口、分块渲染和输入回调。
|
||||
`https://docs.libreoffice.org/libreofficekit.html`
|
||||
|
||||
@@ -39,7 +39,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
||||
|
||||
## 组件映射
|
||||
|
||||
依赖 `ely-gpui-component`(pin b0dbfd4,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 5d6efd4,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
|
||||
|
||||
| 需要 | 用 |
|
||||
| --- | --- |
|
||||
@@ -158,6 +158,9 @@ tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久
|
||||
tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限)
|
||||
tests/scripts/mark-tools.sh 真窗口 ElyMark:查找替换、正则、Tab、列表、表格、拖放与粘贴图片(需辅助功能权限;ELY_CLIPBOARD_TEST=1 再测粘贴)
|
||||
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
|
||||
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
|
||||
tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动
|
||||
tests/scripts/update.sh 装好的 ElyTree 从本机服务的新版下载、替换、重新打开(需辅助功能权限)
|
||||
tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限)
|
||||
tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限)
|
||||
tests/scripts/panels.sh 真窗口 ElyDoc:左侧栏默认不宽于 230、拖动内侧边缘变宽、宽度存入设置、重启后照旧(需辅助功能权限)
|
||||
@@ -184,8 +187,8 @@ 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)、组件库 b0dbfd4(2026-10-09 起)。
|
||||
- 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并经 SSH 推送(本机 HTTPS keychain 令牌失效)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。
|
||||
- 2026-10-01:上游版本以 `scripts/fetch-references.sh` 为准。LibreOffice 26.2.6(8221e31)、MuPDF 1.28.5(8ad45e9)、gpui 为 `gpui-pre` =0.3.8 与 `gpui-pre-platform` =0.3.8(crates.io,2026-10-07 起,随组件库 K01)、组件库 5d6efd4(2026-10-09 起)。
|
||||
- 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并推送(2026-10-09 起推到 git.zacharyzhang.com,HTTPS,令牌在本机钥匙串)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。
|
||||
- 2026-10-06:审查者为探针复制的源码树须用自己的 `CARGO_TARGET_DIR`(`scripts/review.sh` 写明);写完结论前删掉这些副本与其构建目录(曾积到 /tmp 近 900 GB,磁盘写满、链接失败):曾编进本仓库 `target/`,ely-lok 里烘焙的源码路径指向已删的副本,单测找不到 worker。
|
||||
- 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high;本机 `codex` 别名已带 `--yolo`,重复传参会报错)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。
|
||||
- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(macOS 另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定Line truncated
|
||||
@@ -200,8 +203,8 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-01:MailCore 2 的子模块 libetpan 过旧,改钉 libetpan master `4d47862`。
|
||||
- 2026-10-01:每个应用的全部数据在 `<本机数据目录>/ElyOffice/<slug>/`,路径由 `ely_platform::data_dir` 给出。卸载只动这里,且先问。用户文档永不碰。
|
||||
- 2026-10-04(用户定):macOS 包标识前缀 `ely.zacharyzhang.office`(`packaging/macos/bundle.sh`);发布者 ZacharyZhang,网址 zacharyzhang.com(macOS `NSHumanReadableCopyright`,Windows 卸载项 `Publisher` 与 `URLInfoAbout`;Linux 桌面文件无此字段)。界面语言英文与简体中文,以后再加。拼写词典只随英文:LibreOffice 的词典库没有中文(中文不用 Hunspell 拼写)。内核界面语言固定英文,函数名照英文;ElyTable 把内核英文的选区统计(Sum、Average 等七个固定名)换成界面语言,数值照内核。批注与修订的作者取各用户的账户全名(无全名用登录名,与内核自身次序一致),由补丁 0013 的 `setOption("author")` 在 worker 启动时设定;作者设置页随设置任务接入。邮件合并字段写法 `«列名»` 由我定(用户交我决定)。
|
||||
- 2026-10-01:更新清单是 JSON:`{"version", "assets": {"<os>-<arch>": {"url", "sha256"}}}`,只走 https(本机回环除外)。检查、下载、校验在 `ely_platform::update`,从不静默安装。包管理器安装的 Linux 版本不自更新。清单地址由设置给出,尚未配置。
|
||||
- 2026-10-05(用户定):ElyAgent 的后端与内置模型的登录配置以 Kigi CLI(https://github.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP;沿用其提供商登录(订阅账号或 API 密钥)与模型配置。
|
||||
- 2026-10-01:更新清单是 JSON:`{"version", "assets": {"<os>-<arch>": {"url", "sha256"}}}`,只走 https(本机回环除外)。检查、下载、校验在 `ely_platform::update`,从不静默安装。包管理器安装的 Linux 版本不自更新。2026-10-09 起(用户定:0.1.2 加自动更新):每个应用一份清单,`<slug>.json`,随发布上传,应用经 git.zacharyzhang.com 的 API 读最新发布(`api/v1/repos/ZacharyZhang-NY/ElyOffice/releases/latest`),在其文件里找 `<slug>.json`(`ely_platform::release::asset_url`;Gitea 没有 `releases/latest/download` 路径;`ELY_UPDATE_MANIFEST` 直接给清单地址,测试用);只在从 `.app` 运行时可用(开发构建没有);照 PRD §4.2 自动检查默认关闭:设置“通用”的“启动时检查更新”(通用设置 `check_updates`)打开后启动时查一次、查不到只记日志,应用菜单的“检查更新…”随时手动查(已是最新或查不到都以提示写明);有新版即在窗口上方显示组件库的 `UpdateBanner`(文案进内建中英目录,组件库 5d6efd4):横幅在标题栏下方(`Shell` 的页眉里,`Updates` 自己是一个视图);下载(进度)、校验散列、“重新启动以更新”只走正常的退出流程(有未保存的照常询问;取消即什么也不装,按钮仍在);应用真正退出时(`on_app_quit`)以本程序的安装模式(`--ely-install-update dmg 包 进程号`,`ely_ui::run` 最先识别)起一个独立进程:等本进程结束,`ely_platform::install` 挂载 dmg、把同名 .app 拷到旁边再换掉原包(换上前失败即放回原包),无论成败都 `open` 该包;失败的原因写在数据目录 `updates/failed.txt`,重开的应用以提示写明。下载完成只归属于它下载的那个版本;下载中或已下载时再查不覆盖。`packaging/macos/release.sh` 为每个应用写出清单(版本、本机架构的 dmg 地址与 SHA-256),dmg 里另有指向“应用程序”的替身,可直接拖入。新包换上即为装好,旧副本删不掉只记警告。`tests/scripts/update.sh`:装好的 ElyMark 在默认设置下启动不发任何请求;按“检查更新…”后从本机服务的 9.9.9 下载;有未保存的文字时“重新启动以更新”先问,取消则原包不动,“不保存”后装上新版并重新打开(新版本身的进程在跑)。
|
||||
- 2026-10-05(用户定):ElyAgent 的后端与内置模型的登录配置以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP;沿用其提供商登录(订阅账号或 API 密钥)与模型配置。
|
||||
- 2026-10-05(用户定):八个应用都不许卡顿:键入、选择、滚动、文档渲染都要即时(GPUI 的 120 FPS 手感),树莓派 3 上也要流畅。输入延迟与帧时间是验收项,要实测;界面线程不等内核、不持 worker 锁做同步请求,空闲不做事。
|
||||
- 2026-10-05:LOK 的输入与事件路径(实测后定)。原样:视图的事件监听长轮询 worker(持 worker 锁最多 50 毫秒,worker 读线程也随之阻塞最多 250 毫秒),按键排在锁后,键入锁等待中位 290 毫秒、最长 9 秒;输入队列在界面执行器上逐条跳回界面线程,每键约一帧,连续键入积压到 1 至 2 秒。现在:worker 有新事件即主动推送(`tag::EVENTS`,推送线程在输出锁下取批,批次即发送时状态),客户端读线程把回复与事件分开,事件存入 `client::Feed`,视图以异步方式等它(`Feed::next`,有新批次时唤醒,不占线程、不碰 worker 锁;原先每个打开的文档阻塞一个后台线程最多一秒,文档多于核数时会饿死输入与图块;监听任务归视图所有,视图释放即停);每个视图一条后台通道(`view/lane.rs`),输入优先、图块其次,连续执行,界面线程只收落地结果;文档尺寸与页框只在内核报尺寸变化时在后台读;批次弄脏的已显示图块即刻重绘,不等下一帧;视图的 `Reported` 事件只在内核回报或输入落地时发出,滚动与图块到达不触发应用的观察钩子;编辑与光标移动后应用要读的内核列表(ElyDoc 导航、字数、审阅,ElyTable 工作表与几何、单元格规则、格式面板)经 `ely_lok::read_off` 在后台读,一次一个,结果按发起者核对。实测(macOS,`tests/scripts/lag.sh`):按打字速度键入时输入落地 0 至 1 毫秒,图块 1 至 3 毫秒,按键到新图块 ElyDoc 中位 19 毫秒、ElyTable 3 毫秒;连续快速键入(脚本一次送出近百字)时,各次运行在 3 至 225 毫秒的中位之间摆动:新文档首次键入时内核有一次 150 至 230 毫秒的冷启动(首块图块与拼写等初始化),同一批字排在它之后;原为中位 1 秒、p95 1.9 秒。树莓派 3 未实测(本机无设备)。界面线程不碰 worker:应用对内核的读取、保存与另存为的导出、导出 PDF、比较、邮件合并都经 `Tabs::read_in`(或 `ely_lok::read_off`)在后台执行,回到窗口时按发起的标签与读取时的状态核对;保存导出期间标签状态为“正在保存”,同一标签的后续保存排队到它结束;另存为在写入前按文件身份再查一次目标是否已被别的标签持有;导出期间又有编辑时,“保存后关闭”不关闭,回到关闭询问;关闭标签时的撤销历史也在后台读;邮件合并预览与比较在发起时就保留自己的标签号与工作目录;读取失败不立即重试,等下一次状态变化;载入时一并在后台读版面与撤销历史(`ely_lok::layout`,`LokView::open` 接收版面)。窗口关闭回调先不放行,在后台逐个读标签的撤销历史并关闭(有改动的照常询问),最后一个标签关闭后移除窗口;内核文档的关闭也在后台(2026-10-06 起,原先同步读历史、在界面线程持 worker 锁关闭)。
|
||||
- 2026-10-04(用户定):八个应用的内容区一律随系统深浅色:Doc 页面、Table 网格、Show 幻灯片、PDF 页面、邮件正文、Mark 编辑区、Agent 输出。自动颜色随之翻转,文件里写明的颜色照原样;打印与导出 PDF 仍为浅色。各引擎(LOK、MuPDF、CEF)接入应用主题,不留引擎默认色。LOK:`ely.xcd` 定义 `COLOR_SCHEME_ELY_DARK` 与 `COLOR_SCHEME_ELY_LIGHT`(文档底色、自动文字色、网格、链接等取 Ely 默认色板;内核自带的深色方案颜色留空、按应用外观取默认值,传方案名不改外观,所以不用它),`LokView` 在主题深浅变化时经有序队列发 `.uno:ChangeTheme`(不算编辑),页面底色深色取 `surface`、浅色取 `paper`;Omarchy 色板与这两套固定色可能略有差别。导出 PDF 实测仍为白底。图块作废时旧图仍显示,新图到达后替换,编辑与主题切换都不闪。
|
||||
@@ -271,3 +274,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-09:ElyTable 新插图表的位置(R2.10)。内核按“定位区域”把图表放在其旁、并挤回当前可见区;带参数的请求(Ely 经 `FN_PARAM_5` 给区域)原把定位区域取为光标格,图表压在数据上或旁边的另一块数据上。补丁 `0041-lok-calc-chart-beside-its-data.patch`:LOK 下有请求区域时,图表放进数据区右侧(隔一列)第一块够大、无合并格的空单元格(逐列向右找,跨过旁边的数据块),右侧没有依次找下方、左侧、上方,再按行带找全表第一块够大的空处(尺寸从候选格起累加,表尾也快),不受可见区限制;空处按图表所在的工作表查,数据在别的表时从光标起找;整张表没有空处即插入失败、文档不动;`LokView` 在新选中对象(原先无选中)时把它滚入视口,拖动中的对象不滚动。
|
||||
- 2026-10-09:ElyPDF 编辑与缩放不闪白(R2.6)。旧图块不再随编辑或比例变化即刻丢弃:编辑落地时全部当前图块、比例变化时离开视图的同页图块,连同当时的页面尺寸进入“底图”(`apps/ely-pdf/src/backdrop.rs`),按其在页面中的比例位置画在该页新图块之下,直到该页在视口里的新图块到齐、或页面尺寸与旋转变了才释放;每页画什么由 `PdfView::pieces` 决定,绘制与测试同用它。只有改内容的编辑保留底图并沿用已测的页面尺寸(前 100 页以外不再被占位尺寸覆盖);增删、移动、旋转、裁剪页面与撤销重做(`PdfView::edit_pages`)清空底图,因同一序号可能已是另一页。关闭标签时底图随图块交给窗口释放。原先比较整份尺寸表(超过 100 页的混合纸张文档尺寸表后段为占位,比较失败即全部清空)且只认同比例的图块(编辑后右栏收起、比例变化即清空),都会闪白。
|
||||
- 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
|
||||
- 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。
|
||||
- 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。
|
||||
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
|
||||
- 2026-10-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 地址取。
|
||||
@@ -85,6 +85,14 @@ impl Host for Docs {
|
||||
self.offer_spelling(id, view, cx);
|
||||
}
|
||||
|
||||
/// A dialog about a closed tab goes with it.
|
||||
fn tab_closed(&mut self, tab: &ely_office::Tab<Extra, ely_office::Lok>, cx: &mut Context<Self>) {
|
||||
if matches!(self.dialog, Some(Dialog::Output { tab: of, .. }) if of == tab.id) {
|
||||
self.dialog = None;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn search_leaves(&mut self, cx: &mut Context<Self>) {
|
||||
self.mark_place(cx);
|
||||
}
|
||||
@@ -126,7 +134,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let root = ely_ui::document_root(app, docs.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
|
||||
docs.update(cx, |docs, cx| {
|
||||
docs.core.root = root.downgrade();
|
||||
docs.core.window = Some(window.window_handle());
|
||||
docs.own_window(window, cx);
|
||||
window.focus(&docs.core.focus, cx);
|
||||
});
|
||||
let launched = ely_ui::launch_files(cx);
|
||||
@@ -209,7 +217,6 @@ impl Render for Docs {
|
||||
.on_action(cx.listener(Self::on_open))
|
||||
.on_action(cx.listener(Self::on_save))
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_quit))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
|
||||
@@ -36,6 +36,10 @@ pub fn menu(cx: &gpui::App) -> gpui::Menu {
|
||||
pub struct Merge {
|
||||
docs: WeakEntity<Docs>,
|
||||
pub table: Option<Table>,
|
||||
/// The template's tab, active when the data was chosen.
|
||||
pub source: Option<u64>,
|
||||
/// Tabs opened as previews; none is a template.
|
||||
pub previews: std::collections::HashSet<u64>,
|
||||
/// The record to preview, from 1.
|
||||
record: usize,
|
||||
pdf: bool,
|
||||
@@ -46,6 +50,8 @@ impl Merge {
|
||||
Merge {
|
||||
docs,
|
||||
table: None,
|
||||
source: None,
|
||||
previews: std::collections::HashSet::new(),
|
||||
record: 1,
|
||||
pdf: false,
|
||||
}
|
||||
@@ -62,11 +68,20 @@ impl Docs {
|
||||
}
|
||||
|
||||
pub(crate) fn choose_csv(&mut self, cx: &mut Context<Self>) {
|
||||
self.pick(false, cx, |docs, path, cx| match read_csv(&path) {
|
||||
let Some(source) = self.core.active else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
};
|
||||
if self.merge.read(cx).previews.contains(&source) {
|
||||
let text = Docs::text(cx, "doc.merge.from_preview", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
self.pick(false, cx, move |docs, path, cx| match read_csv(&path) {
|
||||
Ok(table) => {
|
||||
log::info!("doc: merge data has {} rows", table.rows.len());
|
||||
docs.merge.update(cx, |merge, cx| {
|
||||
merge.table = Some(table);
|
||||
merge.source = Some(source);
|
||||
merge.record = 1;
|
||||
cx.notify();
|
||||
});
|
||||
@@ -84,20 +99,20 @@ impl Docs {
|
||||
});
|
||||
}
|
||||
|
||||
/// The tab a merge starts from: the active one.
|
||||
/// The template's tab, while it is open.
|
||||
fn source(&self, cx: &mut Context<Self>) -> Option<u64> {
|
||||
if self.core.active.is_none() {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
let source = self.merge.read(cx).source.filter(|source| self.tab(*source).is_some());
|
||||
if source.is_none() {
|
||||
let text = Docs::text(cx, "doc.merge.gone", &[]);
|
||||
self.toast(text, Severity::Warning, cx);
|
||||
}
|
||||
self.core.active
|
||||
source
|
||||
}
|
||||
|
||||
/// Types a field's marker at the cursor.
|
||||
/// Types a field's marker at the template's cursor.
|
||||
pub(crate) fn insert_field(&mut self, header: &str, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
let Some(tab) = self.source(cx).and_then(|source| self.tab(source)) else {
|
||||
return;
|
||||
};
|
||||
tab.view.update(cx, |view, _| view.insert(&marker(header)));
|
||||
}
|
||||
@@ -194,7 +209,12 @@ impl Render for Merge {
|
||||
})
|
||||
};
|
||||
let row = || div().flex().flex_wrap().items_center().gap_1();
|
||||
let template = self.source.and_then(|source| {
|
||||
let docs = self.docs.upgrade()?;
|
||||
Some(docs.read(cx).tab(source)?.name.clone())
|
||||
});
|
||||
let group = PropertyGroup::new(table.name.clone())
|
||||
.row(text("doc.merge.template"), div().child(template.unwrap_or_else(|| text("doc.merge.gone"))))
|
||||
.row(text("doc.merge.data"), choose)
|
||||
.row(text("doc.merge.fields"), row().children(fields))
|
||||
.row(
|
||||
|
||||
@@ -164,6 +164,7 @@ impl Docs {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
log::info!("doc: previewing merge record {}", at + 1);
|
||||
self.merge.update(cx, |merge, _| merge.previews.insert(into));
|
||||
self.load_into(into, Source::Copy(copy), cx);
|
||||
self.core.waiting.push((
|
||||
into,
|
||||
|
||||
@@ -22,9 +22,9 @@ fn records_fill_previews_and_files() {
|
||||
let csv = dir.path().join("people.csv");
|
||||
std::fs::write(&csv, "Name,City\nAda,London\n«City»,上海\n").expect("the csv writes");
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
|
||||
type_into(&mut cx, &docs, " of ");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("City", cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("City", cx)));
|
||||
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
@@ -171,7 +171,7 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
|
||||
let table = read_csv(&csv).expect("the csv reads");
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.insert_field("Name", cx);
|
||||
docs.field_here("Name", cx);
|
||||
docs.preview_record(source, &table, 0, cx);
|
||||
docs.core.active = Some(other);
|
||||
})
|
||||
@@ -191,6 +191,14 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
|
||||
});
|
||||
let preview = dir.path().join("preview.odt");
|
||||
let id = cx.update(|app| docs.read(app).core.tabs[2].id);
|
||||
// The preview is active now; the template stays.
|
||||
cx.update(|app| {
|
||||
docs.update(app, |docs, cx| {
|
||||
docs.core.active = Some(id);
|
||||
assert_eq!(docs.source(cx), Some(source), "the template stays after a preview");
|
||||
assert!(docs.merge.read(cx).previews.contains(&id), "the preview is known");
|
||||
})
|
||||
});
|
||||
with_window(&mut cx, &docs, |docs, window, cx| {
|
||||
docs.write_as(id, preview.clone(), None, window, cx)
|
||||
});
|
||||
@@ -227,7 +235,7 @@ fn two_generations_keep_their_own_templates() {
|
||||
for text in ["SourceA ", "SourceB "] {
|
||||
sources.push(blank(&mut cx, &docs));
|
||||
type_into(&mut cx, &docs, text);
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
|
||||
settled(&cx, &docs);
|
||||
}
|
||||
let csv = dir.path().join("one.csv");
|
||||
@@ -265,3 +273,40 @@ fn two_generations_keep_their_own_templates() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_template_chosen_with_the_data_stays_through_a_preview() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let template = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "Dear ");
|
||||
let csv = dir.path().join("two.csv");
|
||||
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.choose_csv(cx)));
|
||||
cx.simulate_path_prompt_response(move |_| Some(vec![csv]));
|
||||
settle(&cx, |app| docs.read(app).merge.read(app).table.is_some());
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
|
||||
let table = cx.update(|app| docs.read(app).merge.read(app).table.clone().expect("the data"));
|
||||
// As the panel's buttons do: preview, then generate.
|
||||
cx.update(|app| docs.update(app, |docs, cx| {
|
||||
let source = docs.source(cx).expect("the template");
|
||||
docs.preview_record(source, &table, 0, cx);
|
||||
}));
|
||||
settle(&cx, |app| tabs(app, &docs) == 2 && docs.read(app).core.active != Some(template));
|
||||
let out = dir.path().join("out");
|
||||
std::fs::create_dir(&out).expect("the folder");
|
||||
cx.update(|app| docs.update(app, |docs, cx| {
|
||||
let source = docs.source(cx).expect("the template, not the preview");
|
||||
assert_eq!(source, template);
|
||||
docs.generate(source, table.clone(), false, out.clone(), cx);
|
||||
}));
|
||||
let stem = cx.update(|app| docs.read(app).tab(template).expect("a tab").name.to_string());
|
||||
let target = |n: usize| out.join(format!("{stem} {n}.docx"));
|
||||
settle(&cx, |_| target(2).exists());
|
||||
let (first, second) = (text_of(&mut cx, &docs, &target(1)), text_of(&mut cx, &docs, &target(2)));
|
||||
assert!(first.contains("Dear Ada") && second.contains("Dear Grace"), "{first:?} {second:?}");
|
||||
}
|
||||
@@ -29,7 +29,7 @@ impl Docs {
|
||||
self.ask_output(true, cx);
|
||||
}
|
||||
|
||||
fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
|
||||
pub(crate) fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.active_tab() else {
|
||||
let text = Docs::text(cx, "command.blocked.no_document", &[]);
|
||||
return self.toast(text, Severity::Warning, cx);
|
||||
|
||||
@@ -463,3 +463,12 @@ fn an_outside_change_asks_and_keep_mine_overwrites() {
|
||||
mod find;
|
||||
mod more;
|
||||
mod overlap;
|
||||
|
||||
impl Docs {
|
||||
/// Chooses the active tab as template, then types a field.
|
||||
pub(crate) fn field_here(&mut self, header: &str, cx: &mut gpui::Context<Self>) {
|
||||
let active = self.core.active;
|
||||
self.merge.update(cx, |merge, _| merge.source = active);
|
||||
self.insert_field(header, cx);
|
||||
}
|
||||
}
|
||||
@@ -215,3 +215,51 @@ fn an_edit_that_just_landed_still_asks_and_selection_does_not() {
|
||||
});
|
||||
settle(&cx, |app| docs.read(app).tab(id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quit_from_either_window_asks_first() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let id = blank(&mut cx, &docs);
|
||||
commit(&mut cx, &docs, "Unsaved");
|
||||
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
|
||||
settle(&cx, |app| view.read(app).settled());
|
||||
cx.update(ely_ui::commands::bind_quit);
|
||||
let home = cx.add_window(|_, _| Blank);
|
||||
cx.update_window(home.into(), |_, window, app| docs.update(app, |docs, cx| docs.own_window(window, cx)))
|
||||
.expect("the window lives");
|
||||
// The settings window has no quit of its own.
|
||||
let settings = cx.add_window(|_, _| Blank);
|
||||
for from in [home.into(), settings.into()] {
|
||||
cx.dispatch_action(from, ely_ui::commands::Quit);
|
||||
settle(&cx, |app| matches!(docs.read(app).core.dialog, Some(Dialog::Unsaved { .. })));
|
||||
assert!(cx.update(|app| docs.read(app).tab(id).is_some()), "the tab waits for an answer");
|
||||
assert_eq!(cx.update(|app| app.active_window()), Some(home.into()), "the question's window comes forward");
|
||||
// Cancel, as the dialog's button does.
|
||||
cx.update(|app| docs.update(app, |docs, cx| {
|
||||
docs.close_cancelled(cx);
|
||||
docs.core.dialog = None;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_tab_takes_its_export_dialog() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(docs) = docs(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let id = blank(&mut cx, &docs);
|
||||
cx.update(|app| docs.update(app, |docs, cx| docs.ask_output(false, cx)));
|
||||
assert!(cx.update(|app| matches!(docs.read(app).dialog, Some(crate::actions::Dialog::Output { .. }))));
|
||||
with_window(&mut cx, &docs, |docs, window, cx| docs.request_close(id, After::Tab, window, cx));
|
||||
settle(&cx, |app| docs.read(app).tab(id).is_none());
|
||||
// Drawn now, a dialog for the gone tab would crash.
|
||||
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "the dialog went with its tab");
|
||||
}
|
||||
@@ -86,10 +86,10 @@ fn interleaved_previews_keep_their_sources() {
|
||||
};
|
||||
let alpha = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceAlpha ");
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
|
||||
let beta = blank(&mut cx, &docs);
|
||||
type_into(&mut cx, &docs, "SourceBeta ");
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
|
||||
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
|
||||
settled(&cx, &docs);
|
||||
let csv = dir.path().join("two.csv");
|
||||
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
|
||||
|
||||
@@ -29,6 +29,14 @@ impl Desk {
|
||||
return;
|
||||
}
|
||||
tab.extra.heard = changed;
|
||||
self.save_after_pause(id, cx);
|
||||
}
|
||||
|
||||
/// Counts unsaved text as an edit; saves after the pause.
|
||||
pub fn save_after_pause(&mut self, id: u64, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
tab.extra.policy.edit(Instant::now());
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let wait = DEBOUNCE + Duration::from_millis(50);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use ely_files::{Fingerprint, Policy, PolicyEngine};
|
||||
use ely_files::Fingerprint;
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
git::{self as merging, Region, Take, ThreeWayMerge},
|
||||
@@ -102,7 +102,7 @@ impl Desk {
|
||||
Choice::Mine => {
|
||||
if let Some(tab) = self.tab_mut(id) {
|
||||
tab.session.rebase(fingerprint);
|
||||
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
|
||||
tab.extra.restart_autosave();
|
||||
}
|
||||
self.set_outside(id, Outside::Clear, cx);
|
||||
self.save(id, None, window, cx);
|
||||
@@ -120,7 +120,7 @@ impl Desk {
|
||||
return self.set_outside(id, Outside::Unread(why), cx);
|
||||
}
|
||||
tab.extra.base = disk.text.clone();
|
||||
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
|
||||
tab.extra.restart_autosave();
|
||||
tab.doc.lock().expect("the text lock").source = disk;
|
||||
let view = tab.view.clone();
|
||||
view.update(cx, |view, cx| view.adopt(merged, cx));
|
||||
|
||||
@@ -81,6 +81,11 @@ impl Host for Desk {
|
||||
&mut self.core
|
||||
}
|
||||
|
||||
/// A quit records the open tabs first.
|
||||
fn quitting(&mut self, cx: &mut Context<Self>) {
|
||||
self.leave(cx);
|
||||
}
|
||||
|
||||
/// A quit or close stopped: the kept list follows again.
|
||||
fn close_cancelled(&mut self, cx: &mut Context<Self>) {
|
||||
self.keeping.leaving = false;
|
||||
@@ -215,7 +220,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let asked = ely_ui::launch_files(cx);
|
||||
desk.update(cx, |desk, cx| {
|
||||
desk.core.root = root.downgrade();
|
||||
desk.core.window = Some(window.window_handle());
|
||||
desk.own_window(window, cx);
|
||||
window.focus(&desk.core.focus, cx);
|
||||
// Launch files stand alone; unsaved text still returns.
|
||||
if asked.is_empty() {
|
||||
@@ -403,10 +408,6 @@ impl Render for Desk {
|
||||
.on_action(cx.listener(Self::on_export))
|
||||
.on_action(cx.listener(Self::on_print))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.on_action(cx.listener(|desk, quit, window, cx| {
|
||||
desk.leave(cx);
|
||||
desk.on_quit(quit, window, cx)
|
||||
}))
|
||||
.on_action(cx.listener(|desk, _: &InsertTable, _, cx| desk.table("insert", cx)))
|
||||
.on_action(cx.listener(|desk, _: &RowAbove, _, cx| desk.table("row-above", cx)))
|
||||
.on_action(cx.listener(|desk, _: &RowBelow, _, cx| desk.table("row-below", cx)))
|
||||
|
||||
@@ -55,6 +55,14 @@ pub struct Extra {
|
||||
pub restored: bool,
|
||||
}
|
||||
|
||||
impl Extra {
|
||||
/// Autosave from scratch; a write in flight no longer counts.
|
||||
pub fn restart_autosave(&mut self) {
|
||||
self.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
|
||||
self.writing = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Extra {
|
||||
fn default() -> Self {
|
||||
Extra {
|
||||
@@ -217,11 +225,26 @@ impl Desk {
|
||||
Ok(read) => read,
|
||||
Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx),
|
||||
};
|
||||
// Keys the page has not sent yet count first.
|
||||
if !self.settled(id, cx) {
|
||||
return self.when_settled(id, move |desk, _, cx| desk.disk_read(id, path, Ok((fingerprint, disk)), cx));
|
||||
}
|
||||
let current = tab.doc.lock().expect("the text lock").current.clone();
|
||||
let kept = disk.text == tab.extra.base;
|
||||
let dirty = tab.dirty(cx);
|
||||
log::info!("mark: {} changed outside; unsaved {dirty}", path.display());
|
||||
match (disk.text == current, dirty) {
|
||||
(true, _) | (false, false) => self.take_disk(id, disk, fingerprint, cx),
|
||||
// The same text: only the file underneath is new.
|
||||
(true, _) => {
|
||||
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
|
||||
// What shows is now saved: the tab is clean.
|
||||
let saved = crate::engine::mark(&disk.text);
|
||||
view.update(cx, |view, cx| view.saved(&saved, cx));
|
||||
}
|
||||
}
|
||||
// The text as last saved: the edits stay unsaved.
|
||||
(false, true) if kept => self.keep_edits(id, disk, fingerprint, cx),
|
||||
(false, false) => self.take_disk(id, disk, fingerprint, cx),
|
||||
(false, true) => {
|
||||
let tab = self.tab_mut(id).expect("open");
|
||||
tab.session.external_modified();
|
||||
@@ -247,14 +270,47 @@ impl Desk {
|
||||
fingerprint: Fingerprint,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
|
||||
view.update(cx, |view, cx| view.refresh(disk, cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// A new file with the saved text; edits stay.
|
||||
fn keep_edits(&mut self, id: u64, disk: Source, fingerprint: Fingerprint, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.tab_mut(id) else {
|
||||
return;
|
||||
};
|
||||
tab.session.rebase(fingerprint);
|
||||
tab.doc.lock().expect("the text lock").source = disk;
|
||||
// A conflict stopped autosave, maybe mid-write; it resumes.
|
||||
tab.extra.restart_autosave();
|
||||
let (registry, path) = (tab.registry, tab.session.path().map(Path::to_path_buf));
|
||||
if let (Some(registry), Some(path)) = (registry, path)
|
||||
&& let Err(error) = self.core.sessions.track(registry, &path)
|
||||
{
|
||||
log::warn!("mark: the replaced file did not register: {error}");
|
||||
}
|
||||
self.set_outside(id, Outside::Clear, cx);
|
||||
self.save_after_pause(id, cx);
|
||||
}
|
||||
|
||||
/// The tab takes the file as saved; returns its view.
|
||||
fn adopt_disk(
|
||||
&mut self,
|
||||
id: u64,
|
||||
disk: &Source,
|
||||
fingerprint: Fingerprint,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<gpui::Entity<crate::view::MarkView>> {
|
||||
let tab = self.tab_mut(id)?;
|
||||
if let Err(error) = tab.session.reload_at(fingerprint) {
|
||||
return self.set_outside(id, Outside::Unread(error.to_string()), cx);
|
||||
self.set_outside(id, Outside::Unread(error.to_string()), cx);
|
||||
return None;
|
||||
}
|
||||
tab.extra.base = disk.text.clone();
|
||||
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
|
||||
tab.extra.restart_autosave();
|
||||
// Its bytes (BOM, line ends) are what saves keep now.
|
||||
tab.doc.lock().expect("the text lock").source = disk.clone();
|
||||
let (view, registry) = (tab.view.clone(), tab.registry);
|
||||
let path = tab.session.path().map(Path::to_path_buf);
|
||||
// An atomic replace is a new file underneath.
|
||||
@@ -263,8 +319,8 @@ impl Desk {
|
||||
{
|
||||
log::warn!("mark: the replaced file did not register: {error}");
|
||||
}
|
||||
view.update(cx, |view, cx| view.refresh(disk, cx));
|
||||
self.set_outside(id, Outside::Clear, cx);
|
||||
Some(view)
|
||||
}
|
||||
|
||||
pub fn set_outside(&mut self, id: u64, outside: Outside, cx: &mut Context<Self>) {
|
||||
@@ -345,3 +401,29 @@ fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<Recommended
|
||||
log::info!("mark: watching {} folders", dirs.len());
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ely_files::Action;
|
||||
|
||||
use super::Extra;
|
||||
|
||||
#[test]
|
||||
fn a_restarted_autosave_saves_the_next_edit() {
|
||||
let mut extra = Extra::default();
|
||||
extra.policy.set_has_path();
|
||||
extra.policy.edit(Instant::now());
|
||||
// A write was in flight when the disk changed.
|
||||
extra.writing = Some(1);
|
||||
extra.restart_autosave();
|
||||
// As autosave settles a heard write, then edits.
|
||||
if let Some(revision) = extra.writing.take() {
|
||||
extra.policy.save_finished(revision, true);
|
||||
}
|
||||
let now = Instant::now();
|
||||
extra.policy.edit(now);
|
||||
assert!(matches!(extra.policy.tick(now + Duration::from_secs(5)), Action::SaveDue(_)));
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use crate::pages::{rect_on, to_view};
|
||||
use crate::view::PdfView;
|
||||
|
||||
/// How far a click may move, in window pixels.
|
||||
const CLICK: f32 = 3.0;
|
||||
pub(crate) const CLICK: f32 = 3.0;
|
||||
/// Pages read for their annotations at once.
|
||||
const BATCH: usize = 20;
|
||||
|
||||
@@ -253,8 +253,9 @@ impl PdfView {
|
||||
if annots.is_empty() {
|
||||
return;
|
||||
}
|
||||
let author = ely_ui::settings::current(cx).author.clone();
|
||||
self.settled_edit(what, cx, move |worker, doc| {
|
||||
let name = ely_platform::user::name()?;
|
||||
let name = ely_platform::user::named(&author)?;
|
||||
annots.iter_mut().for_each(|annot| annot.author = name.clone());
|
||||
worker.annotate(doc, &annots)
|
||||
});
|
||||
|
||||
@@ -136,7 +136,7 @@ impl Viewer {
|
||||
}
|
||||
|
||||
fn failed_write(&self, name: &str, error: &io::Error, cx: &mut App) {
|
||||
if error.kind() == io::ErrorKind::PermissionDenied {
|
||||
if ely_pdf_engine::refused(error).is_some() {
|
||||
return self.failed(error, cx);
|
||||
}
|
||||
let text = Self::text(cx, "office.error.save", &[("name", name), ("error", &error.to_string())]);
|
||||
|
||||
@@ -76,6 +76,10 @@ pub struct Drag {
|
||||
pub from: (f32, f32),
|
||||
pub now: (f32, f32),
|
||||
pub scale: bool,
|
||||
/// Where the press landed, in window pixels.
|
||||
pub pressed: (f32, f32),
|
||||
/// The pointer went past a click's slack.
|
||||
pub moved: bool,
|
||||
}
|
||||
|
||||
/// The corner handle's reach, in page points.
|
||||
@@ -107,6 +111,11 @@ fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool {
|
||||
x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]
|
||||
}
|
||||
|
||||
/// Whether `outer` holds all of `inner`.
|
||||
fn covers(outer: &[f32; 4], inner: &[f32; 4]) -> bool {
|
||||
outer[0] <= inner[0] && outer[1] <= inner[1] && outer[2] >= inner[2] && outer[3] >= inner[3]
|
||||
}
|
||||
|
||||
impl PdfView {
|
||||
/// A drag or drawing is under way.
|
||||
pub fn gesturing(&self) -> bool {
|
||||
@@ -174,16 +183,21 @@ impl PdfView {
|
||||
Some(Aim::Object { page: on, index }) if on == page => layer.drawn.get(index).filter(|d| corner(&d.rect)).map(|_| (index, true)),
|
||||
_ => None,
|
||||
};
|
||||
let hit = held.or_else(|| layer.drawn.iter().rposition(|d| inside(&d.rect, point)).map(|index| (index, false)));
|
||||
let text = layer.blocks.iter().position(|b| b.editable && inside(&b.rect, point));
|
||||
// Text wins over an object drawn all around it.
|
||||
let around = |rect: &[f32; 4]| text.is_some_and(|t| covers(rect, &layer.blocks[t].rect));
|
||||
let hit = held.or_else(|| {
|
||||
let object = layer.drawn.iter().rposition(|d| inside(&d.rect, point) && !around(&d.rect));
|
||||
object.map(|index| (index, false))
|
||||
});
|
||||
self.aim = match hit {
|
||||
Some((index, scale)) => {
|
||||
let rect = layer.drawn[index].rect;
|
||||
self.drag = Some(Drag { page, index, rect, from: point, now: point, scale });
|
||||
let pressed = (f32::from(at.x), f32::from(at.y));
|
||||
self.drag = Some(Drag { page, index, rect, from: point, now: point, scale, pressed, moved: false });
|
||||
Some(Aim::Object { page, index })
|
||||
}
|
||||
None => (layer.blocks.iter())
|
||||
.position(|b| b.editable && inside(&b.rect, point))
|
||||
.map(|index| Aim::Text { page, index }),
|
||||
None => text.map(|index| Aim::Text { page, index }),
|
||||
};
|
||||
cx.notify();
|
||||
}
|
||||
@@ -193,6 +207,8 @@ impl PdfView {
|
||||
return;
|
||||
};
|
||||
if let Some(drag) = self.drag.as_mut().filter(|drag| drag.page == page) {
|
||||
let far = (f32::from(at.x) - drag.pressed.0).abs() + (f32::from(at.y) - drag.pressed.1).abs();
|
||||
drag.moved |= far >= crate::annotate::CLICK;
|
||||
drag.now = point;
|
||||
cx.notify();
|
||||
}
|
||||
@@ -203,7 +219,8 @@ impl PdfView {
|
||||
let Some(drag) = self.drag.take() else {
|
||||
return;
|
||||
};
|
||||
if (drag.now.0 - drag.from.0).abs() < 1.0 && (drag.now.1 - drag.from.1).abs() < 1.0 {
|
||||
// A hand that shakes a little still clicks.
|
||||
if !drag.moved {
|
||||
return cx.notify();
|
||||
}
|
||||
// A queued edit may renumber what was dragged.
|
||||
@@ -264,7 +281,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_drag_moves_or_scales_from_the_top_left() {
|
||||
let mut drag = Drag { page: 0, index: 0, rect: [10.0, 20.0, 110.0, 70.0], from: (50.0, 40.0), now: (60.0, 45.0), scale: false };
|
||||
let mut drag = Drag { page: 0, index: 0, rect: [10.0, 20.0, 110.0, 70.0], from: (50.0, 40.0), now: (60.0, 45.0), scale: false, pressed: (0.0, 0.0), moved: true };
|
||||
assert_eq!(drag.change(), [1.0, 0.0, 0.0, 1.0, 10.0, 5.0]);
|
||||
assert_eq!(drag.shown(), [20.0, 25.0, 120.0, 75.0]);
|
||||
drag = Drag { from: (110.0, 70.0), now: (210.0, 80.0), scale: true, ..drag };
|
||||
|
||||
@@ -115,9 +115,14 @@ impl PdfView {
|
||||
// A page may now stand where another stood.
|
||||
self.retire_tiles();
|
||||
self.sizes = measured;
|
||||
self.thumbs_stale.clear();
|
||||
for (_, image) in self.thumbs.drain() {
|
||||
self.retired.push(image);
|
||||
}
|
||||
} else {
|
||||
// Same pages: old tiles stay under them until repainted.
|
||||
// Same pages: old pictures stay until repainted.
|
||||
self.keep_all();
|
||||
self.thumbs_stale.extend(self.thumbs.keys().copied());
|
||||
let read = fresh.first.len();
|
||||
self.sizes[..read].copy_from_slice(&measured[..read]);
|
||||
}
|
||||
@@ -125,9 +130,6 @@ impl PdfView {
|
||||
self.failed.clear();
|
||||
self.text.clear();
|
||||
self.asking_text.clear();
|
||||
for (_, image) in self.thumbs.drain() {
|
||||
self.retired.push(image);
|
||||
}
|
||||
self.thumbs_failed.clear();
|
||||
// Indices shift with any edit; aims start over.
|
||||
(self.layers, self.aim, self.drag) = (Default::default(), None, None);
|
||||
|
||||
@@ -46,9 +46,9 @@ fn masked(window: &mut Window, cx: &mut Context<Viewer>) -> Entity<TextInput> {
|
||||
impl Viewer {
|
||||
/// A refusal names what is missing.
|
||||
pub(crate) fn failed(&self, error: &io::Error, cx: &mut App) {
|
||||
match error.kind() {
|
||||
io::ErrorKind::PermissionDenied => self.toast(crate::view::restricted(&error.to_string(), cx), Severity::Warning, cx),
|
||||
_ => self.panel_failed(&error.to_string(), cx),
|
||||
match ely_pdf_engine::refused(error) {
|
||||
Some(needed) => self.toast(crate::view::restricted(needed, cx), Severity::Warning, cx),
|
||||
None => self.panel_failed(&error.to_string(), cx),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Page pictures, and bookmarks if any (PDF-01, PDF-02).
|
||||
|
||||
use std::{collections::BTreeSet, ops::Range};
|
||||
use std::{collections::BTreeSet, ops::Range, sync::Arc};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::SegmentedControl,
|
||||
@@ -9,7 +9,7 @@ use ely_gpui_component::{
|
||||
theme::{ActiveTheme as _, ControlSize},
|
||||
};
|
||||
use gpui::{
|
||||
Action, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, Role,
|
||||
Action, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, RenderImage, Role,
|
||||
StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, div, img, prelude::FluentBuilder as _, px,
|
||||
uniform_list,
|
||||
};
|
||||
@@ -104,7 +104,8 @@ impl PdfView {
|
||||
if self.thumbing {
|
||||
return;
|
||||
}
|
||||
let Some(page) = rows.clone().find(|page| !self.thumbs.contains_key(&(*page, dark)) && !self.thumbs_failed.contains(page)) else {
|
||||
let due = |page: &usize| !self.thumbs.contains_key(&(*page, dark)) || self.thumbs_stale.contains(&(*page, dark));
|
||||
let Some(page) = rows.clone().find(|page| due(page) && !self.thumbs_failed.contains(page)) else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = self.sizes[page];
|
||||
@@ -127,6 +128,7 @@ impl PdfView {
|
||||
});
|
||||
match painted.map(|bgra| crate::pages::picture((tile[2] as u32, tile[3] as u32), bgra)) {
|
||||
Ok(Some(image)) => {
|
||||
view.thumbs_stale.remove(&(page, dark));
|
||||
if let Some(old) = view.thumbs.insert((page, dark), image) {
|
||||
view.retired.push(old);
|
||||
}
|
||||
@@ -145,13 +147,20 @@ impl PdfView {
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfView {
|
||||
/// The picture a row draws; an old one until repainted.
|
||||
pub fn picture(&self, page: usize, dark: bool) -> Option<Arc<RenderImage>> {
|
||||
self.thumbs.get(&(page, dark)).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// One row: the page number and its picture.
|
||||
fn row(view: &Entity<PdfView>, page: usize, dark: bool, cx: &App) -> gpui::AnyElement {
|
||||
let read = view.read(cx);
|
||||
let colors = &cx.theme().colors;
|
||||
let (w, h) = read.sizes[page];
|
||||
let picked = read.picked.contains(&page);
|
||||
let picture = read.thumbs.get(&(page, dark)).cloned();
|
||||
let picture = read.picture(page, dark);
|
||||
let count = read.sizes.len();
|
||||
let label = Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]);
|
||||
let (clicked, pressed, dropped) = (view.clone(), view.clone(), view.clone());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Editing text and objects from the app (C11.3).
|
||||
|
||||
use ely_pdf_engine::sample::{Extras, pdf_with};
|
||||
use ely_pdf_engine::sample::{Extras, one_page, pdf_with};
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::input::over;
|
||||
@@ -339,3 +339,61 @@ fn a_drag_follows_the_hand_when_the_page_shifts_under_it() {
|
||||
let rect = cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect);
|
||||
assert!((rect[0] - 122.0).abs() < 0.5, "moved as far as the hand: {rect:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_a_full_page_picture_aims_at_the_text_and_a_shake_stays_a_click() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("scan.pdf");
|
||||
let extras = Extras { picture: true, backdrop: true, ..Extras::default() };
|
||||
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &extras)).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.zoom = crate::layout::Zoom::Scale(1.0);
|
||||
view.set_tool(Tool::Edit, cx)
|
||||
}));
|
||||
settle(cx, |app| view.read(app).layers.contains_key(&0));
|
||||
click(cx, &view, (80.0, 66.0));
|
||||
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the picture under it");
|
||||
// A two-pixel wobble on the picture moves nothing.
|
||||
let from = over(cx, &view, 0, (300.0, 400.0));
|
||||
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
|
||||
let to = from + gpui::point(gpui::px(2.0), gpui::px(0.0));
|
||||
cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none());
|
||||
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
|
||||
frames(cx);
|
||||
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { .. })), "the picture is aimed");
|
||||
assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "and not moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_picture_on_text_under_a_page_form_still_aims() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = dir.path().join("layered.pdf");
|
||||
// The page-size form comes last, on top of both.
|
||||
let content = "q 10 0 0 10 80 722 cm /Im1 Do Q BT /F1 18 Tf 72 720 Td (Hello world) Tj ET q /Fm1 Do Q";
|
||||
let form = "0 0 1 rg 500 50 20 20 re f";
|
||||
let objects = [
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{form}\nendstream", form.len()),
|
||||
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream".to_string(),
|
||||
];
|
||||
let page = " /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R /Im1 7 0 R >> >>";
|
||||
std::fs::write(&path, one_page("", page, content, &objects)).expect("writes");
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
let view = shown(cx, &viewer);
|
||||
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.zoom = crate::layout::Zoom::Scale(1.0);
|
||||
view.set_tool(Tool::Edit, cx)
|
||||
}));
|
||||
settle(cx, |app| view.read(app).layers.contains_key(&0));
|
||||
// The picture sits at 80..90 across, 60..70 down.
|
||||
click(cx, &view, (85.0, 65.0));
|
||||
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { index: 0, .. })), "the small picture");
|
||||
click(cx, &view, (140.0, 66.0));
|
||||
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })), "the text, not the form around it");
|
||||
}
|
||||
@@ -326,3 +326,31 @@ fn old_tiles_are_painted_and_freed_with_their_tab() {
|
||||
cx.update(|_, app| view.update(app, |view, _| view.retire_all()));
|
||||
assert!(cx.update(|_, app| view.read(app).backdrop.is_empty()), "closing hands every old tile over");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_keeps_page_pictures_until_they_repaint() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["Hello"])])], true);
|
||||
let view = shown(cx, &viewer);
|
||||
let dark = cx.update(|_, app| ely_gpui_component::theme::ActiveTheme::theme(app).is_dark());
|
||||
settle(cx, |app| view.read(app).picture(0, dark).is_some() && repainted(view.read(app)));
|
||||
let old = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
|
||||
// A picture on its way holds back the repaint.
|
||||
cx.update(|_, app| view.update(app, |view, _| view.thumbing = true));
|
||||
cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![note()], cx)));
|
||||
settle(cx, |app| view.read(app).edited == 1 && repainted(view.read(app)));
|
||||
// Dropped from the atlas, it returns only if drawn.
|
||||
cx.update(|window, _| window.drop_image(old.clone())).expect("drops");
|
||||
super::frames(cx);
|
||||
assert!(cx.update(|window, _| window.has_image_atlas_entry(&old)), "the old picture shows meanwhile");
|
||||
cx.update(|_, app| view.update(app, |view, cx| {
|
||||
view.thumbing = false;
|
||||
cx.notify()
|
||||
}));
|
||||
settle(cx, |app| view.read(app).thumbs_stale.is_empty());
|
||||
let new = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
|
||||
assert!(!std::sync::Arc::ptr_eq(&new, &old), "the new picture replaces it");
|
||||
super::frames(cx);
|
||||
assert!(cx.update(|window, _| window.has_image_atlas_entry(&new)), "and is drawn");
|
||||
}
|
||||
@@ -33,6 +33,39 @@ fn pages_export_as_images_and_text() {
|
||||
assert!(text.contains("First") && text.contains("Second"), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_write_the_system_refuses_reports_it_without_a_crash() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let path = file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["First"])]);
|
||||
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
|
||||
shown(cx, &viewer);
|
||||
let locked = dir.path().join("locked");
|
||||
std::fs::create_dir(&locked).expect("a folder");
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o500)).expect("read-only");
|
||||
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
|
||||
let txt = locked.join("doc.txt");
|
||||
let how = Optimizing { compress: true, from: 300, to: 0 };
|
||||
// The window's toasts live on its root.
|
||||
let root = cx.update(|window, app| {
|
||||
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
|
||||
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
|
||||
root
|
||||
});
|
||||
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_export(id, Kind::Text, how, txt.clone(), cx)));
|
||||
let failed = |app: &gpui::App| {
|
||||
let toasts = root.read(app).toasts(app);
|
||||
toasts.iter().any(|(text, severity)| {
|
||||
*severity == Some(ely_gpui_component::primitives::Severity::Danger) && text.contains("Permission denied")
|
||||
})
|
||||
};
|
||||
settle(cx, failed);
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).expect("writable again");
|
||||
assert!(!txt.exists(), "nothing is written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_file_opens_repaired_and_saves_as_a_new_file() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
|
||||
@@ -71,6 +71,8 @@ pub struct PdfView {
|
||||
/// Page pictures for the side panel, by page and darkness.
|
||||
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
|
||||
pub thumbs_failed: HashSet<usize>,
|
||||
/// Pictures from before an edit; shown until repainted.
|
||||
pub thumbs_stale: HashSet<(usize, bool)>,
|
||||
/// A picture is on its way from the worker.
|
||||
pub thumbing: bool,
|
||||
/// Pages picked in the side panel, and the anchor.
|
||||
@@ -188,6 +190,7 @@ impl PdfView {
|
||||
mark: loaded.mark,
|
||||
thumbs: HashMap::new(),
|
||||
thumbs_failed: HashSet::new(),
|
||||
thumbs_stale: HashSet::new(),
|
||||
thumbing: false,
|
||||
picked: BTreeSet::new(),
|
||||
anchor: None,
|
||||
@@ -227,10 +230,10 @@ impl PdfView {
|
||||
pub fn fail(&self, what: &str, error: &io::Error, cx: &mut Context<Self>) {
|
||||
// Engine text may quote the document: the user sees it.
|
||||
log::error!("pdf: {what} failed ({:?})", error.kind());
|
||||
let text = match error.kind() {
|
||||
let text = match ely_pdf_engine::refused(error) {
|
||||
_ if ely_pdf_engine::is_stale(error) => ely_ui::i18n::text(cx, "pdf.object.changed", &[]),
|
||||
io::ErrorKind::PermissionDenied => restricted(&error.to_string(), cx),
|
||||
_ => ely_ui::i18n::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]),
|
||||
Some(needed) => restricted(needed, cx),
|
||||
None => ely_ui::i18n::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]),
|
||||
};
|
||||
cx.emit(Failed(text.to_string()));
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let root = ely_ui::document_root(app, viewer.clone().into(), &commands::FILE, crate::keys::menus(cx), window, cx);
|
||||
viewer.update(cx, |viewer, cx| {
|
||||
viewer.core.root = root.downgrade();
|
||||
viewer.core.window = Some(window.window_handle());
|
||||
viewer.own_window(window, cx);
|
||||
window.focus(&viewer.core.focus, cx);
|
||||
});
|
||||
for path in ely_ui::launch_files(cx) {
|
||||
@@ -247,7 +247,6 @@ impl Render for Viewer {
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_export))
|
||||
.on_action(cx.listener(Self::on_print))
|
||||
.on_action(cx.listener(Self::on_quit))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
|
||||
+13
-57
@@ -1,16 +1,15 @@
|
||||
//! ElyShow's window: decks over one LOK worker (§10.1).
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::HashSet,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use ely_gpui_component::{feedback::EmptyState, primitives::IconName, shell::StatusBar};
|
||||
use ely_lok::{
|
||||
LokView, Sheets, Worker,
|
||||
history::Step,
|
||||
show::{Design, Objects},
|
||||
LokView, Worker,
|
||||
show::Design,
|
||||
};
|
||||
use ely_office::{Core, Kind, prelude::*};
|
||||
use ely_platform::AppId;
|
||||
@@ -18,61 +17,13 @@ use ely_ui::{Root, Shell, commands};
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
|
||||
ParentElement as _, Render, RenderImage, SharedString, Styled as _, Window, div,
|
||||
ParentElement as _, Render, SharedString, Styled as _, Window, div,
|
||||
};
|
||||
|
||||
use crate::design::Panel;
|
||||
use crate::slides::Strip;
|
||||
|
||||
/// Parts, edits, caret and selected frame.
|
||||
pub type Seen = (u64, u64, bool, Option<ely_lok::Rect>);
|
||||
|
||||
/// What ElyShow keeps per deck.
|
||||
#[derive(Default)]
|
||||
pub struct Extra {
|
||||
pub slides: Sheets,
|
||||
/// The document as the last read saw it.
|
||||
pub read_at: Option<ely_lok::Doc>,
|
||||
/// What the view showed at the last read.
|
||||
pub read: Option<Seen>,
|
||||
pub reading: bool,
|
||||
pub design: Option<Design>,
|
||||
pub objects: Option<Objects>,
|
||||
pub notes: Option<ely_lok::show::Notes>,
|
||||
pub remarks: Vec<ely_lok::show::Remark>,
|
||||
/// The shown slide's transition and effects; none on a master.
|
||||
pub motion: Option<(ely_lok::show::Transition, Vec<ely_lok::show::Effect>)>,
|
||||
pub media: Vec<ely_lok::show::Media>,
|
||||
/// Counts completed reads.
|
||||
pub reads: u64,
|
||||
/// The slide size in twips.
|
||||
pub size: (i64, i64),
|
||||
/// Undo and redo lists at the last read.
|
||||
pub history: Option<(Vec<Step>, Vec<Step>)>,
|
||||
/// A size sent, in 1/100 mm, until read back.
|
||||
pub size_asked: Option<(i64, i64)>,
|
||||
/// A transition sent for a slide, until read back.
|
||||
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
|
||||
/// Pictures by slide name; stale ones show until repainted.
|
||||
pub thumbs: HashMap<String, Arc<RenderImage>>,
|
||||
pub retired: ely_gpui_component::primitives::Retired,
|
||||
pub stale: HashSet<String>,
|
||||
/// Pictures that failed; retried after the next read.
|
||||
pub failed: HashSet<String>,
|
||||
pub painting: bool,
|
||||
/// The parts are masters.
|
||||
pub masters: bool,
|
||||
/// The scheme the pictures were painted with.
|
||||
pub dark: Option<bool>,
|
||||
}
|
||||
|
||||
impl Extra {
|
||||
/// The size to show, in 1/100 mm: asked, else read.
|
||||
pub fn shown_size(&self) -> (i64, i64) {
|
||||
self.size_asked
|
||||
.unwrap_or_else(|| crate::design::mm100(self.size))
|
||||
}
|
||||
}
|
||||
pub use crate::extra::Extra;
|
||||
|
||||
pub struct Deck {
|
||||
pub core: Core<Deck>,
|
||||
@@ -137,7 +88,13 @@ impl Host for Deck {
|
||||
}
|
||||
|
||||
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
|
||||
view.update(cx, |view, cx| view.fit_page(cx));
|
||||
// A new deck fits once; a zoom then stays.
|
||||
if let Some(tab) = self.tab_mut(id)
|
||||
&& !tab.extra.fitted
|
||||
{
|
||||
tab.extra.fitted = true;
|
||||
view.update(cx, |view, cx| view.fit_page(cx));
|
||||
}
|
||||
// Pictures follow the kernel's light or dark scheme.
|
||||
let dark = view.read(cx).dark();
|
||||
if let Some(tab) = self.tab_mut(id)
|
||||
@@ -176,7 +133,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let root = ely_ui::document_root(app, deck.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
|
||||
deck.update(cx, |deck, cx| {
|
||||
deck.core.root = root.downgrade();
|
||||
deck.core.window = Some(window.window_handle());
|
||||
deck.own_window(window, cx);
|
||||
window.focus(&deck.core.focus, cx);
|
||||
});
|
||||
let launched = ely_ui::launch_files(cx);
|
||||
@@ -491,7 +448,6 @@ impl Render for Deck {
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_export))
|
||||
.on_action(cx.listener(Self::on_print))
|
||||
.on_action(cx.listener(Self::on_quit))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.child(shell)
|
||||
.children(self.core_dialog(cx))
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! What ElyShow keeps per deck.
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use ely_lok::{
|
||||
Sheets,
|
||||
history::Step,
|
||||
show::{Design, Objects},
|
||||
};
|
||||
use gpui::RenderImage;
|
||||
|
||||
/// Parts, edits, caret and selected frame.
|
||||
pub type Seen = (u64, u64, bool, Option<ely_lok::Rect>);
|
||||
|
||||
/// What ElyShow keeps per deck.
|
||||
#[derive(Default)]
|
||||
pub struct Extra {
|
||||
pub slides: Sheets,
|
||||
/// The document as the last read saw it.
|
||||
pub read_at: Option<ely_lok::Doc>,
|
||||
/// What the view showed at the last read.
|
||||
pub read: Option<Seen>,
|
||||
pub reading: bool,
|
||||
pub design: Option<Design>,
|
||||
pub objects: Option<Objects>,
|
||||
pub notes: Option<ely_lok::show::Notes>,
|
||||
pub remarks: Vec<ely_lok::show::Remark>,
|
||||
/// The shown slide's transition and effects; none on a master.
|
||||
pub motion: Option<(ely_lok::show::Transition, Vec<ely_lok::show::Effect>)>,
|
||||
pub media: Vec<ely_lok::show::Media>,
|
||||
/// Counts completed reads.
|
||||
pub reads: u64,
|
||||
/// The slide size in twips.
|
||||
pub size: (i64, i64),
|
||||
/// Undo and redo lists at the last read.
|
||||
pub history: Option<(Vec<Step>, Vec<Step>)>,
|
||||
/// A size sent, in 1/100 mm, until read back.
|
||||
pub size_asked: Option<(i64, i64)>,
|
||||
/// A transition sent for a slide, until read back.
|
||||
pub transition_asked: Option<(String, ely_lok::show::Transition, u64)>,
|
||||
/// Pictures by slide name; stale ones show until repainted.
|
||||
pub thumbs: HashMap<String, Arc<RenderImage>>,
|
||||
pub retired: ely_gpui_component::primitives::Retired,
|
||||
pub stale: HashSet<String>,
|
||||
/// Pictures that failed; retried after the next read.
|
||||
pub failed: HashSet<String>,
|
||||
pub painting: bool,
|
||||
/// The parts are masters.
|
||||
pub masters: bool,
|
||||
/// The scheme the pictures were painted with.
|
||||
pub dark: Option<bool>,
|
||||
/// The view was fitted to the slide once.
|
||||
pub fitted: bool,
|
||||
}
|
||||
|
||||
impl Extra {
|
||||
/// The size to show, in 1/100 mm: asked, else read.
|
||||
pub fn shown_size(&self) -> (i64, i64) {
|
||||
self.size_asked
|
||||
.unwrap_or_else(|| crate::design::mm100(self.size))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod check;
|
||||
mod deck;
|
||||
mod design;
|
||||
mod dialog;
|
||||
mod extra;
|
||||
mod guides;
|
||||
mod insert;
|
||||
mod list;
|
||||
|
||||
@@ -384,3 +384,27 @@ fn a_comment_picked_on_another_slide_shows_its_slide_again() {
|
||||
settle(&cx, |app| current(app) == Some(0));
|
||||
assert_eq!(cx.update(|app| deck.read(app).shown_remark()), Some(key));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zoom_stays_through_later_reports() {
|
||||
use ely_gpui_component::shell::ZoomStep;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let Some(deck) = blank(&mut cx, dir.path()) else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let view = |app: &gpui::App| deck.read(app).active_tab().expect("a tab").view.clone();
|
||||
settle(&cx, |app| view(app).read(app).fitting());
|
||||
update(&mut cx, &deck, |deck, cx| deck.zoom(ZoomStep::In, cx));
|
||||
let zoom = cx.update(|app| view(app).read(app).zoom());
|
||||
update(&mut cx, &deck, |deck, cx| {
|
||||
let (id, view) = deck.active_tab().map(|tab| (tab.id, tab.view.clone())).expect("a tab");
|
||||
deck.observed(id, &view, cx);
|
||||
});
|
||||
cx.update(|app| {
|
||||
let view = view(app);
|
||||
assert!(!view.read(app).fitting(), "a report does not fit the slide again");
|
||||
assert_eq!(view.read(app).zoom(), zoom, "the zoom stays");
|
||||
});
|
||||
}
|
||||
@@ -143,7 +143,7 @@ pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
|
||||
let root = ely_ui::document_root(app, book.clone().into(), &ely_ui::commands::FILE, menus, window, cx);
|
||||
book.update(cx, |book, cx| {
|
||||
book.core.root = root.downgrade();
|
||||
book.core.window = Some(window.window_handle());
|
||||
book.own_window(window, cx);
|
||||
window.focus(&book.core.focus, cx);
|
||||
});
|
||||
let launched = ely_ui::launch_files(cx);
|
||||
@@ -372,7 +372,6 @@ impl Render for Book {
|
||||
.on_action(cx.listener(Self::on_open_file))
|
||||
.on_action(cx.listener(Self::on_save))
|
||||
.on_action(cx.listener(Self::on_save_as))
|
||||
.on_action(cx.listener(Self::on_quit))
|
||||
.on_action(cx.listener(Self::on_close_tab))
|
||||
.on_action(cx.listener(Self::on_export))
|
||||
.on_action(cx.listener(Self::on_print))
|
||||
|
||||
@@ -16,6 +16,7 @@ mod doc;
|
||||
mod feed;
|
||||
mod frames;
|
||||
mod install;
|
||||
mod load;
|
||||
pub use install::{install_dir, templates};
|
||||
use install::worker_bin;
|
||||
pub use doc::{Pdf, Sheet, Sheets};
|
||||
@@ -37,6 +38,8 @@ pub struct Worker {
|
||||
session: u64,
|
||||
stderr: ely_ipc::Relay,
|
||||
check: Check,
|
||||
/// The name new comments sign with, once set.
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
/// The start-up check: to run, passed, or why it failed.
|
||||
@@ -73,9 +76,16 @@ pub struct Doc {
|
||||
id: u64,
|
||||
/// The document's revision when this handle was taken.
|
||||
pub(crate) rev: u64,
|
||||
/// LOK_DOCTYPE: 0 text, 1 spreadsheet, 2 presentation.
|
||||
kind: u64,
|
||||
}
|
||||
|
||||
impl Doc {
|
||||
/// Whether this is a spreadsheet.
|
||||
pub fn is_sheet(&self) -> bool {
|
||||
self.kind == 1
|
||||
}
|
||||
|
||||
/// Whether this handle saw a later state than `other`.
|
||||
pub fn newer_than(&self, other: &Doc) -> bool {
|
||||
self.rev > other.rev
|
||||
@@ -181,6 +191,7 @@ impl Worker {
|
||||
session: SESSIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
|
||||
stderr,
|
||||
check: Check::Pending(profile_dir.to_path_buf()),
|
||||
author: None,
|
||||
};
|
||||
Ok(worker)
|
||||
}
|
||||
@@ -194,47 +205,15 @@ impl Worker {
|
||||
/// The name new comments and tracked changes carry.
|
||||
pub fn set_author(&mut self, name: &str) -> io::Result<()> {
|
||||
log::info!("lok: the author is set");
|
||||
// Wanted even if this worker fails; a restart sends it.
|
||||
self.author = Some(name.to_string());
|
||||
self.request(tag::SET_AUTHOR, &Pack::new().str(name).bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loads `url`; macros never run (LOK-11).
|
||||
pub fn load(&mut self, url: &str) -> io::Result<Doc> {
|
||||
self.unlock(url, "")
|
||||
}
|
||||
|
||||
/// Loads text `url` as a sheet, read as `how` says.
|
||||
pub fn import(&mut self, url: &str, how: &crate::sheet::csv::Import) -> io::Result<Doc> {
|
||||
use crate::sheet::csv::{COLUMNS, ENCODINGS};
|
||||
let at = |found: Option<usize>| found.expect("a listed choice") as u64;
|
||||
let pack = Pack::new()
|
||||
.str(url)
|
||||
.u64(u64::from(how.separator))
|
||||
.u64(how.quote.map_or(0, u64::from))
|
||||
.u64(at(ENCODINGS.iter().position(|e| *e == how.encoding)))
|
||||
.u64(how.columns.len() as u64);
|
||||
let pack = how.columns.iter().fold(pack, |pack, column| {
|
||||
pack.u64(at(COLUMNS.iter().position(|c| c == column)))
|
||||
});
|
||||
let payload = self.request(tag::IMPORT, &pack.bytes())?;
|
||||
let id = Unpack::new(&payload).u64()?;
|
||||
self.feed.opened(id);
|
||||
Ok(Doc { worker: self.session, id, rev: 0 })
|
||||
}
|
||||
|
||||
/// Loads `url` with its password; a locked one is PermissionDenied.
|
||||
pub fn unlock(&mut self, url: &str, password: &str) -> io::Result<Doc> {
|
||||
let request = Pack::new().str(url).str(password).bytes();
|
||||
let payload = self.request(tag::LOAD, &request).map_err(|error| {
|
||||
if error.to_string() == crate::ipc::LOCKED {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, error.to_string())
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})?;
|
||||
let id = Unpack::new(&payload).u64()?;
|
||||
self.feed.opened(id);
|
||||
Ok(Doc { worker: self.session, id, rev: 0 })
|
||||
/// The name last asked for; a restart signs with it.
|
||||
pub fn author(&self) -> Option<&str> {
|
||||
self.author.as_deref()
|
||||
}
|
||||
|
||||
/// Runs a macro by its URL (LOK-11 probe).
|
||||
|
||||
@@ -181,17 +181,17 @@ mod tests {
|
||||
};
|
||||
feed.push(1, first);
|
||||
feed.push(1, second);
|
||||
let batch = feed.wait(&Doc { worker: 0, id: 1, rev: 0 }, 0, 0).expect("a batch");
|
||||
let batch = feed.wait(&Doc { worker: 0, id: 1, rev: 0, kind: 0 }, 0, 0).expect("a batch");
|
||||
assert_eq!((batch.seq, batch.all, batch.rects.len()), (2, true, 1));
|
||||
assert_eq!(
|
||||
batch.states,
|
||||
[("b".to_string(), "1".to_string()), ("a".into(), "2".into())]
|
||||
);
|
||||
let idle = feed.wait(&Doc { worker: 0, id: 1, rev: 0 }, 2, 0).expect("the snapshot");
|
||||
let idle = feed.wait(&Doc { worker: 0, id: 1, rev: 0, kind: 0 }, 2, 0).expect("the snapshot");
|
||||
assert!(idle.rects.is_empty() && idle.states.is_empty() && idle.seq == 2);
|
||||
feed.closed(1);
|
||||
assert!(
|
||||
feed.wait(&Doc { worker: 0, id: 1, rev: 0 }, 2, 0).is_err(),
|
||||
feed.wait(&Doc { worker: 0, id: 1, rev: 0, kind: 0 }, 2, 0).is_err(),
|
||||
"a closed document"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Loading documents: by URL, with a password, or as text.
|
||||
|
||||
use std::io;
|
||||
|
||||
use super::{Doc, Worker};
|
||||
use crate::ipc::{Pack, Unpack, tag};
|
||||
|
||||
impl Worker {
|
||||
/// Loads `url`; macros never run (LOK-11).
|
||||
pub fn load(&mut self, url: &str) -> io::Result<Doc> {
|
||||
self.unlock(url, "")
|
||||
}
|
||||
|
||||
/// Loads text `url` as a sheet, read as `how` says.
|
||||
pub fn import(&mut self, url: &str, how: &crate::sheet::csv::Import) -> io::Result<Doc> {
|
||||
use crate::sheet::csv::{COLUMNS, ENCODINGS};
|
||||
let at = |found: Option<usize>| found.expect("a listed choice") as u64;
|
||||
let pack = Pack::new()
|
||||
.str(url)
|
||||
.u64(u64::from(how.separator))
|
||||
.u64(how.quote.map_or(0, u64::from))
|
||||
.u64(at(ENCODINGS.iter().position(|e| *e == how.encoding)))
|
||||
.u64(how.columns.len() as u64);
|
||||
let pack = how.columns.iter().fold(pack, |pack, column| {
|
||||
pack.u64(at(COLUMNS.iter().position(|c| c == column)))
|
||||
});
|
||||
let payload = self.request(tag::IMPORT, &pack.bytes())?;
|
||||
self.opened(&payload)
|
||||
}
|
||||
|
||||
/// Loads `url` with its password; a locked one is PermissionDenied.
|
||||
pub fn unlock(&mut self, url: &str, password: &str) -> io::Result<Doc> {
|
||||
let request = Pack::new().str(url).str(password).bytes();
|
||||
let payload = self.request(tag::LOAD, &request).map_err(|error| {
|
||||
if error.to_string() == crate::ipc::LOCKED {
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, error.to_string())
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})?;
|
||||
self.opened(&payload)
|
||||
}
|
||||
|
||||
/// A load's reply: the document's number and kind.
|
||||
fn opened(&mut self, payload: &[u8]) -> io::Result<Doc> {
|
||||
let mut reply = Unpack::new(payload);
|
||||
let (id, kind) = (reply.u64()?, reply.u64()?);
|
||||
self.feed.opened(id);
|
||||
Ok(Doc { worker: self.session, id, rev: 0, kind })
|
||||
}
|
||||
}
|
||||
@@ -375,8 +375,9 @@ impl LokView {
|
||||
}
|
||||
if batch.cursor != self.cursor {
|
||||
self.moves += 1;
|
||||
// A moved cursor stays in view.
|
||||
// A moved cursor stays in view, but not mid-drag.
|
||||
if self.viewport.1 > 0.0
|
||||
&& !self.dragging
|
||||
&& let Some(cursor) = batch.cursor
|
||||
{
|
||||
self.show(cursor, cx);
|
||||
@@ -385,6 +386,7 @@ impl LokView {
|
||||
// A moved cell cursor stays in view too.
|
||||
if batch.cell.cursor != self.cell.cursor
|
||||
&& self.viewport.1 > 0.0
|
||||
&& !self.dragging
|
||||
&& let Some(cell) = batch.cell.cursor
|
||||
{
|
||||
self.show(cell, cx);
|
||||
|
||||
@@ -251,6 +251,10 @@ impl LokView {
|
||||
|
||||
/// Room beside a narrow document, which sits centred.
|
||||
pub(super) fn slack(&self) -> (f32, f32) {
|
||||
// A sheet's grid grows with the view; it starts left.
|
||||
if self.doc.is_sheet() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let extent = self.extent();
|
||||
let slack = |view: f32, doc: i32| (view - self.geom.px_of(doc)).max(0.0) / 2.0;
|
||||
// A fitted page sits centred both ways.
|
||||
|
||||
@@ -61,11 +61,11 @@ impl LokView {
|
||||
let need = self.print_of((x + w + vw + fx, y + h + vh + fy));
|
||||
self.reach = (self.reach.0.max(need.0), self.reach.1.max(need.1));
|
||||
}
|
||||
if x >= fx && (x < fx + self.scroll.0 || x + w > fx + self.scroll.0 + vw) {
|
||||
self.scroll.0 = x - fx - vw / 2;
|
||||
if x >= fx {
|
||||
self.scroll.0 = least(x - fx, w, self.scroll.0, vw);
|
||||
}
|
||||
if y >= fy && (y < fy + self.scroll.1 || y + h > fy + self.scroll.1 + vh) {
|
||||
self.scroll.1 = y - fy - vh / 3;
|
||||
if y >= fy {
|
||||
self.scroll.1 = least(y - fy, h, self.scroll.1, vh);
|
||||
}
|
||||
self.clamp_scroll();
|
||||
cx.notify();
|
||||
@@ -172,3 +172,10 @@ impl LokView {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The scroll nearest `scroll` that shows `start..start + len`.
|
||||
fn least(start: i32, len: i32, scroll: i32, view: i32) -> i32 {
|
||||
// A span wider than the view keeps covering it.
|
||||
let end = start + len - view;
|
||||
scroll.clamp(start.min(end), start.max(end))
|
||||
}
|
||||
@@ -186,7 +186,12 @@ fn a_sheet_brings_far_cut_and_frozen_cells_into_view() {
|
||||
let (view, worker) = live_view_of(app, &install, "short.fods", SHORT_ROWS);
|
||||
let json = worker.lock().expect("the worker lock").values(&view.read(app).doc, crate::sheet::GEOMETRY).expect("geometry");
|
||||
view.update(app, |view, cx| {
|
||||
let wide = Bounds { origin: point(px(0.0), px(0.0)), size: size(px(6000.0), px(200.0)) };
|
||||
view.prepare(wide);
|
||||
assert_eq!(view.spare, (0.0, 0.0), "before its geometry a sheet starts left");
|
||||
view.set_geometry(crate::sheet::geometry(&json).expect("parsed"), cx);
|
||||
view.prepare(wide);
|
||||
assert_eq!(view.spare, (0.0, 0.0), "and stays left after it");
|
||||
view.viewport = (800.0, 200.0);
|
||||
let (width, height) = (view.geom.twips_of(view.viewport.0), view.geom.twips_of(view.viewport.1));
|
||||
let shows = |view: &LokView, rect: crate::Rect| {
|
||||
@@ -202,6 +207,16 @@ fn a_sheet_brings_far_cut_and_frozen_cells_into_view() {
|
||||
let cut = (width - 200, 0, 1280, 256);
|
||||
view.show(cut, cx);
|
||||
assert!(view.scroll.0 > 0 && shows(view, cut), "a cell cut at the right edge comes into view");
|
||||
let (x, _, w, _) = view.tile_rect(cut);
|
||||
assert_eq!(view.scroll.0, x + w - width, "it scrolls no further than the cell's edge");
|
||||
let before = view.scroll;
|
||||
view.show((0, 0, view.print_of((x + w + width, 0)).0, 256), cx);
|
||||
assert_eq!(view.scroll, before, "a cell wider than the view and covering it keeps still");
|
||||
view.dragging = true;
|
||||
let cell = crate::Cell { cursor: Some((1418, 90_000, 1280, 277)), ..crate::Cell::default() };
|
||||
view.apply_batch(crate::Events { cell, ..crate::Events::default() }, None, cx);
|
||||
assert_eq!(view.scroll, before, "a drag does not scroll under the pointer");
|
||||
view.dragging = false;
|
||||
view.scroll = (0, 0);
|
||||
view.set_frozen((0, 600), cx);
|
||||
let frozen = view.place((0, 300)).y;
|
||||
|
||||
@@ -187,8 +187,8 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
|
||||
.str()
|
||||
.and_then(|url| Ok((url, unpack.str()?)))
|
||||
.and_then(|(url, password)| load(lok, &url, password, ""))
|
||||
.map(|id| Pack::new().u64(id).bytes()),
|
||||
tag::IMPORT => text::import(lok, unpack).map(|id| Pack::new().u64(id).bytes()),
|
||||
.and_then(loaded),
|
||||
tag::IMPORT => text::import(lok, unpack).and_then(loaded),
|
||||
tag::EXPORT_TEXT => text::export(unpack).map(|()| Vec::new()),
|
||||
tag::DOC_SIZE => unpack
|
||||
.u64()
|
||||
@@ -339,6 +339,12 @@ fn load(lok: &Lok, url: &str, password: String, filter: &str) -> io::Result<u64>
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The reply to a load: the number and LOK_DOCTYPE.
|
||||
fn loaded(id: u64) -> io::Result<Vec<u8>> {
|
||||
let kind = with_doc(id, |doc| doc.kind())?;
|
||||
Ok(Pack::new().u64(id).u64(kind as u64).bytes())
|
||||
}
|
||||
|
||||
/// Stops watching and frees document `id`.
|
||||
fn close(id: u64) {
|
||||
let mut docs = docs().lock().expect("the docs lock");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::primitives::Severity;
|
||||
use ely_ui::commands::{CloseTab, Open, Quit, Save, SaveAs};
|
||||
use ely_ui::commands::{CloseTab, Open, Save, SaveAs};
|
||||
use gpui::{App, AppContext as _, Context, PathPromptOptions, TaskExt as _, Window};
|
||||
|
||||
use crate::{Dialog, Engine, Host, Opening, Saving, Tabs, in_window, text};
|
||||
@@ -41,8 +41,37 @@ pub trait Closing: Host {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_quit(&mut self, _: &Quit, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.proceed(After::Quit, window, cx);
|
||||
/// Takes `window` as home; quit anywhere asks here first.
|
||||
fn own_window(&mut self, window: &Window, cx: &mut Context<Self>) {
|
||||
let handle = window.window_handle();
|
||||
self.core_mut().window = Some(handle);
|
||||
let host = cx.entity().downgrade();
|
||||
ely_ui::commands::route_quit(cx, move |cx| {
|
||||
let host = host.clone();
|
||||
// The asking window is busy until its action ends.
|
||||
cx.defer(move |cx| {
|
||||
let asked = handle.update(cx, |_, window, cx| {
|
||||
// The question shows in front, with the keys.
|
||||
window.activate_window();
|
||||
host.update(cx, |host, cx| {
|
||||
host.quitting(cx);
|
||||
host.proceed(After::Quit, window, cx);
|
||||
})
|
||||
});
|
||||
match asked {
|
||||
Ok(Ok(())) => {}
|
||||
// No home window is left to ask in.
|
||||
_ if handle.update(cx, |_, _, _| ()).is_err() => cx.quit(),
|
||||
Ok(Err(error)) | Err(error) => log::error!("office: the quit was not asked: {error}"),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A close or quit stopped: no update installs on it.
|
||||
fn close_stopped(&mut self, cx: &mut Context<Self>) {
|
||||
ely_ui::updates::quit_stopped(cx);
|
||||
self.close_cancelled(cx);
|
||||
}
|
||||
|
||||
fn no_document(&self, cx: &mut Context<Self>) {
|
||||
|
||||
@@ -87,7 +87,7 @@ pub trait Dialogs: Host {
|
||||
// Cancel stops the whole close.
|
||||
let cancelled = move |window: &mut Window, cx: &mut App| {
|
||||
owner.update(cx, |host, cx| {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
host.dialog_done(window, cx)
|
||||
})
|
||||
};
|
||||
@@ -119,7 +119,7 @@ pub trait Dialogs: Host {
|
||||
let cancelled = move |window: &mut Window, cx: &mut App| {
|
||||
owner.update(cx, |host, cx| {
|
||||
if then.is_some() {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
}
|
||||
host.dialog_done(window, cx)
|
||||
})
|
||||
@@ -135,7 +135,7 @@ pub trait Dialogs: Host {
|
||||
move |host, _, cx| {
|
||||
// Reloading ends the close that waited.
|
||||
if then.is_some() {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
}
|
||||
host.reload(tab, cx)
|
||||
},
|
||||
|
||||
@@ -72,6 +72,8 @@ pub trait Host: Sized + 'static {
|
||||
fn observed(&mut self, id: u64, view: &Entity<View<Self>>, cx: &mut Context<Self>);
|
||||
/// Runs before a search first moves the cursor.
|
||||
fn search_leaves(&mut self, _: &mut Context<Self>) {}
|
||||
/// Runs as a quit starts, before any tab closes.
|
||||
fn quitting(&mut self, _: &mut Context<Self>) {}
|
||||
/// Runs when a close stops: question or save cancelled.
|
||||
fn close_cancelled(&mut self, _: &mut Context<Self>) {}
|
||||
/// An outside change met a save; true if shown.
|
||||
|
||||
@@ -77,9 +77,14 @@ impl Engine for Lok {
|
||||
}
|
||||
|
||||
fn restart(app: AppId, worker: &mut Worker) -> io::Result<()> {
|
||||
*worker = spawn(app)?;
|
||||
worker.ready()?;
|
||||
worker.set_author(&ely_platform::user::name()?)
|
||||
let author = worker.author().map(str::to_string);
|
||||
let author = author.ok_or_else(|| io::Error::other("the ended worker had no author"))?;
|
||||
// The ended worker stays until the new one is ready.
|
||||
let mut fresh = spawn(app)?;
|
||||
fresh.ready()?;
|
||||
fresh.set_author(&author)?;
|
||||
*worker = fresh;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn settled(view: &LokView) -> bool {
|
||||
@@ -129,10 +134,13 @@ pub fn start(app: AppId, cx: &mut App) -> io::Result<(Arc<Mutex<Worker>>, PathBu
|
||||
let worker = Arc::new(Mutex::new(spawn(app)?));
|
||||
// The window opens now; the kernel finishes starting behind it.
|
||||
let starting = Arc::clone(&worker);
|
||||
// Settings as read at launch; defaults if unreadable.
|
||||
let wanted = Arc::new(Mutex::new(ely_ui::settings::current(cx).author.clone()));
|
||||
let first = Arc::clone(&wanted);
|
||||
let started = cx.background_executor().spawn(async move {
|
||||
let mut worker = starting.lock().expect("the worker lock");
|
||||
worker.ready()?;
|
||||
worker.set_author(&ely_platform::user::name()?)
|
||||
sign(&mut worker, &first)
|
||||
});
|
||||
cx.spawn(async move |_| {
|
||||
if let Err(error) = started.await {
|
||||
@@ -140,10 +148,16 @@ pub fn start(app: AppId, cx: &mut App) -> io::Result<(Arc<Mutex<Worker>>, PathBu
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
follow_author(&worker, cx);
|
||||
follow_author(&worker, wanted, cx);
|
||||
Ok((worker, work))
|
||||
}
|
||||
|
||||
/// Sends the newest wanted name, read under the worker lock.
|
||||
fn sign(worker: &mut Worker, wanted: &Mutex<String>) -> io::Result<()> {
|
||||
let author = wanted.lock().expect("the author lock").clone();
|
||||
worker.set_author(&ely_platform::user::named(&author)?)
|
||||
}
|
||||
|
||||
/// A worker process on the app's profile, not yet checked.
|
||||
fn spawn(app: AppId) -> io::Result<Worker> {
|
||||
let data = ely_platform::data_dir(app).map_err(io::Error::other)?;
|
||||
@@ -154,19 +168,19 @@ fn spawn(app: AppId) -> io::Result<Worker> {
|
||||
}
|
||||
|
||||
/// A new author name signs from the next comment on.
|
||||
fn follow_author(worker: &Arc<Mutex<Worker>>, cx: &mut App) {
|
||||
fn follow_author(worker: &Arc<Mutex<Worker>>, wanted: Arc<Mutex<String>>, cx: &mut App) {
|
||||
let worker = Arc::clone(worker);
|
||||
let mut signed = ely_ui::settings::current(cx).author.clone();
|
||||
cx.observe_global::<ely_ui::settings::Current>(move |cx| {
|
||||
let author = ely_ui::settings::current(cx).author.clone();
|
||||
if author == signed {
|
||||
if *wanted.lock().expect("the author lock") == author {
|
||||
return;
|
||||
}
|
||||
signed = author;
|
||||
let worker = Arc::clone(&worker);
|
||||
*wanted.lock().expect("the author lock") = author;
|
||||
let (worker, wanted) = (Arc::clone(&worker), Arc::clone(&wanted));
|
||||
cx.background_executor()
|
||||
.spawn(async move {
|
||||
let set = ely_platform::user::name().and_then(|name| worker.lock().expect("the worker lock").set_author(&name));
|
||||
// Any order of these tasks ends on the newest name.
|
||||
let set = sign(&mut worker.lock().expect("the worker lock"), &wanted);
|
||||
if let Err(error) = set {
|
||||
log::error!("lok: the new author name is not set: {error}");
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ pub trait Saving: Host {
|
||||
}
|
||||
// No save: a close that waited on it stops.
|
||||
if then.is_some() {
|
||||
host.close_cancelled(cx);
|
||||
host.close_stopped(cx);
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -419,7 +419,7 @@ pub trait Saving: Host {
|
||||
failed => {
|
||||
self.report(id, failed, cx);
|
||||
if then.is_some() {
|
||||
self.close_cancelled(cx);
|
||||
self.close_stopped(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! A failed engine restart keeps the author for retries.
|
||||
|
||||
use ely_office::{Engine as _, Lok};
|
||||
use ely_platform::AppId;
|
||||
|
||||
#[test]
|
||||
fn a_restart_signs_with_the_newest_name_and_survives_a_failure() {
|
||||
let Ok(install) = ely_lok::install_dir() else {
|
||||
eprintln!("SKIP: no LOK build");
|
||||
return;
|
||||
};
|
||||
let home = tempfile::tempdir().expect("a temp dir");
|
||||
// The one test here: nothing else reads HOME.
|
||||
unsafe { std::env::set_var("HOME", home.path()) };
|
||||
let profile = ely_platform::data_dir(AppId::Doc).expect("a data dir").join("lok-profile");
|
||||
std::fs::create_dir_all(&profile).expect("the profile");
|
||||
let mut worker = ely_lok::Worker::launch(&install, &profile).expect("the worker");
|
||||
worker.ready().expect("checks");
|
||||
worker.set_author("Ada").expect("signs");
|
||||
// The new worker's check cannot write its sample here.
|
||||
let blocker = profile.join("lok-verify.fodt");
|
||||
if blocker.exists() {
|
||||
std::fs::remove_file(&blocker).expect("the last sample goes");
|
||||
}
|
||||
std::fs::create_dir(&blocker).expect("a folder in the way");
|
||||
assert!(Lok::restart(AppId::Doc, &mut worker).is_err(), "the check fails");
|
||||
assert_eq!(worker.author(), Some("Ada"), "the author survives a failed restart");
|
||||
std::fs::remove_dir(&blocker).expect("cleared");
|
||||
Lok::restart(AppId::Doc, &mut worker).expect("the next try restarts");
|
||||
assert_eq!(worker.author(), Some("Ada"));
|
||||
// A name asked while down signs after the restart.
|
||||
worker.shutdown().expect("stops");
|
||||
assert!(worker.set_author("Latest").is_err(), "a stopped engine cannot take it");
|
||||
Lok::restart(AppId::Doc, &mut worker).expect("restarts");
|
||||
assert_eq!(worker.author(), Some("Latest"), "the newest name signs");
|
||||
}
|
||||
@@ -29,6 +29,12 @@ typedef struct
|
||||
int *c;
|
||||
int n;
|
||||
int hit;
|
||||
/* Page-wide: each target's block, and hits per block. */
|
||||
int *owner;
|
||||
int *hits;
|
||||
/* The glyph each block last counted, so once each. */
|
||||
int *seen;
|
||||
int glyph;
|
||||
} targets;
|
||||
|
||||
/* The glyphs of `block` with their user-space origins. */
|
||||
@@ -58,19 +64,32 @@ static int take_glyph(fz_context *ctx, void *opaque, int *ucs, int ucslen, fz_ma
|
||||
{
|
||||
targets *t = opaque;
|
||||
fz_point at = fz_transform_point(fz_transform_point(fz_make_point(0, 0), trm), ctm);
|
||||
int i, k;
|
||||
int i, k, found = 0;
|
||||
t->glyph++;
|
||||
for (i = 0; i < t->n; i++)
|
||||
{
|
||||
int match = 0;
|
||||
if (fabsf(at.x - t->at[i].x) > 0.5f || fabsf(at.y - t->at[i].y) > 0.5f)
|
||||
continue;
|
||||
for (k = 0; k < ucslen; k++)
|
||||
if (ucs[k] == t->c[i] || t->c[i] == FZ_REPLACEMENT_CHARACTER)
|
||||
{
|
||||
t->hit++;
|
||||
return 1;
|
||||
}
|
||||
for (k = 0; k < ucslen && !match; k++)
|
||||
match = ucs[k] == t->c[i] || t->c[i] == FZ_REPLACEMENT_CHARACTER;
|
||||
if (!match)
|
||||
continue;
|
||||
if (!t->owner)
|
||||
{
|
||||
t->hit++;
|
||||
return 1;
|
||||
}
|
||||
/* Every block this glyph matches counts it once. */
|
||||
if (t->seen[t->owner[i]] != t->glyph)
|
||||
{
|
||||
t->seen[t->owner[i]] = t->glyph;
|
||||
t->hits[t->owner[i]]++;
|
||||
}
|
||||
found = 1;
|
||||
}
|
||||
return 0;
|
||||
t->hit += found;
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Runs the glyph filter over the page; `dry` changes nothing. */
|
||||
@@ -99,7 +118,7 @@ fz_matrix ely_to_user(fz_context *ctx, pdf_page *page)
|
||||
/* Whether all of `block` sits in the page's own content. */
|
||||
static int reachable(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stext_block *block)
|
||||
{
|
||||
targets t = { NULL, NULL, 0, 0 };
|
||||
targets t = { NULL, NULL, 0, 0, NULL, NULL, NULL, 0 };
|
||||
int ok = 0;
|
||||
fz_var(t.at);
|
||||
fz_var(t.c);
|
||||
@@ -122,6 +141,70 @@ static int reachable(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stex
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Each text block's glyphs found in page content, one pass. */
|
||||
static void reachable_all(fz_context *ctx, pdf_document *pdf, pdf_page *page, fz_stext_page *text, int n, int *ok)
|
||||
{
|
||||
targets t = { NULL, NULL, 0, 0, NULL, NULL, NULL, 0 };
|
||||
int *count = NULL, all = 0, b;
|
||||
fz_matrix to_user = ely_to_user(ctx, page);
|
||||
fz_stext_block *block;
|
||||
fz_stext_line *line;
|
||||
fz_stext_char *ch;
|
||||
fz_var(t.at);
|
||||
fz_var(t.c);
|
||||
fz_var(t.owner);
|
||||
fz_var(t.hits);
|
||||
fz_var(t.seen);
|
||||
fz_var(count);
|
||||
pdf_begin_operation(ctx, pdf, "Look at text");
|
||||
fz_try(ctx)
|
||||
{
|
||||
for (block = text->first_block; block; block = block->next)
|
||||
if (block->type == FZ_STEXT_BLOCK_TEXT)
|
||||
for (line = block->u.t.first_line; line; line = line->next)
|
||||
for (ch = line->first_char; ch; ch = ch->next)
|
||||
all++;
|
||||
t.at = fz_malloc_array(ctx, all + 1, fz_point);
|
||||
t.c = fz_malloc_array(ctx, all + 1, int);
|
||||
t.owner = fz_malloc_array(ctx, all + 1, int);
|
||||
t.hits = fz_calloc(ctx, n + 1, sizeof(int));
|
||||
t.seen = fz_calloc(ctx, n + 1, sizeof(int));
|
||||
count = fz_calloc(ctx, n + 1, sizeof(int));
|
||||
b = 0;
|
||||
for (block = text->first_block; block; block = block->next)
|
||||
{
|
||||
if (block->type != FZ_STEXT_BLOCK_TEXT)
|
||||
continue;
|
||||
for (line = block->u.t.first_line; line; line = line->next)
|
||||
for (ch = line->first_char; ch; ch = ch->next)
|
||||
if (!(ch->flags & FZ_STEXT_SYNTHETIC))
|
||||
{
|
||||
t.at[t.n] = fz_transform_point(ch->origin, to_user);
|
||||
t.c[t.n] = ch->c;
|
||||
t.owner[t.n++] = b;
|
||||
count[b]++;
|
||||
}
|
||||
b++;
|
||||
}
|
||||
filter_glyphs(ctx, pdf, page, &t, 1);
|
||||
for (b = 0; b < n; b++)
|
||||
ok[b] = count[b] > 0 && t.hits[b] == count[b];
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
pdf_abandon_operation(ctx, pdf);
|
||||
ely_forget_resources(ctx, pdf);
|
||||
fz_free(ctx, t.at);
|
||||
fz_free(ctx, t.c);
|
||||
fz_free(ctx, t.owner);
|
||||
fz_free(ctx, t.hits);
|
||||
fz_free(ctx, t.seen);
|
||||
fz_free(ctx, count);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
fz_rethrow(ctx);
|
||||
}
|
||||
|
||||
/* The text of `block`, lines on lines. */
|
||||
static void block_text(fz_context *ctx, fz_buffer *buf, fz_stext_block *block)
|
||||
{
|
||||
@@ -153,18 +236,24 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char
|
||||
pdf_page *page = NULL;
|
||||
fz_buffer *buf = NULL, *words = NULL;
|
||||
fz_output *out = NULL;
|
||||
int *reached = NULL;
|
||||
fz_var(text);
|
||||
fz_var(page);
|
||||
fz_var(buf);
|
||||
fz_var(words);
|
||||
fz_var(out);
|
||||
fz_var(reached);
|
||||
fz_try(ctx)
|
||||
{
|
||||
pdf_document *pdf = ely_pdf_of(ctx, doc);
|
||||
fz_stext_block *block;
|
||||
int first = 1;
|
||||
int first = 1, n = 0, at = 0;
|
||||
text = read_text(ctx, doc, number);
|
||||
page = pdf_load_page(ctx, pdf, number);
|
||||
for (block = text->first_block; block; block = block->next)
|
||||
n += block->type == FZ_STEXT_BLOCK_TEXT;
|
||||
reached = fz_calloc(ctx, n + 1, sizeof(int));
|
||||
reachable_all(ctx, pdf, page, text, n, reached);
|
||||
buf = fz_new_buffer(ctx, 1024);
|
||||
out = fz_new_output_with_buffer(ctx, buf);
|
||||
fz_write_string(ctx, out, "[");
|
||||
@@ -175,7 +264,8 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char
|
||||
if (block->type != FZ_STEXT_BLOCK_TEXT)
|
||||
continue;
|
||||
ch = block->u.t.first_line->first_char;
|
||||
editable = upright(block) && reachable(ctx, pdf, page, block);
|
||||
editable = reached[at++];
|
||||
editable = editable && upright(block);
|
||||
fz_drop_buffer(ctx, words);
|
||||
words = NULL;
|
||||
words = fz_new_buffer(ctx, 256);
|
||||
@@ -198,6 +288,7 @@ int ely_text_blocks(fz_context *ctx, fz_document *doc, int number, unsigned char
|
||||
fz_drop_buffer(ctx, words);
|
||||
fz_drop_page(ctx, (fz_page *)page);
|
||||
fz_drop_stext_page(ctx, text);
|
||||
fz_free(ctx, reached);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
|
||||
@@ -293,10 +293,30 @@ impl Drop for Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// A permission refusal is `PermissionDenied`, named by what it needs.
|
||||
/// A request the document's permissions refused, by what it needs.
|
||||
#[derive(Debug)]
|
||||
pub struct Refused(pub &'static str);
|
||||
|
||||
impl std::fmt::Display for Refused {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}{}", crate::proto::may::REFUSED, self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Refused {}
|
||||
|
||||
/// What a permission refusal needs; none for other errors.
|
||||
pub fn refused(error: &io::Error) -> Option<&'static str> {
|
||||
error.get_ref()?.downcast_ref::<Refused>().map(|refused| refused.0)
|
||||
}
|
||||
|
||||
/// A worker error; a refusal names a permission it knows.
|
||||
fn refusal(text: String) -> io::Error {
|
||||
match text.strip_prefix(crate::proto::may::REFUSED) {
|
||||
Some(needed) => io::Error::new(io::ErrorKind::PermissionDenied, needed.to_string()),
|
||||
let named = text
|
||||
.strip_prefix(crate::proto::may::REFUSED)
|
||||
.and_then(|needed| crate::proto::may::NAMES.iter().find(|(_, name)| *name == needed));
|
||||
match named {
|
||||
Some((_, name)) => io::Error::other(Refused(name)),
|
||||
None => io::Error::other(text),
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ pub mod proto;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod sample;
|
||||
|
||||
pub use client::{Doc, Opened, Tile, Worker, is_stale};
|
||||
pub use client::{Doc, Opened, Refused, Tile, Worker, is_stale, refused};
|
||||
pub use content::{Drawn, TextBlock, Writing, Wrote};
|
||||
pub use ocr::{OcrLine, tessdata};
|
||||
pub use output::Optimizing;
|
||||
|
||||
@@ -26,6 +26,8 @@ pub struct Extras<'a> {
|
||||
pub stamp: Option<&'a str>,
|
||||
/// Page one: a 2x2 image, 100x50 at 72, 500.
|
||||
pub picture: bool,
|
||||
/// With `picture`: page one draws it full-page first.
|
||||
pub backdrop: bool,
|
||||
/// Fields hang under one parent of this name.
|
||||
pub parent: Option<&'a str>,
|
||||
/// A parent and child field with no widget.
|
||||
@@ -95,7 +97,11 @@ pub fn pdf_with(pages: &[(f32, f32, &[&str])], marks: &[Mark], extras: &Extras)
|
||||
_ => b"<< /Type /Outlines /Count 0 >>".to_vec(),
|
||||
});
|
||||
for (n, (w, h, lines)) in pages.iter().enumerate() {
|
||||
let mut stream = String::from("BT /F1 18 Tf 72 0 Td\n");
|
||||
let mut stream = match n == 0 && extras.backdrop {
|
||||
true => format!("q {w} 0 0 {h} 0 0 cm /Im1 Do Q\n"),
|
||||
false => String::new(),
|
||||
};
|
||||
stream.push_str("BT /F1 18 Tf 72 0 Td\n");
|
||||
for (at, line) in lines.iter().enumerate() {
|
||||
let y = h - 72.0 - at as f32 * 24.0;
|
||||
stream.push_str(&format!("1 0 0 1 72 {y} Tm ({line}) Tj\n"));
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, Worker, Writing, Wrote,
|
||||
sample::{Extras, pdf_with},
|
||||
sample::{Extras, one_page, pdf_with},
|
||||
};
|
||||
|
||||
fn worker() -> Worker {
|
||||
@@ -372,3 +372,64 @@ fn a_partial_to_unicode_falls_back_as_reading_does() {
|
||||
let doc = worker.open(&out).expect("reopens").doc;
|
||||
assert_eq!(page_text(&mut worker, doc), "BA", "saved and read again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dense_page_lists_its_blocks_in_one_pass() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut content = String::new();
|
||||
for col in 0..10 {
|
||||
for row in 0..100 {
|
||||
content.push_str(&format!("BT /F1 2 Tf {} {} Td (Cell {col} {row}) Tj ET\n", 20 + col * 58, 780 - row * 7));
|
||||
}
|
||||
}
|
||||
let font = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string();
|
||||
let path = dir.path().join("dense.pdf");
|
||||
std::fs::write(&path, one_page("", " /Resources << /Font << /F1 5 0 R >> >>", &content, &[font])).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
let started = std::time::Instant::now();
|
||||
let blocks = worker.text_blocks(doc, 0).expect("blocks");
|
||||
let took = started.elapsed();
|
||||
assert_eq!((blocks.len(), blocks.iter().filter(|b| b.editable).count()), (1000, 1000));
|
||||
// Per block took 1.5 s here; one pass 0.1 s.
|
||||
assert!(took < std::time::Duration::from_millis(750), "{took:?}");
|
||||
}
|
||||
|
||||
/// One page: `content`, font F1, and form Fm1 drawing `form`.
|
||||
fn layered(dir: &Path, content: &str, form: &str) -> (Worker, Doc) {
|
||||
let objects = [
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Length {} >>\nstream\n{form}\nendstream", form.len()),
|
||||
];
|
||||
let page = " /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> >>";
|
||||
let path = dir.join("layered.pdf");
|
||||
std::fs::write(&path, one_page("", page, content, &objects)).expect("writes");
|
||||
let mut worker = worker();
|
||||
let doc = worker.open(&path).expect("opens").doc;
|
||||
(worker, doc)
|
||||
}
|
||||
|
||||
fn editable(worker: &mut Worker, doc: Doc) -> Vec<(String, bool)> {
|
||||
let blocks = worker.text_blocks(doc, 0).expect("blocks");
|
||||
blocks.into_iter().map(|b| (b.text, b.editable)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turned_text_first_leaves_the_later_blocks_their_own_answer() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "BT /F1 18 Tf 0 1 -1 0 50 500 Tm (ROTATED) Tj ET q 1 0 0 1 300 400 cm /Fm1 Do Q BT /F1 18 Tf 72 200 Td (OWN) Tj ET";
|
||||
let (mut worker, doc) = layered(dir.path(), content, "BT /F1 12 Tf 0 10 Td (STAMP) Tj ET");
|
||||
let mut got = editable(&mut worker, doc);
|
||||
got.sort();
|
||||
assert_eq!(got, [("OWN".into(), true), ("ROTATED".into(), false), ("STAMP".into(), false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_that_share_a_glyph_origin_each_count_it() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let content = "q /Fm1 Do Q BT /F1 18 Tf 72 300 Td (BREAK) Tj ET BT /F1 18 Tf 72 700 Td (AB) Tj ET";
|
||||
let (mut worker, doc) = layered(dir.path(), content, "BT /F1 36 Tf 72 700 Td (AX) Tj ET");
|
||||
let got = editable(&mut worker, doc);
|
||||
assert!(got.contains(&("AB".into(), true)), "{got:?}");
|
||||
assert!(got.contains(&("AX".into(), false)), "{got:?}");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Images, text, optimized copies, printing leave, repaired files (C11.8).
|
||||
|
||||
use std::{io::ErrorKind, path::Path};
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, Lock, Optimizing, Worker,
|
||||
@@ -89,7 +89,8 @@ fn printing_and_copying_need_leave() {
|
||||
worker.protect(doc, &limited, Some(&Lock { user: String::new(), owner: "owner".into(), allow: may::EDIT })).expect("protects");
|
||||
let (mut other, read) = opened(&limited);
|
||||
for refused in [other.print_copy(read, &dir.path().join("p.pdf")), other.export_text(read, &dir.path().join("t.txt"))] {
|
||||
assert_eq!(refused.expect_err("refused").kind(), ErrorKind::PermissionDenied);
|
||||
let refused = refused.expect_err("refused");
|
||||
assert!(ely_pdf_engine::refused(&refused).is_some(), "refused: {refused}");
|
||||
}
|
||||
worker.print_copy(doc, &dir.path().join("print.pdf")).expect("an unprotected file prints");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Protected copies and what they allow (PDF-11).
|
||||
|
||||
use std::{io::ErrorKind, path::Path};
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Doc, Lock, Worker,
|
||||
@@ -43,7 +43,7 @@ fn a_protected_copy_asks_and_holds_its_limits() {
|
||||
let allowed = worker.permissions(opened.doc).expect("reads");
|
||||
assert_eq!(allowed & (may::PRINT | may::COPY | may::EDIT | may::OWNER), may::PRINT);
|
||||
let refused = worker.rotate_pages(opened.doc, &[0], 90).expect_err("refused");
|
||||
assert_eq!((refused.kind(), refused.to_string()), (ErrorKind::PermissionDenied, "assemble".to_string()));
|
||||
assert_eq!(ely_pdf_engine::refused(&refused), Some("assemble"));
|
||||
let text: String = worker.text(opened.doc, 0).expect("still reads").iter().map(|c| c.c).collect();
|
||||
assert!(text.contains("Hello"));
|
||||
}
|
||||
@@ -72,7 +72,7 @@ fn changing_protection_needs_the_owner() {
|
||||
worker.unlock(doc, "user").expect("unlocks");
|
||||
let bare = dir.path().join("bare.pdf");
|
||||
let refused = worker.protect(doc, &bare, None).expect_err("refused");
|
||||
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
|
||||
assert!(ely_pdf_engine::refused(&refused).is_some(), "refused: {refused}");
|
||||
assert!(!bare.exists());
|
||||
worker.owner(doc, "owner").expect("tries");
|
||||
worker.protect(doc, &bare, None).expect("unprotects");
|
||||
@@ -103,7 +103,7 @@ fn a_file_with_no_open_password_still_holds_limits() {
|
||||
let opened = other.open(&out).expect("opens");
|
||||
assert!(!opened.locked);
|
||||
let refused = other.annotate(opened.doc, &[]).expect_err("refused");
|
||||
assert_eq!((refused.kind(), refused.to_string()), (ErrorKind::PermissionDenied, "annotate".to_string()));
|
||||
assert_eq!(ely_pdf_engine::refused(&refused), Some("annotate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Signing by update and checking what was signed (PDF-12, QA-15).
|
||||
|
||||
use std::{io::ErrorKind, path::{Path, PathBuf}};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ely_pdf_engine::{
|
||||
Digest, Doc, Lock, Signing, Worker,
|
||||
@@ -128,7 +128,7 @@ fn a_new_field_needs_leave_to_change_the_page() {
|
||||
worker.protect(doc, &limited, Some(&lock)).expect("protects");
|
||||
let (mut other, read) = opened(&limited);
|
||||
let refused = other.sign(read, &dir.path().join("signed.pdf"), &signing(None, "S", "ely")).expect_err("refused");
|
||||
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
|
||||
assert!(ely_pdf_engine::refused(&refused).is_some(), "refused: {refused}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Puts a downloaded release in place of the running app.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
/// The `.app` running this process; none in dev builds.
|
||||
pub fn running_bundle() -> io::Result<Option<PathBuf>> {
|
||||
let exe = std::env::current_exe()?;
|
||||
Ok(bundle_of(&exe).map(Path::to_path_buf))
|
||||
}
|
||||
|
||||
fn bundle_of(exe: &Path) -> Option<&Path> {
|
||||
exe.ancestors().find(|dir| dir.extension().is_some_and(|ext| ext == "app"))
|
||||
}
|
||||
|
||||
/// Replaces `bundle` with the same-named app inside `dmg`.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn install(dmg: &Path, bundle: &Path) -> io::Result<()> {
|
||||
let name = bundle.file_name().ok_or_else(|| io::Error::other("the app has no name"))?;
|
||||
let parent = bundle.parent().ok_or_else(|| io::Error::other("the app has no folder"))?;
|
||||
let mount = tempfile::tempdir()?;
|
||||
run(Command::new("hdiutil").args(["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountpoint"]).arg(mount.path()).arg(dmg))?;
|
||||
let copied = stage(&mount.path().join(name), parent, name);
|
||||
let detached = run(Command::new("hdiutil").args(["detach", "-force"]).arg(mount.path()));
|
||||
let staged = copied?;
|
||||
detached?;
|
||||
swap(&staged, bundle)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn install(_dmg: &Path, _bundle: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "installing updates is not built for this platform"))
|
||||
}
|
||||
|
||||
/// Copies the new app beside the old, hidden.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn stage(from: &Path, parent: &Path, name: &std::ffi::OsStr) -> io::Result<PathBuf> {
|
||||
if !from.is_dir() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, format!("the download holds no {}", name.to_string_lossy())));
|
||||
}
|
||||
let staged = parent.join(format!(".{}.new", name.to_string_lossy()));
|
||||
if staged.exists() {
|
||||
std::fs::remove_dir_all(&staged)?;
|
||||
}
|
||||
run(Command::new("ditto").arg(from).arg(&staged))?;
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
/// Puts `staged` at `bundle`; on failure the old returns.
|
||||
fn swap(staged: &Path, bundle: &Path) -> io::Result<()> {
|
||||
let name = bundle.file_name().ok_or_else(|| io::Error::other("the app has no name"))?;
|
||||
let old = bundle.with_file_name(format!(".{}.old", name.to_string_lossy()));
|
||||
if old.exists() {
|
||||
std::fs::remove_dir_all(&old)?;
|
||||
}
|
||||
std::fs::rename(bundle, &old)?;
|
||||
if let Err(error) = std::fs::rename(staged, bundle) {
|
||||
std::fs::rename(&old, bundle)?;
|
||||
return Err(error);
|
||||
}
|
||||
log::info!("install: {} replaced", bundle.display());
|
||||
// Installed: an old copy left behind is only untidy.
|
||||
if let Err(error) = std::fs::remove_dir_all(&old) {
|
||||
log::warn!("install: the old copy at {} stays: {error}", old.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns once process `pid` has ended.
|
||||
#[cfg(unix)]
|
||||
pub fn wait_for_exit(pid: u32) {
|
||||
while unsafe { libc::kill(pid as libc::pid_t, 0) } == 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn wait_for_exit(_pid: u32) {}
|
||||
|
||||
/// Opens `bundle` as a fresh launch.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn reopen(bundle: &Path) -> io::Result<()> {
|
||||
run(Command::new("open").arg(bundle))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn reopen(_bundle: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "reopening after an update is not built for this platform"))
|
||||
}
|
||||
|
||||
/// Runs `command`; a failure carries what it said.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn run(command: &mut Command) -> io::Result<()> {
|
||||
let out = command.stdin(Stdio::null()).output()?;
|
||||
if out.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let said = String::from_utf8_lossy(&out.stderr);
|
||||
Err(io::Error::other(format!("{:?} failed ({}): {}", command.get_program(), out.status, said.trim())))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_bundle_is_the_nearest_app_folder() {
|
||||
let exe = Path::new("/Applications/ElyDoc.app/Contents/MacOS/ely-doc");
|
||||
assert_eq!(bundle_of(exe), Some(Path::new("/Applications/ElyDoc.app")));
|
||||
assert_eq!(bundle_of(Path::new("/repo/target/debug/ely-doc")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swap_replaces_the_app_and_leaves_no_copy() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (bundle, staged) = (dir.path().join("ElyDoc.app"), dir.path().join(".ElyDoc.app.new"));
|
||||
std::fs::create_dir_all(bundle.join("old")).expect("the old app");
|
||||
std::fs::create_dir_all(staged.join("new")).expect("the new app");
|
||||
swap(&staged, &bundle).expect("swaps");
|
||||
assert!(bundle.join("new").exists() && !bundle.join("old").exists());
|
||||
assert_eq!(std::fs::read_dir(dir.path()).expect("lists").count(), 1, "nothing else is left");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn an_old_copy_that_will_not_go_leaves_the_install_done() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (bundle, staged) = (dir.path().join("ElyDoc.app"), dir.path().join(".ElyDoc.app.new"));
|
||||
std::fs::create_dir_all(&bundle).expect("the old app");
|
||||
std::fs::write(bundle.join("version"), "old").expect("marks it");
|
||||
std::fs::create_dir_all(&staged).expect("the new app");
|
||||
std::fs::write(staged.join("version"), "new").expect("marks it");
|
||||
run(Command::new("chflags").arg("uchg").arg(bundle.join("version"))).expect("locks a file");
|
||||
swap(&staged, &bundle).expect("installed although the old copy stays");
|
||||
assert_eq!(std::fs::read_to_string(bundle.join("version")).expect("reads"), "new");
|
||||
let old = dir.path().join(".ElyDoc.app.old");
|
||||
run(Command::new("chflags").arg("nouchg").arg(old.join("version"))).expect("unlocks");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn a_dmg_installs_over_the_app() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let source = dir.path().join("src");
|
||||
std::fs::create_dir_all(source.join("ElyDoc.app/Contents")).expect("the new app");
|
||||
std::fs::write(source.join("ElyDoc.app/Contents/version"), "new").expect("marks it");
|
||||
let dmg = dir.path().join("ElyDoc.dmg");
|
||||
run(Command::new("hdiutil").args(["create", "-quiet", "-fs", "HFS+", "-srcfolder"]).arg(&source).arg(&dmg)).expect("a dmg");
|
||||
let bundle = dir.path().join("Applications/ElyDoc.app");
|
||||
std::fs::create_dir_all(bundle.join("Contents")).expect("the old app");
|
||||
std::fs::write(bundle.join("Contents/version"), "old").expect("marks it");
|
||||
install(&dmg, &bundle).expect("installs");
|
||||
assert_eq!(std::fs::read_to_string(bundle.join("Contents/version")).expect("reads"), "new");
|
||||
let empty = dir.path().join("Applications/Other.app");
|
||||
assert!(install(&dmg, &empty).is_err(), "a dmg without that app is refused");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//! One running copy per app; two would wipe shared data.
|
||||
|
||||
use std::{fs::File, io, path::Path, sync::OnceLock};
|
||||
|
||||
use crate::AppId;
|
||||
|
||||
/// The lock this process holds while it runs.
|
||||
static HELD: OnceLock<File> = OnceLock::new();
|
||||
|
||||
/// Takes `app`'s instance lock; a running copy refuses this one.
|
||||
pub fn hold(app: AppId) -> io::Result<()> {
|
||||
let dir = crate::data_dir(app).map_err(io::Error::other)?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
take(app, &dir)
|
||||
}
|
||||
|
||||
fn take(app: AppId, dir: &Path) -> io::Result<()> {
|
||||
let file = std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(dir.join("instance.lock"))?;
|
||||
match lock(&file)? {
|
||||
None => HELD.set(file).map_err(|_| io::Error::other("this process took its instance lock twice")),
|
||||
Some(holder) => Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} is already running ({holder}); open files in its window", app.name()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Locks `file`, or names its holder as the OS reports.
|
||||
#[cfg(unix)]
|
||||
fn lock(file: &File) -> io::Result<Option<String>> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = file.as_raw_fd();
|
||||
loop {
|
||||
let mut want: libc::flock = unsafe { std::mem::zeroed() };
|
||||
want.l_type = libc::F_WRLCK as _;
|
||||
want.l_whence = libc::SEEK_SET as _;
|
||||
if unsafe { libc::fcntl(fd, libc::F_SETLK, &want) } == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let error = io::Error::last_os_error();
|
||||
if !matches!(error.raw_os_error(), Some(libc::EACCES | libc::EAGAIN)) {
|
||||
return Err(error);
|
||||
}
|
||||
let mut held = want;
|
||||
if unsafe { libc::fcntl(fd, libc::F_GETLK, &mut held) } != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// Let go between the two calls: try again.
|
||||
if i64::from(held.l_type) != i64::from(libc::F_UNLCK) {
|
||||
return Ok(Some(format!("process {}", held.l_pid)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows names no holder; the lock alone refuses.
|
||||
#[cfg(windows)]
|
||||
fn lock(file: &File) -> io::Result<Option<String>> {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx};
|
||||
const ERROR_LOCK_VIOLATION: i32 = 33;
|
||||
let flags = LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY;
|
||||
let mut overlapped = unsafe { std::mem::zeroed() };
|
||||
match unsafe { LockFileEx(file.as_raw_handle(), flags, 0, u32::MAX, u32::MAX, &mut overlapped) } {
|
||||
0 if io::Error::last_os_error().raw_os_error() == Some(ERROR_LOCK_VIOLATION) => Ok(Some("another copy holds its lock".into())),
|
||||
0 => Err(io::Error::last_os_error()),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn copies_started_together_name_the_one_holder() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let exe = std::env::current_exe().expect("this test");
|
||||
let copies: Vec<_> = (0..6)
|
||||
.map(|_| {
|
||||
std::process::Command::new(&exe)
|
||||
.args(["--exact", "instance::tests::one_copy", "--nocapture"])
|
||||
.env("ELY_INSTANCE_DIR", dir.path())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.expect("a copy")
|
||||
})
|
||||
.collect();
|
||||
let said: Vec<String> = copies
|
||||
.into_iter()
|
||||
.map(|copy| String::from_utf8_lossy(©.wait_with_output().expect("ends").stdout).into_owned())
|
||||
.collect();
|
||||
let holders: Vec<&String> = said.iter().filter(|out| out.contains("HELD")).collect();
|
||||
assert_eq!(holders.len(), 1, "{said:?}");
|
||||
let holder = holders[0].split("HELD ").nth(1).and_then(|rest| rest.split_whitespace().next()).expect("its pid");
|
||||
let refused: Vec<&String> = said.iter().filter(|out| out.contains("REFUSED")).collect();
|
||||
assert_eq!(refused.len(), 5, "{said:?}");
|
||||
// Unix names the holder; Windows cannot ask.
|
||||
let named = if cfg!(unix) { format!("(process {holder})") } else { "(another copy holds its lock)".into() };
|
||||
for out in refused {
|
||||
assert!(out.contains(&named), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One copy, in a process of its own.
|
||||
#[test]
|
||||
fn one_copy() {
|
||||
let Ok(dir) = std::env::var("ELY_INSTANCE_DIR") else {
|
||||
return;
|
||||
};
|
||||
match take(AppId::Doc, Path::new(&dir)) {
|
||||
Ok(()) => {
|
||||
println!("HELD {} ", std::process::id());
|
||||
std::thread::sleep(std::time::Duration::from_millis(1500));
|
||||
}
|
||||
Err(error) => println!("REFUSED {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,14 @@ mod dirs;
|
||||
pub mod env;
|
||||
pub mod git;
|
||||
pub mod graphics;
|
||||
pub mod install;
|
||||
pub mod instance;
|
||||
pub mod locale;
|
||||
pub mod lock;
|
||||
pub mod logging;
|
||||
pub mod media;
|
||||
pub mod print;
|
||||
pub mod release;
|
||||
pub mod settings;
|
||||
pub mod startup;
|
||||
pub mod storage;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! The newest release's files, as the release server lists them.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Latest {
|
||||
tag_name: String,
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
/// Where the latest release at `api` keeps the file `name`.
|
||||
pub fn asset_url(api: &str, name: &str) -> Result<String> {
|
||||
let text = crate::update::fetch(&crate::update::manifest_agent(), api)?
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(1 << 20)
|
||||
.read_to_string()
|
||||
.context("cannot read the latest release")?;
|
||||
asset_in(&text, name)
|
||||
}
|
||||
|
||||
fn asset_in(text: &str, name: &str) -> Result<String> {
|
||||
let latest: Latest = serde_json::from_str(text).context("the latest release is not valid")?;
|
||||
match latest.assets.into_iter().find(|asset| asset.name == name) {
|
||||
Some(asset) => Ok(asset.browser_download_url),
|
||||
None => bail!("release {} has no {name}", latest.tag_name),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_named_file_is_found_or_said_missing() {
|
||||
let text = r#"{"tag_name":"v0.1.2","assets":[{"name":"ely-doc.json","browser_download_url":"https://h/d.json"}]}"#;
|
||||
assert_eq!(super::asset_in(text, "ely-doc.json").unwrap(), "https://h/d.json");
|
||||
let missing = super::asset_in(text, "ely-tree.json").unwrap_err().to_string();
|
||||
assert_eq!(missing, "release v0.1.2 has no ely-tree.json");
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use serde_json::{Map, Value};
|
||||
use crate::{AppId, lock::FileLock};
|
||||
|
||||
mod shared;
|
||||
pub use shared::{APPEARANCE, AUTHOR, Appearance, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Shared, word};
|
||||
pub use shared::{APPEARANCE, AUTHOR, Appearance, CHECK_UPDATES, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Shared, word};
|
||||
|
||||
/// The layout this build reads and writes.
|
||||
pub const VERSION: u64 = 1;
|
||||
|
||||
@@ -38,6 +38,7 @@ pub const LANGUAGE: &str = "language";
|
||||
pub const MOTION: &str = "motion";
|
||||
pub const OMARCHY_SYNC: &str = "omarchy_sync";
|
||||
pub const AUTHOR: &str = "author";
|
||||
pub const CHECK_UPDATES: &str = "check_updates";
|
||||
|
||||
/// The shared settings; absent fields take their defaults.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -48,6 +49,8 @@ pub struct Shared {
|
||||
pub omarchy_sync: bool,
|
||||
/// Signs comments and changes; empty means the account name.
|
||||
pub author: String,
|
||||
/// Asks for a newer release at launch; off unless chosen.
|
||||
pub check_updates: bool,
|
||||
}
|
||||
|
||||
impl Default for Shared {
|
||||
@@ -58,6 +61,7 @@ impl Default for Shared {
|
||||
motion: Motion::System,
|
||||
omarchy_sync: true,
|
||||
author: String::new(),
|
||||
check_updates: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +96,11 @@ impl Shared {
|
||||
appearance: pick(fields, APPEARANCE, &Appearance::ALL)?.unwrap_or(defaults.appearance),
|
||||
language: pick(fields, LANGUAGE, &Language::ALL)?.unwrap_or(defaults.language),
|
||||
motion: pick(fields, MOTION, &Motion::ALL)?.unwrap_or(defaults.motion),
|
||||
check_updates: match fields.get(CHECK_UPDATES) {
|
||||
None => defaults.check_updates,
|
||||
Some(Value::Bool(on)) => *on,
|
||||
Some(other) => return Err(wrong(CHECK_UPDATES, other)),
|
||||
},
|
||||
omarchy_sync: match fields.get(OMARCHY_SYNC) {
|
||||
None => defaults.omarchy_sync,
|
||||
Some(Value::Bool(on)) => *on,
|
||||
|
||||
@@ -63,14 +63,15 @@ fn shared_fields_type_strictly_and_default_when_absent() {
|
||||
let fields = |text: &str| serde_json::from_str::<serde_json::Map<_, _>>(text).unwrap();
|
||||
assert_eq!(Shared::from_fields(&fields("{}")).unwrap(), Shared::default());
|
||||
let read = Shared::from_fields(&fields(
|
||||
r#"{"appearance":"dark","language":"zh-CN","motion":"reduce","omarchy_sync":false,"author":" Ana "}"#,
|
||||
r#"{"appearance":"dark","language":"zh-CN","motion":"reduce","omarchy_sync":false,"author":" Ana ","check_updates":true}"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(read.check_updates && !Shared::default().check_updates, "asked for, never by default");
|
||||
assert_eq!(
|
||||
(read.appearance, read.language, read.motion, read.omarchy_sync, read.author.as_str()),
|
||||
(Appearance::Dark, Language::Chinese, Motion::Reduced, false, "Ana")
|
||||
);
|
||||
for bad in [r#"{"appearance":"blue"}"#, r#"{"omarchy_sync":"yes"}"#, r#"{"author":3}"#] {
|
||||
for bad in [r#"{"appearance":"blue"}"#, r#"{"omarchy_sync":"yes"}"#, r#"{"author":3}"#, r#"{"check_updates":1}"#] {
|
||||
assert!(Shared::from_fields(&fields(bad)).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ fn secure_host(url: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
/// GETs `url`, following redirects only to places `secure_host` accepts.
|
||||
fn fetch(agent: &Agent, url: &str) -> Result<Response<Body>> {
|
||||
pub(crate) fn fetch(agent: &Agent, url: &str) -> Result<Response<Body>> {
|
||||
let mut url = url.to_string();
|
||||
for _ in 0..=MAX_REDIRECTS {
|
||||
secure_host(&url)?;
|
||||
@@ -335,7 +335,7 @@ fn fetch(agent: &Agent, url: &str) -> Result<Response<Body>> {
|
||||
bail!("too many redirects")
|
||||
}
|
||||
|
||||
fn manifest_agent() -> Agent {
|
||||
pub(crate) fn manifest_agent() -> Agent {
|
||||
Agent::config_builder()
|
||||
.timeout_global(Some(MANIFEST_TIMEOUT))
|
||||
.max_redirects(0)
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
use std::io;
|
||||
|
||||
/// The author name from settings, else the account's.
|
||||
pub fn name() -> io::Result<String> {
|
||||
let set = crate::settings::Shared::load()?.author;
|
||||
if !set.is_empty() {
|
||||
return Ok(set);
|
||||
/// `author` as the settings give it, else the account's name.
|
||||
pub fn named(author: &str) -> io::Result<String> {
|
||||
if !author.is_empty() {
|
||||
return Ok(author.to_string());
|
||||
}
|
||||
account()
|
||||
}
|
||||
@@ -61,8 +60,10 @@ fn full_name() -> io::Result<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_account_has_a_name() {
|
||||
let name = super::name().expect("a name");
|
||||
assert!(!name.is_empty());
|
||||
fn a_set_name_signs_else_the_account() {
|
||||
assert_eq!(super::named("Ada").expect("a name"), "Ada");
|
||||
let account = super::named("").expect("a name");
|
||||
assert!(!account.is_empty());
|
||||
assert_eq!(account, super::account().expect("the account"));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ ely-platform.workspace = true
|
||||
gpui.workspace = true
|
||||
directories.workspace = true
|
||||
futures.workspace = true
|
||||
semver.workspace = true
|
||||
gpui_platform.workspace = true
|
||||
log.workspace = true
|
||||
notify.workspace = true
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use ely_platform::AppId;
|
||||
use gpui::{Action, App, KeyBinding, Menu, MenuItem, SharedString, Window, actions};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{Action, App, Global, KeyBinding, Menu, MenuItem, SharedString, Window, actions};
|
||||
|
||||
use crate::i18n;
|
||||
|
||||
@@ -16,6 +18,7 @@ actions!(
|
||||
CloseTab,
|
||||
ToggleCommandPalette,
|
||||
OpenSettings,
|
||||
CheckForUpdates,
|
||||
Quit
|
||||
]
|
||||
);
|
||||
@@ -25,6 +28,29 @@ actions!(
|
||||
[OpenElyDoc, OpenElyTable, OpenElyShow, OpenElyPdf, OpenElyMail, OpenElyTree, OpenElyMark, OpenElyAgent]
|
||||
);
|
||||
|
||||
/// The main window's own quit, run from any window.
|
||||
struct QuitRoute(Rc<dyn Fn(&mut App)>);
|
||||
|
||||
impl Global for QuitRoute {}
|
||||
|
||||
/// Quit from every window, settings too, runs `quit`.
|
||||
pub fn route_quit(cx: &mut App, quit: impl Fn(&mut App) + 'static) {
|
||||
cx.set_global(QuitRoute(Rc::new(quit)));
|
||||
}
|
||||
|
||||
/// Quit, from any window, goes through `quit`.
|
||||
pub fn bind_quit(cx: &mut App) {
|
||||
cx.on_action(|_: &Quit, cx| quit(cx));
|
||||
}
|
||||
|
||||
/// Quits through the main window's route, if one is set.
|
||||
pub(crate) fn quit(cx: &mut App) {
|
||||
match cx.try_global::<QuitRoute>().map(|route| Rc::clone(&route.0)) {
|
||||
Some(route) => route(cx),
|
||||
None => cx.quit(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The action that starts `app` (§16 app switching).
|
||||
pub fn switch_to(app: AppId) -> Box<dyn Action> {
|
||||
match app {
|
||||
@@ -146,6 +172,10 @@ pub fn menus(
|
||||
first.push(MenuItem::separator());
|
||||
}
|
||||
first.push(MenuItem::action(i18n::text(cx, "menu.app.settings", &[]), OpenSettings));
|
||||
// Only an installed app can update itself.
|
||||
if crate::updates::updater(cx).is_some() {
|
||||
first.push(MenuItem::action(i18n::text(cx, "menu.app.updates", &[]), CheckForUpdates));
|
||||
}
|
||||
first.push(MenuItem::separator());
|
||||
first.push(MenuItem::action(i18n::text(cx, "menu.app.quit", &[("app", app.name())]), Quit));
|
||||
let mut menus = vec![
|
||||
|
||||
@@ -24,6 +24,9 @@ use gpui::{App, SharedString};
|
||||
|
||||
const EN: &[(&str, &str)] = &[
|
||||
("menu.app.quit", "Quit {app}"),
|
||||
("updates.failed", "The update did not finish: {error}"),
|
||||
("updates.latest", "{app} {version} is the latest version."),
|
||||
("menu.app.updates", "Check for Updates…"),
|
||||
("menu.app.switch", "Open Ely App"),
|
||||
("toast.open", "Open"),
|
||||
("toast.open_failed", "The file did not open: {error}"),
|
||||
@@ -76,6 +79,9 @@ const EN: &[(&str, &str)] = &[
|
||||
|
||||
const ZH_CN: &[(&str, &str)] = &[
|
||||
("menu.app.quit", "退出 {app}"),
|
||||
("updates.failed", "更新没有完成:{error}"),
|
||||
("updates.latest", "{app} {version} 已是最新版本。"),
|
||||
("menu.app.updates", "检查更新…"),
|
||||
("menu.app.switch", "打开 Ely 应用"),
|
||||
("toast.open", "打开"),
|
||||
("toast.open_failed", "文件没能打开:{error}"),
|
||||
|
||||
@@ -9,6 +9,11 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("settings.appearance.light", "Light"),
|
||||
("settings.appearance.dark", "Dark"),
|
||||
("settings.omarchy", "Follow Omarchy"),
|
||||
("settings.updates", "Check for updates at launch"),
|
||||
(
|
||||
"settings.updates.hint",
|
||||
"Off, Ely asks its release server for a newer version only when you choose Check for Updates.",
|
||||
),
|
||||
("settings.language", "Language"),
|
||||
("settings.language.system", "System"),
|
||||
("settings.language.next", "Applies the next time each app starts"),
|
||||
@@ -64,6 +69,11 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("settings.appearance.light", "浅色"),
|
||||
("settings.appearance.dark", "深色"),
|
||||
("settings.omarchy", "跟随 Omarchy"),
|
||||
("settings.updates", "启动时检查更新"),
|
||||
(
|
||||
"settings.updates.hint",
|
||||
"关闭时,只在你选择“检查更新”时才向发布服务器询问新版本。",
|
||||
),
|
||||
("settings.language", "语言"),
|
||||
("settings.language.system", "跟随系统"),
|
||||
("settings.language.next", "各应用下次启动时生效"),
|
||||
|
||||
@@ -45,6 +45,9 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("doc.merge.open", "Mail Merge…"),
|
||||
("doc.merge.title", "Mail Merge"),
|
||||
("doc.merge.data", "Data"),
|
||||
("doc.merge.template", "Template"),
|
||||
("doc.merge.gone", "The template is closed. Choose the data again."),
|
||||
("doc.merge.from_preview", "A preview cannot be a template. Switch to the original document."),
|
||||
("doc.merge.choose", "Choose CSV…"),
|
||||
("doc.merge.fields", "Fields"),
|
||||
("doc.merge.record", "Record"),
|
||||
@@ -104,6 +107,9 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("doc.merge.open", "邮件合并…"),
|
||||
("doc.merge.title", "邮件合并"),
|
||||
("doc.merge.data", "数据"),
|
||||
("doc.merge.template", "模板"),
|
||||
("doc.merge.gone", "模板已关闭,请重新选择数据。"),
|
||||
("doc.merge.from_preview", "预览不能作模板,请切回原文档。"),
|
||||
("doc.merge.choose", "选择 CSV…"),
|
||||
("doc.merge.fields", "字段"),
|
||||
("doc.merge.record", "记录"),
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod settings;
|
||||
mod shell;
|
||||
pub mod tasks;
|
||||
mod theme;
|
||||
pub mod updates;
|
||||
|
||||
pub use opening::{launch_files, on_open};
|
||||
pub use run::run;
|
||||
|
||||
@@ -15,6 +15,14 @@ pub fn run<V: Render>(
|
||||
app: AppId,
|
||||
root: impl FnOnce(AppId, &mut Window, &mut App) -> Entity<V> + 'static,
|
||||
) {
|
||||
// As the installer after an update, nothing else runs.
|
||||
if let Some(code) = crate::updates::installer(app) {
|
||||
std::process::exit(code);
|
||||
}
|
||||
// Before the log: a second copy must not trim it.
|
||||
if let Err(error) = ely_platform::instance::hold(app) {
|
||||
startup::fail(app, &format!("{error}"));
|
||||
}
|
||||
if let Err(error) = logging::init(app) {
|
||||
startup::fail(app, &format!("logging did not start: {error:#}"));
|
||||
}
|
||||
@@ -73,8 +81,10 @@ fn open<V: Render>(
|
||||
}
|
||||
theme::install(cx);
|
||||
crate::i18n::install(cx);
|
||||
crate::updates::install(app, cx);
|
||||
cx.on_action(|_: &crate::commands::CheckForUpdates, cx| crate::updates::check_now(cx));
|
||||
crate::commands::bind_keys(cx);
|
||||
cx.on_action(|_: &crate::commands::Quit, cx| cx.quit());
|
||||
crate::commands::bind_quit(cx);
|
||||
cx.on_action(move |_: &crate::commands::OpenSettings, cx| crate::settings::open(app, cx));
|
||||
let mut options = WindowOptions {
|
||||
focus: smoke == Smoke::Off,
|
||||
|
||||
@@ -9,7 +9,7 @@ use ely_gpui_component::{
|
||||
};
|
||||
use ely_platform::{
|
||||
AppId,
|
||||
settings::{APPEARANCE, AUTHOR, Appearance, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Store, word},
|
||||
settings::{APPEARANCE, AUTHOR, Appearance, CHECK_UPDATES, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Store, word},
|
||||
};
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, FocusHandle, Global, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
@@ -222,7 +222,7 @@ impl SettingsView {
|
||||
Motion::Reduced => text(cx, "settings.motion.reduce", &[]),
|
||||
Motion::Full => text(cx, "settings.motion.full", &[]),
|
||||
}, MOTION, cx);
|
||||
let mut section = SettingsSection::new(text(cx, "settings.general", &[]))
|
||||
let section = SettingsSection::new(text(cx, "settings.general", &[]))
|
||||
.row(SettingsRow::new(text(cx, "settings.appearance", &[])).control(theme))
|
||||
.row(
|
||||
SettingsRow::new(text(cx, "settings.language", &[]))
|
||||
@@ -230,6 +230,14 @@ impl SettingsView {
|
||||
.control(language),
|
||||
)
|
||||
.row(SettingsRow::new(text(cx, "settings.motion", &[])).control(motion));
|
||||
let updates = Switch::new("settings-updates", shared.check_updates)
|
||||
.name(text(cx, "settings.updates", &[]))
|
||||
.on_change(|on, _, cx| set(CHECK_UPDATES, Value::Bool(on), cx));
|
||||
let mut section = section.row(
|
||||
SettingsRow::new(text(cx, "settings.updates", &[]))
|
||||
.description(text(cx, "settings.updates.hint", &[]))
|
||||
.control(updates),
|
||||
);
|
||||
// Only where Omarchy is installed.
|
||||
if ely_platform::omarchy_current_dir().is_some() {
|
||||
let follow = Switch::new("settings-omarchy", shared.omarchy_sync)
|
||||
|
||||
@@ -124,6 +124,7 @@ impl RenderOnce for Shell {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(TitleBar::new("titlebar").title(self.title))
|
||||
.children(crate::updates::updater(cx))
|
||||
.children(menu_bar(cx))
|
||||
.children(self.tabs);
|
||||
// Panels give way so the content keeps its room.
|
||||
|
||||
@@ -102,6 +102,33 @@ fn build(
|
||||
if !document {
|
||||
window.focus(&focus, cx);
|
||||
}
|
||||
if let Some(updates) = crate::updates::updater(cx) {
|
||||
cx.subscribe(
|
||||
&updates,
|
||||
|root: &mut Root, _, news: &crate::updates::UpdateNews, cx| match news {
|
||||
crate::updates::UpdateNews::Failed(error) => {
|
||||
let text = crate::i18n::text(cx, "updates.failed", &[("error", error)]);
|
||||
root.toast(text, Severity::Danger, cx);
|
||||
}
|
||||
crate::updates::UpdateNews::Latest => {
|
||||
let args = [
|
||||
("app", root.app.name()),
|
||||
("version", env!("CARGO_PKG_VERSION")),
|
||||
];
|
||||
let text = crate::i18n::text(cx, "updates.latest", &args);
|
||||
root.toast(text, Severity::Success, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
// A failed install from the last quit says why.
|
||||
if let Some(note) = updates.update(cx, |updates, _| updates.note.take()) {
|
||||
let text = crate::i18n::text(cx, "updates.failed", &[("error", ¬e)]);
|
||||
cx.defer_in(window, move |root: &mut Root, _, cx| {
|
||||
root.toast(text, Severity::Danger, cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
Root {
|
||||
app,
|
||||
focus,
|
||||
@@ -154,6 +181,11 @@ impl Root {
|
||||
});
|
||||
}
|
||||
|
||||
/// The toasts on show: title and severity, oldest first.
|
||||
pub fn toasts(&self, cx: &gpui::App) -> Vec<(SharedString, Option<Severity>)> {
|
||||
self.toaster.read(cx).shown()
|
||||
}
|
||||
|
||||
/// A toast whose button opens the output `path` (§16).
|
||||
pub fn toast_output(
|
||||
&mut self,
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
//! A newer release: offered, downloaded, installed after quitting.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::atomic::AtomicBool,
|
||||
};
|
||||
|
||||
use ely_gpui_component::shell::{UpdateBanner, UpdateState};
|
||||
use ely_platform::{
|
||||
AppId,
|
||||
update::{self, Check, Release},
|
||||
};
|
||||
use futures::{StreamExt as _, channel::mpsc};
|
||||
use gpui::{
|
||||
App, AppContext as _, Context, Entity, EventEmitter, Global, IntoElement, Render, Window, div,
|
||||
};
|
||||
|
||||
/// The release server's newest release, listed by its API.
|
||||
const LATEST: &str = "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/ElyOffice/releases/latest";
|
||||
/// The argument that runs this binary as the installer.
|
||||
const INSTALL: &str = "--ely-install-update";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Phase {
|
||||
Offered,
|
||||
Fetching(f32),
|
||||
Fetched(PathBuf),
|
||||
}
|
||||
|
||||
pub struct Updates {
|
||||
app: AppId,
|
||||
bundle: PathBuf,
|
||||
offer: Option<(Release, Phase)>,
|
||||
dismissed: bool,
|
||||
/// The download to install once the app has quit.
|
||||
pending: Option<PathBuf>,
|
||||
/// Why the last install failed, for the window to say.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// What an update says to the window.
|
||||
pub enum UpdateNews {
|
||||
Failed(String),
|
||||
/// A check the user asked for found nothing newer.
|
||||
Latest,
|
||||
}
|
||||
|
||||
impl EventEmitter<UpdateNews> for Updates {}
|
||||
|
||||
struct Updater(Entity<Updates>);
|
||||
|
||||
impl Global for Updater {}
|
||||
|
||||
/// The app's updater, if it runs from an installed bundle.
|
||||
pub fn updater(cx: &App) -> Option<Entity<Updates>> {
|
||||
cx.try_global::<Updater>().map(|updater| updater.0.clone())
|
||||
}
|
||||
|
||||
/// Checks now, as the user asked from the menu.
|
||||
pub fn check_now(cx: &mut App) {
|
||||
if let Some(updates) = updater(cx) {
|
||||
updates.update(cx, |updates, cx| updates.check(true, cx));
|
||||
}
|
||||
}
|
||||
|
||||
/// The quit an update asked for stopped: install nothing.
|
||||
pub fn quit_stopped(cx: &mut App) {
|
||||
if let Some(updates) = updater(cx) {
|
||||
updates.update(cx, |updates, _| {
|
||||
if updates.pending.take().is_some() {
|
||||
log::info!("update: the quit stopped; nothing installs");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Run as the installer: waits, installs, reopens; the exit code.
|
||||
pub fn installer(app: AppId) -> Option<i32> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let [_, flag, dmg, bundle, pid] = args.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if flag != INSTALL {
|
||||
return None;
|
||||
}
|
||||
let Ok(pid) = pid.parse() else {
|
||||
eprintln!("update: {pid} is not a process number");
|
||||
return Some(2);
|
||||
};
|
||||
ely_platform::install::wait_for_exit(pid);
|
||||
let installed = ely_platform::install::install(Path::new(dmg), Path::new(bundle));
|
||||
if let Err(error) = &installed {
|
||||
// To the app's log; the reopened app shows it.
|
||||
eprintln!("update: the install failed: {error}");
|
||||
let note = ely_platform::data_dir(app)
|
||||
.map_err(io::Error::other)
|
||||
.and_then(|dir| {
|
||||
std::fs::create_dir_all(dir.join("updates"))?;
|
||||
std::fs::write(dir.join("updates/failed.txt"), error.to_string())
|
||||
});
|
||||
if let Err(lost) = note {
|
||||
eprintln!("update: the failure note was not written: {lost}");
|
||||
}
|
||||
}
|
||||
// Last: the note is written before the app reads it.
|
||||
if let Err(error) = ely_platform::install::reopen(Path::new(bundle)) {
|
||||
eprintln!("update: the app did not reopen: {error}");
|
||||
return Some(1);
|
||||
}
|
||||
Some(i32::from(installed.is_err()))
|
||||
}
|
||||
|
||||
/// An installed app's updater; checks at launch if chosen.
|
||||
pub(crate) fn install(app: AppId, cx: &mut App) {
|
||||
let bundle = match ely_platform::install::running_bundle() {
|
||||
Ok(Some(bundle)) => bundle,
|
||||
Ok(None) => return log::info!("update: a dev build; no check"),
|
||||
Err(error) => return log::warn!("update: the running app is not found: {error}"),
|
||||
};
|
||||
match update::install_policy() {
|
||||
Ok(update::InstallPolicy::SelfManaged) => {}
|
||||
Ok(update::InstallPolicy::PackageManager) => {
|
||||
return log::info!("update: the package manager updates this app");
|
||||
}
|
||||
Err(error) => return log::warn!("update: {error:#}"),
|
||||
}
|
||||
let note = ely_platform::data_dir(app)
|
||||
.ok()
|
||||
.map(|dir| dir.join("updates/failed.txt"));
|
||||
let note = note.and_then(|path| {
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.inspect(|_| drop(std::fs::remove_file(&path)))
|
||||
});
|
||||
let updates = cx.new(|cx| {
|
||||
cx.on_app_quit(|this: &mut Updates, _| {
|
||||
this.start_installer();
|
||||
async {}
|
||||
})
|
||||
.detach();
|
||||
Updates {
|
||||
app,
|
||||
bundle,
|
||||
offer: None,
|
||||
dismissed: false,
|
||||
pending: None,
|
||||
note,
|
||||
}
|
||||
});
|
||||
cx.set_global(Updater(updates.clone()));
|
||||
// Off by default: no request unless the user chose it.
|
||||
if crate::settings::current(cx).check_updates {
|
||||
updates.update(cx, |updates, cx| updates.check(false, cx));
|
||||
} else {
|
||||
log::info!("update: no check at launch; it is off in settings");
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's manifest in the newest release, or the test's.
|
||||
fn manifest(app: AppId) -> anyhow::Result<String> {
|
||||
match std::env::var("ELY_UPDATE_MANIFEST") {
|
||||
Ok(url) => Ok(url),
|
||||
Err(_) => ely_platform::release::asset_url(LATEST, &format!("{}.json", app.slug())),
|
||||
}
|
||||
}
|
||||
|
||||
impl Updates {
|
||||
/// Asks for a newer release; `asked` reports every outcome.
|
||||
fn check(&mut self, asked: bool, cx: &mut Context<Self>) {
|
||||
// A download under way or done stays the offer.
|
||||
if matches!(
|
||||
self.offer,
|
||||
Some((_, Phase::Fetching(_) | Phase::Fetched(_)))
|
||||
) {
|
||||
log::info!("update: a release is already on its way");
|
||||
if asked {
|
||||
self.dismissed = false;
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let app = self.app;
|
||||
let current =
|
||||
semver::Version::parse(env!("CARGO_PKG_VERSION")).expect("the build's version");
|
||||
// The lookup is a request too: off the window's thread.
|
||||
let checked = cx.background_spawn(async move { manifest(app).and_then(|url| update::check(¤t, &url)) });
|
||||
cx.spawn(async move |this, cx| {
|
||||
let checked = checked.await;
|
||||
this.update(cx, |this, cx| match checked {
|
||||
Ok(Check::Available(_))
|
||||
if matches!(
|
||||
this.offer,
|
||||
Some((_, Phase::Fetching(_) | Phase::Fetched(_)))
|
||||
) => {}
|
||||
Ok(Check::Available(release)) => {
|
||||
(this.offer, this.dismissed) = (Some((release, Phase::Offered)), false);
|
||||
cx.notify();
|
||||
}
|
||||
Ok(Check::Current) if asked => cx.emit(UpdateNews::Latest),
|
||||
// At launch a failure only logs; offline is normal.
|
||||
Err(error) if asked => cx.emit(UpdateNews::Failed(format!("{error:#}"))),
|
||||
Err(error) => log::warn!("update: the launch check failed: {error:#}"),
|
||||
Ok(Check::Current) => log::info!("update: nothing newer at launch"),
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn later(&mut self, cx: &mut Context<Self>) {
|
||||
self.dismissed = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn download(&mut self, cx: &mut Context<Self>) {
|
||||
let Some((release, phase)) = self
|
||||
.offer
|
||||
.as_mut()
|
||||
.filter(|(_, phase)| *phase == Phase::Offered)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
*phase = Phase::Fetching(0.0);
|
||||
cx.notify();
|
||||
let (release, app) = (release.clone(), self.app);
|
||||
let (sender, mut progress) = mpsc::unbounded();
|
||||
let fetching = release.clone();
|
||||
let fetched = cx.background_spawn(async move {
|
||||
let dir = ely_platform::data_dir(app)
|
||||
.map_err(|error| format!("{error:#}"))?
|
||||
.join("updates");
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||
let cancel = AtomicBool::new(false);
|
||||
let mut tell = |done: u64, total: Option<u64>| {
|
||||
let part = total
|
||||
.filter(|total| *total > 0)
|
||||
.map_or(0.0, |total| done as f32 / total as f32);
|
||||
sender.unbounded_send(part).ok();
|
||||
};
|
||||
let file = update::download(&fetching, &dir, &cancel, update::IDLE_TIMEOUT, &mut tell)
|
||||
.map_err(|error| format!("{error:#}"))?
|
||||
.ok_or("the download stopped")?;
|
||||
update::verify_file(&file, &fetching.sha256).map_err(|error| format!("{error:#}"))?;
|
||||
Ok::<PathBuf, String>(file)
|
||||
});
|
||||
cx.spawn(async move |this, cx| {
|
||||
while let Some(part) = progress.next().await {
|
||||
this.update(cx, |this, cx| this.fetching(part, cx)).ok();
|
||||
}
|
||||
let fetched = fetched.await;
|
||||
this.update(cx, |this, cx| this.fetched(&release, fetched, cx))
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn fetching(&mut self, part: f32, cx: &mut Context<Self>) {
|
||||
if let Some((_, phase @ Phase::Fetching(_))) = self.offer.as_mut() {
|
||||
*phase = Phase::Fetching(part);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// A finished download counts only for the release it fetched.
|
||||
fn fetched(
|
||||
&mut self,
|
||||
release: &Release,
|
||||
fetched: Result<PathBuf, String>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some((offered, phase)) = self
|
||||
.offer
|
||||
.as_mut()
|
||||
.filter(|(offered, _)| offered == release)
|
||||
else {
|
||||
return log::warn!("update: a download for another release was dropped");
|
||||
};
|
||||
match fetched {
|
||||
Ok(file) => *phase = Phase::Fetched(file),
|
||||
Err(error) => {
|
||||
*phase = Phase::Offered;
|
||||
cx.emit(UpdateNews::Failed(error));
|
||||
}
|
||||
}
|
||||
log::info!("update: {} is {:?}", offered.version, phase);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Quits as usual: unsaved work is asked about first.
|
||||
fn restart(&mut self, cx: &mut Context<Self>) {
|
||||
let Some((_, Phase::Fetched(file))) = self.offer.as_ref() else {
|
||||
return;
|
||||
};
|
||||
self.pending = Some(file.clone());
|
||||
crate::commands::quit(cx);
|
||||
}
|
||||
|
||||
/// At the real quit: the installer waits for this process.
|
||||
fn start_installer(&mut self) {
|
||||
let Some(file) = self.pending.take() else {
|
||||
return;
|
||||
};
|
||||
let started = std::env::current_exe().and_then(|exe| {
|
||||
let mut command = Command::new(exe);
|
||||
command
|
||||
.arg(INSTALL)
|
||||
.arg(&file)
|
||||
.arg(&self.bundle)
|
||||
.arg(std::process::id().to_string());
|
||||
// Its words go to the app's log.
|
||||
let log = ely_platform::logging::path()
|
||||
.map(|path| std::fs::OpenOptions::new().append(true).open(path));
|
||||
let said = match log {
|
||||
Some(file) => Stdio::from(file?),
|
||||
None => Stdio::null(),
|
||||
};
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(said);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::process::CommandExt::process_group(&mut command, 0);
|
||||
command.spawn()
|
||||
});
|
||||
match started {
|
||||
Ok(_) => log::info!("update: installs after this quit"),
|
||||
Err(error) => log::error!("update: the installer did not start: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Updates {
|
||||
/// The bar under the title while a release is offered.
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let this = cx.entity();
|
||||
let Some((release, phase)) = self.offer.as_ref().filter(|_| !self.dismissed) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let state = match phase {
|
||||
Phase::Offered => UpdateState::Available,
|
||||
Phase::Fetching(done) => UpdateState::Downloading(*done),
|
||||
Phase::Fetched(_) => UpdateState::Ready,
|
||||
};
|
||||
let (download, restart, later) = (this.clone(), this.clone(), this);
|
||||
let banner = UpdateBanner::new("update-banner", release.version.to_string(), state)
|
||||
.on_download(move |_, cx| download.update(cx, |this, cx| this.download(cx)))
|
||||
.on_later(move |_, cx| later.update(cx, |this, cx| this.later(cx)));
|
||||
match phase {
|
||||
Phase::Fetched(_) => banner
|
||||
.on_restart(move |_, cx| restart.update(cx, |this, cx| this.restart(cx)))
|
||||
.into_any_element(),
|
||||
_ => banner.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn release(version: &str, url: &str) -> Release {
|
||||
Release {
|
||||
version: semver::Version::parse(version).expect("a version"),
|
||||
url: url.into(),
|
||||
sha256: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_check_brings_back_a_dismissed_download(cx: &mut gpui::TestAppContext) {
|
||||
let updates = cx.new(|_| Updates {
|
||||
app: AppId::Tree,
|
||||
bundle: PathBuf::from("/Applications/ElyTree.app"),
|
||||
offer: Some((
|
||||
release("9.0.0", "https://b"),
|
||||
Phase::Fetched(PathBuf::from("B.dmg")),
|
||||
)),
|
||||
dismissed: true,
|
||||
pending: None,
|
||||
note: None,
|
||||
});
|
||||
updates.update(cx, |updates, cx| updates.check(true, cx));
|
||||
updates.read_with(cx, |updates, _| {
|
||||
assert!(!updates.dismissed, "the bar shows again");
|
||||
assert_eq!(
|
||||
updates.offer.as_ref().map(|(_, phase)| phase.clone()),
|
||||
Some(Phase::Fetched(PathBuf::from("B.dmg")))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_download_for_an_older_offer_is_dropped(cx: &mut gpui::TestAppContext) {
|
||||
let updates = cx.new(|_| Updates {
|
||||
app: AppId::Tree,
|
||||
bundle: PathBuf::from("/Applications/ElyTree.app"),
|
||||
offer: Some((release("9.0.0", "https://b"), Phase::Fetching(0.5))),
|
||||
dismissed: false,
|
||||
pending: None,
|
||||
note: None,
|
||||
});
|
||||
updates.update(cx, |updates, cx| {
|
||||
updates.fetched(
|
||||
&release("8.0.0", "https://a"),
|
||||
Ok(PathBuf::from("A.dmg")),
|
||||
cx,
|
||||
);
|
||||
assert_eq!(
|
||||
updates.offer.as_ref().map(|(_, phase)| phase.clone()),
|
||||
Some(Phase::Fetching(0.5))
|
||||
);
|
||||
updates.fetched(
|
||||
&release("9.0.0", "https://b"),
|
||||
Ok(PathBuf::from("B.dmg")),
|
||||
cx,
|
||||
);
|
||||
assert_eq!(
|
||||
updates.offer.as_ref().map(|(_, phase)| phase.clone()),
|
||||
Some(Phase::Fetched(PathBuf::from("B.dmg")))
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+631
@@ -0,0 +1,631 @@
|
||||
# 缺陷清单
|
||||
|
||||
2026-10-09 对首版六个应用(ElyDoc、ElyTable、ElyShow、ElyPDF、ElyMark、ElyTree)与其共用 crate 的全量静态审查。逐行读了 `apps/` 六个应用、`crates/` 全部源码(含 C 垫片与 ElyMark 网页脚本);Calc 与 Impress 的内核行为对照上游 LibreOffice `libreoffice-26-2` 分支源码核实(`sc/source/ui/view/*.cxx`、`sd/source/ui/view/*.cxx`、`desktop/source/lib/init.cxx`)。审查容器里没有引擎与组件库,没有运行与构建;凡标“已确认”都是沿调用链读到底的结论,“可能”是路径成立但依赖未能运行核实的条件。
|
||||
|
||||
编号前缀:T ElyTable,S ElyShow,P ElyPDF,D ElyDoc,M ElyMark,G ElyTree,K LOK 桥(`crates/ely-lok`),X 共用外壳(`ely-office`、`ely-files`、`ely-ui`、`ely-platform`、`ely-ipc`)。严重度:致命=崩溃、丢数据或功能不可用;高=常见路径结果错或功能坏;中=边缘路径坏或界面误导;低=小问题。
|
||||
|
||||
|
||||
## 统计
|
||||
|
||||
| 节 | 致命 | 高 | 中 | 低 | 合计 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| T ElyTable 与 LOK 画布 | 1 | 1 | 7 | 6 | 15 |
|
||||
| S ElyShow | 2 | 0 | 2 | 6 | 10 |
|
||||
| P ElyPDF | 1 | 3 | 4 | 6 | 14 |
|
||||
| D ElyDoc | 0 | 2 | 2 | 4 | 8 |
|
||||
| M ElyMark | 0 | 1 | 5 | 8 | 14 |
|
||||
| G ElyTree | 0 | 0 | 3 | 11 | 14 |
|
||||
| K LOK 桥 | 0 | 1 | 1 | 2 | 4 |
|
||||
| X 共用外壳与平台 | 1 | 2 | 4 | 7 | 14 |
|
||||
| 合计 | 5 | 10 | 28 | 50 | 93 |
|
||||
|
||||
致命:T-01 内核的图块与命中按像素网格,Ely 的光标、选区、表头按文档 twips,越远越错位;S-01 切换幻灯片后主画布不重画;S-03 ElyShow 的缩放一两帧后弹回“适合幻灯片”,缩放功能不可用;P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃;X-01 设置窗口在前时 Cmd+Q 直接退出,跳过未保存询问并抹掉恢复点。
|
||||
|
||||
高:T-02 单元格光标露出边缘即把视图滚到中央,点击与拖选时视图跳动;D-01 导出或打印对话框开着时关闭其标签即崩溃;D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录;P-02 每次编辑落地清掉全部图块与缩略图,页面空白、刚画的墨迹消失;P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图;P-04 `TEXT_BLOCKS` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应;M-01 键入期间外部以相同内容重写文件,丢掉上次停顿之后键入的全部文字;K-01 非 macOS 的 worker 不运行 VCL 主循环,内核的回调与异步输入永远不送达;X-02 共用 `settings.json` 读不出时 Doc、Table、Show 根本起不来;X-03 第二个实例启动即清掉运行中实例的工作目录与恢复点。
|
||||
|
||||
## 用户报告的三个问题
|
||||
|
||||
### ElyTable:点一个单元格,光标跳到别处;拖不出多格选区
|
||||
|
||||
根因是 T-01:Ely 把 Calc 的“打印 twips”(文档坐标)与“图块 twips”(按内核像素网格对齐的坐标)当成同一套。内核画图块与命中测试都按它自己每行每列取整后的像素网格(`ScViewData::ToPixel` 截断),而单元格光标、选区、表头几何都以文档 twips 报回;越往右下,两套坐标相差越多,到第 300 行左右相差整整一行。点击落在内核的像素网格上(选中的是鼠标下画出的那格),但光标框与表头按文档 twips 画,出现在相邻的格上,看起来就是“跳到别处”。T-02 让部分可见的格一被点中就把视图滚半屏,拖选到边缘时内容在指针下移动,选区随之错位。T-03、T-04 是同一组问题的两个侧面。
|
||||
|
||||
### ElyShow:换幻灯片后主视图不变,要拉一下窗口才刷新
|
||||
|
||||
根因是 S-01:Impress 在 `SwitchPage` 期间压掉一切图块作废通知(`sd::Window::LogicInvalidate` 里 `IsInSwitchPage()` 直接返回),只发 `LOK_CALLBACK_SET_PART`;`LokView` 收到 `SET_PART` 只加计数,不作废图块,`Input::Part` 落地时也不作废。旧幻灯片的图块一直显示,直到窗口尺寸变化让适合缩放变了、全部图块退役重画。
|
||||
|
||||
### ElyPDF:编辑不工作、画不出
|
||||
|
||||
鼠标、命中与坐标换算本身是对的(`pages.rs` 按工具分派到 `press`/`drag_to`/`release` 或 `sketch_*`,`page_at`/`from_view` 与垫片 `placed()` 互逆)。失败来自状态与时序:P-02 每次编辑落地都清掉全部图块与缩略图,页面先变成空白纸再一块一块重画,刚画的墨迹要等所在图块重画后才出现,重的页面上像“什么也没画出来”;P-05 上一笔还在落地时结束的下一笔被拒并提示“文档已改动”,连续手绘会丢笔画;P-03 “编辑文字和图片”先命中图片再命中文字,整页背景图或扫描件上永远点不到文字,点击稍一抖动就成了移动背景图;P-04 该工具的层读取在每个文字块上重跑一遍整页内容过滤,密集页面要等很久,等待期间点击被静默忽略;P-06 字体默认“原字体”,嵌入 Type 1/Type 3 字体的文件一应用就报原始引擎错误。另有 P-01:写到无权限目录等任何 `PermissionDenied` 都被拼成不存在的文案键,直接崩溃。
|
||||
|
||||
## T ElyTable 与 LOK 画布(Calc)
|
||||
|
||||
### T-01 内核的图块与命中按像素网格,Ely 的光标、选区、表头按文档 twips,越远越错位
|
||||
- 严重度:致命 | 置信度:已确认(上游源码核实)
|
||||
- 位置:`crates/ely-lok/src/view/scroll.rs:112`(`doc_point` 线性换算)、`crates/ely-lok/src/view/render.rs:275-330`(图块按 `TILE_TWIPS` 线性排布)、`apps/ely-table/src/grid.rs:96-121`(表头按 `place()` 线性换算)、`crates/ely-lok/src/view.rs:382-387`(单元格光标按打印 twips 直接用)
|
||||
- 现象:滚到几百行之后点一格,名称框显示的地址是鼠标下画出的那格,但光标框画在相邻行列上,表头标号也对不上;拖选时选区高亮与格线错开;行高非默认(如 0.2 cm)的表十几行就错一行。
|
||||
- 原因:上游 `ScGridWindow::PaintTile`(`sc/source/ui/view/gridwin4.cxx:1459-1600`)把图块起点换成像素(`fTilePosXPixel = nTilePosX * nOutputWidth / nTileWidth`),再用 `ScPositionHelper`/`ScBoundsProvider` 在**累加取整的像素**网格里找首行首列,以该列的像素起点为原点画;`ScModelObj::postMouseEvent`(`docuno.cxx:852`)把 twips 乘 `GetPPTX()` 成像素后 `GetPosFromPixel`(`viewdata.cxx:2858`)逐行 `ToPixel` 累加命中。默认行高 256 twips 取整成 17 像素(真值 17.07),每行差 0.067 像素,约 255 行差一行;默认列宽 1280 twips 取整成 85 像素,约 256 列差一列。而 `sc_print_twips_msgs` 下 CELL_CURSOR、TEXT_SELECTION、`SheetGeometryData` 都是文档 twips,上游专门有 `ScViewData::GetPrintTwipsPosFromTileTwips`(`viewdata.cxx:2637`)做两套坐标的换算,Collabora Online 的客户端也以 `SheetGeometry` 在两者之间换算;Ely 没有这一步。
|
||||
- 复现:新建工作簿,键入 1 回车三百次(或打开任何几百行的 xlsx),滚到第 300 行,点 A300:光标框落在 A301 附近,左侧表头标的是 299;拖选 A300:C305,高亮与格线错开。
|
||||
- 修法:以 `.uno:SheetGeometryData` 为准在客户端建像素网格(按内核同样的 `ToPixel` 截断累加),把鼠标与可见区从文档 twips 换成图块 twips 再发给内核,把 CELL_CURSOR、选区、引用标记从文档 twips 换回图块 twips 再画;并调用 `setClientZoom` 让内核的 PPTX 与 Ely 的图块比例一致(见 T-04)。
|
||||
|
||||
### T-02 单元格光标露出边缘即把视图滚到中央,点击与拖选时视图跳动
|
||||
- 严重度:高 | 置信度:已确认
|
||||
- 位置:`crates/ely-lok/src/view/scroll.rs:45-64`(`show`),调用处 `crates/ely-lok/src/view.rs:372-387`
|
||||
- 现象:点一个只露出一半的格(右缘或底缘),整张表横向跳半屏、纵向跳三分之二屏;拖选拖到边缘时内容在指针下滑走,选区落错位置。
|
||||
- 原因:`show()` 对任何不完全可见的矩形都重置滚动:`scroll.0 = x - fx - vw / 2`、`scroll.1 = y - fy - vh / 3`,不是最小滚动量;而 `apply_batch` 对每次光标或单元格光标变化都调它。
|
||||
- 复现:把窗口缩到只露出 A 到 H 列,点半露的 H 列;或从 A1 向右下拖选到视口边缘。
|
||||
- 修法:`show()` 只滚到刚好露出目标(`scroll = x + w - vw` 或 `x`),与一般表格软件一致;拖选期间不因选区变化滚动。
|
||||
|
||||
### T-03 视口比工作表宽时网格先居中再左移一次
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`crates/ely-lok/src/view/render.rs:243-253`(`spare` 居中)、`crates/ely-lok/src/view/scroll.rs:99-108`(`extent`)
|
||||
- 现象:宽屏上打开工作簿,表格先出现在视口中央、两侧是空底色,内核按 `setClientVisibleArea` 扩大平铺区并报 `DOCUMENT_SIZE_CHANGED` 后整张表再跳到左边;窗口每次拉宽都重现一次。
|
||||
- 原因:`prepare` 把窄文档居中(`slack(viewport.0, extent.0)`),这对 Writer 页面合理,对 Calc 的网格不合理:Calc 的文档尺寸只是已用区加余量(上游 `GetTiledRenderingArea`),随可见区增长。
|
||||
- 修法:Calc 文档不居中(`spare = (0, 0)`),或以 `reach` 先把 `extent` 撑到至少一个视口宽。
|
||||
|
||||
### T-04 从不调用 `setClientZoom`,内核命中与几何按默认缩放算
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`crates/ely-lok/src/ffi/document.rs`(无对应调用)、`crates/ely-lok/src/view/zoom.rs:44-62`(缩放只改本地 `geom`)
|
||||
- 现象:Ely 缩放到 125% 或 200% 后,内核的 PPTX 仍是 100% 的 1/15,`getDocumentSize`、`GetPosFromPixel` 的像素取整都按 100% 网格算;与 T-01 叠加,缩放越大错位越明显(图块按 Ely 的比例画,命中按内核的比例算,两套像素网格的取整不同)。
|
||||
- 原因:上游 `ScModelObj::setClientZoom`(`docuno.cxx:1100`)是客户端告知像素/twips 比例的唯一入口,Collabora Online 在载入与每次缩放时都调;Ely 的 `Document` 没有包装它。
|
||||
- 修法:载入与每次 `zoom_at` 后发 `setClientZoom(tile_pixels, tile_pixels, TILE_TWIPS, TILE_TWIPS)`。
|
||||
|
||||
### T-05 文档尺寸按内核像素取整,表尾被裁
|
||||
- 严重度:低 | 置信度:已确认(上游源码核实)
|
||||
- 位置:`crates/ely-lok/src/view.rs:360-368`(`doc_size` 直接取 `getDocumentSize`)
|
||||
- 现象:`getDocumentSize`(上游 `docuno.cxx:740-780`)返回“像素累加后再除 PPTX”的 twips(85 像素×15=1275 twips 一列,而不是 1280),平铺区越大短得越多;滚动到最右最下时最后一列、最后几行露不全。
|
||||
- 修法:以几何(`SheetGeometryData`)算 Calc 的文档范围,不用 `getDocumentSize`。
|
||||
|
||||
### T-06 滚轮向下被内核平铺区卡住
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`crates/ely-lok/src/view/scroll.rs:20-28`(`clamp_scroll` 以 `extent` 为界)、`render.rs:264-268`(可见区在绘制时才发)
|
||||
- 现象:快速向下滚动空白区时每次只能滚到当前文档底,内核收到可见区后再扩 25 行,下一次滚动才能继续;连续滚动一顿一顿。
|
||||
- 修法:Calc 下 `extent` 至少放到 `scroll + 2 个视口`,或滚动时提前发可见区。
|
||||
|
||||
### T-07 表头点选、行高列宽拖动是“按编号的命令”,而每次点击与拖选都推进修订,紧接着的表头操作被当作过期拒绝
|
||||
- 严重度:中 | 置信度:已确认(时序窗口为一次后台读取)
|
||||
- 位置:`crates/ely-lok/src/commands/sheet.rs:154-164`(`select.column`/`select.row` 的 `Col`/`Row` 在 `NUMBERED` 内,`commands.rs:63-72`)、`crates/ely-office/src/lok.rs:187-202`(`run_in` 对编号命令取 `read_at` 发 EXPECT)、`crates/ely-lok/src/worker/revision.rs:51-66`(鼠标按下、松开、按键拖动都 `bump`)、`apps/ely-table/src/grid.rs:204-211`、`:306-327`、`apps/ely-table/src/book.rs:199-246`(`reread` 之后 `read_at` 才更新)
|
||||
- 现象:点一格(或拖选、键入)后马上点列头或行头、或拖完列边:命令带着上一次读取的修订发 EXPECT,内核修订已被鼠标事件推进,回 `stale:`,弹“文档在此间已改动,请重试”,整列没有选中。`read_at` 还没来得及由 `reread` 更新时(刚打开工作簿、worker 忙于画图块时)必现。
|
||||
- 原因:列号、行号不会被光标移动改变,EXPECT 的保护在这里是多余的;而所有鼠标按下与松开(含拖选的每一步移动)都计入修订。
|
||||
- 修法:`select.column`、`select.row`、`columns.width`、`rows.height` 不作编号命令(或 `run_in` 对它们不带 `at`),只保留对工作表、对象、参考线等真会被改动重排的编号做 EXPECT。
|
||||
|
||||
### T-08 自动筛选的列菜单对光标所在的表而不是按钮所在的表筛选、排序
|
||||
- 严重度:中 | 置信度:可能(内核侧按补丁追到;取决于单元格光标在哪)
|
||||
- 位置:`apps/ely-table/src/data.rs:412-437`(`column_asked` 发 `data.filter`/`data.sort`)、`:379-409`(`ask_column`);内核 `patches/libreoffice/0017-lok-calc-filter-validation-columns.patch` 的 `lcl_Query`(`rView.GetDBData()`)与 `SID_SORT` 路径(`GetDBData()`)
|
||||
- 现象:单元格光标在筛选区之外(刚在别处键入)时点列头的筛选箭头、勾选值或“升序”:要么弹内核错误(`Check::Undoable` 失败),要么筛选、排序了光标所在的另一块数据。菜单本身能开,因为 `.uno:AutoFilterValues?col=&row=` 用的是按钮格。
|
||||
- 原因:内核自己的菜单对按钮格 `GetDBAtCursor(nCol, nRow)` 操作,补丁后的 `LaunchAutoFilterMenu` 不移动光标;Ely 的答复却经 `GetDBData()`(当前光标或选区)解析。
|
||||
- 修法:`column_asked` 先 `cell.goto` 到 `address(column, row)`(`Dialog::Column` 记下 `row`),或让补丁按按钮格取数据区。
|
||||
|
||||
### T-09 导入文本文件时整文件的 UTF-8 校验在界面线程跑
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`apps/ely-table/src/text.rs:241-245`(`in_window` 里 `Pending::new`)、`:58-63`、`:108-116`(`guess_encoding` 对全文 `from_utf8`)
|
||||
- 现象:打开几百 MB 的 CSV,导入对话框出现前整窗冻结几百毫秒到数秒(树莓派更久)。
|
||||
- 原因:读字节在后台,`Pending::new`(编码与分隔符猜测)却在主线程闭包里。
|
||||
- 修法:猜测放进后台任务,`in_window` 只接现成的 `Pending`。
|
||||
|
||||
### T-10 CSV 导入对话框开着时打开任何别的对话框,待导入的文件静默消失
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`apps/ely-table/src/text.rs:254-262`(`next_import` 把 `Pending` 移进 `Dialog::Import`);无条件 `self.dialog = Some(..)` 的:`apps/ely-table/src/output.rs:218`、`rules.rs:146`、`:278`、`:341`、`analysis.rs:123`、`:149`、`page.rs:146`、`protect.rs:61`、`formulas.rs:119`、`:209`、`data.rs:231`、`:317`、`:397`
|
||||
- 现象:访达双击 .csv 出现导入对话框,按 Cmd+E 再取消:`Pending`(已检查字节的唯一副本)被丢,文件再也不打开,无提示。
|
||||
- 修法:替换 `Dialog::Import` 时把它放回 `imports` 队头,或有对话框开着时拒绝再开。
|
||||
|
||||
### T-11 对话框比其标签活得久:导出、定义名称、重命名不可见却“开着”,挡住导入;名称表的“转到”激活已关闭的标签
|
||||
- 严重度:低 | 置信度:可能
|
||||
- 位置:`apps/ely-table/src/dialog.rs:644-648`(导出 `sheet_in(*tab)?`)、`:563-571`、`:542-545`;`apps/ely-table/src/text.rs:255-257`(`next_import` 要 `dialog.is_none()`);`apps/ely-table/src/formulas.rs:255-258`(`core.active = tab` 不核对)
|
||||
- 现象:Cmd+E 后 Cmd+W 关掉该标签:对话框不画了但 `self.dialog` 仍为 `Some`,无法关闭,之后打开 .csv 没反应,直到再开关一次别的对话框;名称表的“转到”把 `core.active` 指到已关标签,正文显示“没有打开的文档”。ElyDoc 的 D-01 是同一类问题(那里直接崩溃)。
|
||||
- 修法:`Book` 实现 `Host::tab_closed` 清掉指向该标签的 `dialog` 与 `choices`;“转到”先核对 `tab(tab).is_some()`。
|
||||
|
||||
### T-12 打印区域是整列或整行时页面设置无法应用
|
||||
- 严重度:中 | 置信度:可能(内核以 `$A:$D` 格式化整列区域)
|
||||
- 位置:`apps/ely-table/src/page.rs:75-83`(`cell_ok` 要求字母加数字)、`:86-97`(`areas`)、`:169-176`(`page_asked` 以 `table.page.bad_reference` 拒绝)
|
||||
- 现象:Excel 文件的打印区域 `=Sheet1!$A:$D` 预填进对话框,只改方向或缩放再按应用也被拒“Write ranges like A1:D20…”;不重写区域就用不了页面设置。
|
||||
- 修法:`areas()` 接受 `COL:COL` 与 `ROW:ROW`(内核 `ParseAny` 本就接受),或内核给的原值免校验。
|
||||
|
||||
### T-13 打印失败报成“导出失败”(非 macOS 必现)
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-table/src/output.rs:147-150`(`print` 把 `print_later` 的失败交给 `output_failed`,文案 `table.export.failed`)、`crates/ely-platform/src/print.rs:87-89`
|
||||
- 修法:单独的 `table.print.failed` 文案;平台未实现时禁用或说明。
|
||||
|
||||
### T-14 导入扫描把整个文件的每个字段都建成字符串
|
||||
- 严重度:低 | 置信度:已确认(影响随文件大小)
|
||||
- 位置:`apps/ely-table/src/text.rs:78-106`(`scan`)、`:158-173`(`split` 返回全部行的 `Vec<Vec<String>>`)
|
||||
- 现象:只需要 8 行预览与每列的类型标志,却为 200 MB 的 CSV 分配约 1–2 GB 堆,树莓派上可能耗尽内存。
|
||||
- 修法:`split` 改为迭代器,只留前 `SHOWN` 行与列标志。
|
||||
|
||||
### T-15 非 UTF-8 文本的默认编码跟系统区域而不是界面语言
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-table/src/text.rs:236`(`i18n::resolve(&locale::system())`)
|
||||
- 现象:英文系统上把应用语言设成简体中文,打开 GB18030 的 CSV 默认仍是 Windows-1252(反之亦然),与 agents.md 的“中文界面 GB18030、英文界面 Windows-1252”不符。
|
||||
- 修法:用 `ely_ui::i18n` 解析后的当前语言。
|
||||
|
||||
## S ElyShow(Impress)
|
||||
|
||||
### S-01 切换幻灯片后主画布不重画
|
||||
- 严重度:致命 | 置信度:已确认(上游源码核实)
|
||||
- 位置:`crates/ely-lok/src/events.rs:274`(`SET_PART` 只加 `cell.sheets`)、`crates/ely-lok/src/view/input.rs:231-235`(`show_part` 只排队 `Input::Part`)、`crates/ely-lok/src/view/lane.rs:95-124`(`Input` 落地不作废图块)、`apps/ely-show/src/slides.rs:244-247`、`apps/ely-show/src/deck.rs:308-314`(只在母版视图切换时 `invalidate`)
|
||||
- 现象:在左侧缩略图条点另一张幻灯片(或经批注栏、演示检查跳转),缩略图高亮变了,主画布仍是上一张;拖一下窗口边才变。
|
||||
- 原因:上游 `DrawViewShell::SwitchPage`(`sd/source/ui/view/drviews1.cxx:1003-1012`)只发 `LOK_CALLBACK_SET_PART`;切换期间 `sd::Window::LogicInvalidate`(`sd/source/ui/view/sdwindow.cxx:1016-1020`)因 `IsInSwitchPage()` 直接返回,不发 INVALIDATE_TILES。Collabora Online 的图块请求自带 `part`,切页即换缓存;Ely 的 `paint_tile` 不带 part,图块缓存按坐标键保留,所以旧页一直显示。窗口尺寸变化时 `fit_zoom` 改了缩放、`retire_tiles` 才重画。Calc 切表经 `SetTabNo` 走 `PaintGrid` 会发作废,所以 ElyTable 没有这个症状。
|
||||
- 复现:新建演示,“幻灯片›新建”,点第一张缩略图。
|
||||
- 修法:`LokView` 在 `Input::Part` 落地(或收到 `SET_PART`)时 `retire_tiles()` 并清 `failed`,或把 part 编进图块键。
|
||||
|
||||
### S-02 `deck.rs` 超过 500 行
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-show/src/deck.rs`(506 行)
|
||||
- 原因:违反规则 5(文件不超过 500 行);工作区内只有这一个文件超限。
|
||||
- 修法:把 `reread` 的读取闭包或 `Extra` 的图片管理拆到独立模块。
|
||||
|
||||
### S-03 ElyShow 的缩放一两帧后弹回“适合幻灯片”,缩放功能不可用
|
||||
- 严重度:致命 | 置信度:已确认
|
||||
- 位置:`apps/ely-show/src/deck.rs:139-150`(`Host::observed` 每次都 `view.fit_page(cx)`)、`crates/ely-office/src/open.rs:253`(每个 `Reported` 都调 `observed`)、`crates/ely-lok/src/view/lane.rs:121` 与 `view.rs:413`(任何输入落地、任何内核事件批次都发 `Reported`)、`crates/ely-lok/src/view.rs:262-268`(`fit_page` 置 `fit = true`)、`crates/ely-lok/src/view/zoom.rs:51`(`zoom_at` 置 `fit = false`)、`render.rs:256`、`:286-289`
|
||||
- 现象:Cmd+=、Cmd+-、Cmd+滚轮、捏合、状态栏缩放控件、“视图”菜单:幻灯片放大一帧,随即跳回适合页面,状态栏百分比也跳回。agents.md 写的“手动缩放即停止适合”不成立。
|
||||
- 原因:缩放后重绘改变了可见区,排队 `Input::Visible`,落地即 `Reported` → `observed` → 重新 `fit_page`;没有可见区变化时下一批内核事件也一样。
|
||||
- 复现:打开任一演示,Cmd+=。
|
||||
- 修法:只在标签首次观察时适合一次(`Extra` 里记标志),`observed` 不再调 `fit_page`;Cmd+0 已由 `Zooming::zoom(Reset)` 处理。
|
||||
|
||||
### S-04 选屏对话框被别的对话框顶掉后,`show_starting` 卡住、放映副本泄漏
|
||||
- 严重度:中 | 置信度:可能(多屏才会到选屏对话框)
|
||||
- 位置:`apps/ely-show/src/slideshow/start.rs:180-183`(置 `show_starting`、开对话框)、`:289-310`(只有取消与开始两条路清理)、无条件覆盖 `self.dialog` 的:`apps/ely-show/src/new.rs:298`、`apps/ely-show/src/table.rs:24-28`、`apps/ely-show/src/remarks.rs:80`、`apps/ely-show/src/chart.rs:453-454`;守卫 `start.rs:123-125` 只记日志
|
||||
- 现象:两块屏,F5 出选屏对话框时按 Cmd+N(或插入›表格、批注、图表)顶掉它;此后 F5、Shift+F5、放映菜单都没反应也无提示,`show-N.odp` 副本留在内核与磁盘里直到下次启动。同样的覆盖也会丢掉打了一半的批注或图表数据。
|
||||
- 修法:任何 `ask_*` 替换对话框时(或 `watch_dialogs` 发现种类离开 `Dialog::Show`)跑与取消相同的清理;或有对话框开着时拒绝再开。
|
||||
|
||||
### S-05 一条批注的时间恰为午夜或只有日期,整个批注栏不显示
|
||||
- 严重度:中 | 置信度:可能(上游 `utl::toISO8601` 时分秒全零时不写时间部分;jiff 的 `civil::DateTime` 要求有时间)
|
||||
- 位置:`apps/ely-show/src/remarks.rs:28-36`(逐条 `parse::<jiff::civil::DateTime>()?`)、`:168-176`(一条失败整栏换成错误文字);内核 `sd/source/ui/unoidl/unomodel.cxx` 以 `utl::toISO8601` 写 `dateTime`
|
||||
- 现象:含 00:00:00 批注(LibreOffice “去除个人信息”保存的文件、只写 `dc:date` 的工具)的演示,右栏只显示“a bad comment time 2025-03-01: …”,画布上的标记还在但栏内无法编辑删除。ElyDoc 的 D-04 是同一类问题。
|
||||
- 修法:无时间部分时按 `civil::Date` 解析,单条失败只影响该条。
|
||||
|
||||
### S-06 导出或打印的“从–到”填 0 时导出全部幻灯片
|
||||
- 严重度:低 | 置信度:可能
|
||||
- 位置:`apps/ely-show/src/output/dialog.rs:82`(`value.round() as u32`,无下限)、`apps/ely-show/src/output.rs:53-59`(`pages()` 只排序)、`crates/ely-lok/src/worker.rs:403-430`(`from == 0` 表示全部)、`apps/ely-show/src/output.rs:117-125`(`targets` 的 `number as usize - 1`,0 时溢出:发行构建回绕、调试构建 panic)
|
||||
- 修法:`set` 里夹到 `1..=last`,`pages()` 再校验。
|
||||
|
||||
### S-07 母版视图下演示检查的行按幻灯片序号跳转
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-show/src/check.rs:184-194`(`show_part(part)` 用幻灯片序号)、`:112`(`check_show` 无母版视图守卫,`slides.rs:93-99` 有)
|
||||
- 现象:显示母版时点“第 3 张”,内核对母版列表 `setPart(2)`,显示的是第 3 个母版或空白,对话框关闭。
|
||||
- 修法:先退出母版视图,或母版视图下禁用行。
|
||||
|
||||
### S-08 刚编辑过幻灯片就进备注栏键入,备注可能丢
|
||||
- 严重度:低 | 置信度:可能
|
||||
- 位置:`apps/ely-show/src/notes.rs`(`notes.at` 在聚焦期间冻结在上次读完的修订;`run_on(tab, &at, "notes.set", …)` 是编号命令,EXPECT 拒绝过期修订;强制重读后内核旧备注覆盖输入框,而 `shown` 已置为键入文字)
|
||||
- 现象:编辑幻灯片后在后台重读完成前点进备注框键入、失焦:提示“文档在此间已改动,请重试”,键入的备注被旧备注换掉;再失焦不再发送(`text == shown`)。
|
||||
- 修法:聚焦时取视图的 `read_at()` 更新 `notes.at`;发送失败时让 `shown` 保持内核文字以便下次失焦重发。
|
||||
|
||||
### S-09 改尺寸失败后 `size_asked` 一直留着
|
||||
- 严重度:低 | 置信度:可能
|
||||
- 位置:`apps/ely-show/src/design.rs:62-64`(命令前置 `size_asked`)、`apps/ely-show/src/deck.rs:343-347`(只在读回 0.5 mm 内才清);用它的 `deck.rs:71-74`、`apps/ely-show/src/insert.rs:144`、`apps/ely-show/src/media.rs:116`、`apps/ely-show/src/guides.rs:200`、`:318`
|
||||
- 现象:`slide.size` 失败(worker 错误、无撤销步)后宽高字段显示内核没接受的尺寸,新参考线、媒体按它定位裁剪。`transition_asked` 有落地判定,尺寸没有。
|
||||
- 修法:命令报错时清掉,或像切换那样按序号落定。
|
||||
|
||||
### S-10 开始放映的两处拒绝无提示(规则 2)
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-show/src/slideshow/start.rs:194-198`(标签或屏幕已不在 → 只记日志并 `discard`;选屏对话框开着时关掉标签再按开始可达)、`:123-125`(正在启动 → 只记日志)
|
||||
- 修法:两处都弹提示。
|
||||
|
||||
## P ElyPDF(MuPDF)
|
||||
|
||||
### P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃
|
||||
- 严重度:致命 | 置信度:已确认(文案层缺键即失败)
|
||||
- 位置:`apps/ely-pdf/src/view.rs:123-126`(`restricted` 拼 `pdf.may.{needed}`)、`:228`(`fail` 对一切 `PermissionDenied` 都走 `restricted(&error.to_string())`)、`apps/ely-pdf/src/protect.rs:315`、`apps/ely-pdf/src/ask.rs:507-509`
|
||||
- 现象:导出、提取、拆分、签名、保护、附件另存写到不可写文件夹,或任何 OS 权限错误,不显示错误而是崩溃。
|
||||
- 原因:`restricted` 假定错误文字是 worker 的 `restricted:<名>`(`client.rs:297-302`),但 `ely_files::export`→`save_atomic` 保留 OS 的错误类别,`holder()`→`same_file`→`canonicalize` 同样,`annotate()` 里的 `user::name()` 读设置也可能是 `PermissionDenied`;得到的键 `pdf.may.Permission denied (os error 13)` 不在任何表里。
|
||||
- 复现:文件›导出… 到只读文件夹。
|
||||
- 修法:只在文字是 `may::NAMES` 之一时走 `restricted`,否则走 `pdf.failed`/`office.error.save`;或给 worker 的拒绝单独的错误类型。
|
||||
|
||||
### P-02 每次编辑落地清掉全部图块与缩略图,页面空白、刚画的墨迹消失
|
||||
- 严重度:高 | 置信度:已确认
|
||||
- 位置:`apps/ely-pdf/src/edits.rs:102-111`(`refresh` 清 `tiles`、`thumbs`)、`apps/ely-pdf/src/pages.rs:272-279`(每帧只请求一块)
|
||||
- 现象:任何编辑(一笔手绘、勾一个复选框、填一个字段)后可见页面变成空白纸,再以每帧一块 512 像素的速度填回;期间刚加的批注看不见;缩略图也全部重画。图多的页面上每次编辑要几秒,感觉“画了没反应”。
|
||||
- 原因:LOK 画布作废时旧图继续显示到新图到达(agents.md 已定),PDF 视图没有照做:`retire_tiles()` 直接清空 `self.tiles`,`page_view` 画 `bg(paper)` 加空的 `tiles`。
|
||||
- 修法:旧图块保留到替换到达时才退役(`paint()` 的 `tiles.insert` 处已这样做),缩略图同。
|
||||
|
||||
### P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图
|
||||
- 严重度:高 | 置信度:已确认
|
||||
- 位置:`apps/ely-pdf/src/content.rs:172-187`(命中顺序)、`:206`(1 点阈值)
|
||||
- 现象:整页背景图、扫描件、包裹整页的表单 XObject 上,点任何文字选中的都是图片(面板显示替换/删除);按下与松开相差 1 个页面点(适合宽度下约 1 像素)即成“移动对象”编辑,把背景图挪走。图下的文字永远编辑不到。
|
||||
- 原因:`layer.drawn.iter().rposition(|d| inside(..))` 先于文字块查找,不看目标大小;`release()` 的阈值是页面点不是窗口像素。
|
||||
- 修法:对象整页或包住文字块时优先文字块;移动阈值按窗口像素(如 3 像素)。
|
||||
|
||||
### P-04 `TEXT_BLOCKS` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应
|
||||
- 严重度:高 | 置信度:可能(代价推断,未计时)
|
||||
- 位置:`crates/ely-pdf-engine/shim/ely_text.c:227-245`、`:156-179`、`:133-145`(每块一次 `pdf_filter_page_contents`);`apps/ely-pdf/src/content.rs:129-156`(逐页读)、`:169-171`(层未到即静默返回)
|
||||
- 现象:密集页面上开编辑工具很久不出框,点击被忽略,图块与缩略图也停住(worker 单线程被占满)。
|
||||
- 修法:每页一遍干跑记下全部字形原点与字符,再逐块比对;或只对点中的块判定可编辑。
|
||||
|
||||
### P-05 连续快画或快点被拒:“文档在此间已改动,未执行”
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`apps/ely-pdf/src/annotate.rs:189-203`(`settled_edit`)、`apps/ely-pdf/src/content.rs:209-214`、`apps/ely-pdf/src/writes.rs:176-190`、`apps/ely-pdf/src/edits.rs:56-93`
|
||||
- 现象:上一笔还在落地(`annotate`+重读页数、页框、书签)时结束的下一笔、下一个矩形、下一个复选框点击被丢弃,并把原因说成“文档改动”;多笔手绘丢笔。
|
||||
- 原因:`settled()` 是队列为空且不在编辑中;按坐标的批注不会被在途编辑作废(按编号的才会,worker 已以 `stale:` 拒绝),却被一律拒绝而不是排队。
|
||||
- 修法:按坐标的批注进编辑队列;拒绝只留给按编号的编辑,并改正文案。
|
||||
|
||||
### P-06 默认字体“原字体”在 Type 1/Type 3 字体的文件上失败,只给引擎原话
|
||||
- 严重度:中 | 置信度:可能(`pdf_font_writing_supported` 只认 TrueType/CFF/OpenType)
|
||||
- 位置:`apps/ely-pdf/src/editing.rs:77`、`crates/ely-pdf-engine/shim/ely_write.c:61-67`、`apps/ely-pdf/src/writes.rs:115`、`:138-142`
|
||||
- 现象:LaTeX 或旧 PDF 上“应用”报“previewing text failed: the document's font cannot write new text”,不提示换字体(缺字形的情形倒有 `pdf.text.missing` 提示)。
|
||||
- 修法:像 `Wrote::Missing` 一样在面板里提示换字体,或读块时预判可写性、默认 Helvetica。
|
||||
|
||||
### P-07 含连字的文字块永远不可编辑
|
||||
- 严重度:中 | 置信度:可能
|
||||
- 位置:`crates/ely-pdf-engine/shim/ely_text.c:91-109`(`block_targets` 按 stext 字符计数)、`:112-130`(`take_glyph` 按字形计数)、`:167`
|
||||
- 现象:含 fi、fl、ffi 连字的段落不出编辑框、点击无效;真放过去 `ely_remove_block` 也会报“N of M glyphs were found”。
|
||||
- 修法:按同一原点的字符分组计字形,或每字形按 `ucslen` 多计命中。
|
||||
|
||||
### P-08 页面读取失败后每帧重试、每帧弹提示
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`apps/ely-pdf/src/annotate.rs:76-110`(`ask_notes`)、`:113-134`、`apps/ely-pdf/src/content.rs:129-156`(`ask_layers`)、`apps/ely-pdf/src/attach.rs:184-205`、`apps/ely-pdf/src/sign.rs:49-75`
|
||||
- 现象:某页的批注、层、附件、名称或签名读一次失败后,`fail` 弹提示、`notify` 重绘、重绘再问、再失败,没完没了;worker 死掉后每个可见页每帧一条错误提示,直到按“重新打开”。
|
||||
- 原因:失败不记页(图块有 `failed`/`thumbs_failed`,这些没有)。
|
||||
- 修法:各类读取加按页的失败集合(`refresh()` 清),引擎已结束时不再问。
|
||||
|
||||
### P-09 识别文字的写入被拒后仍弹“成功”
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-pdf/src/ocr.rs:510-513`(`toast(.., Success)` 在 `on_same` 之后无条件执行)
|
||||
- 修法:成功提示移进 `on_same` 的闭包。
|
||||
|
||||
### P-10 预览回复被丢弃时“应用”静默无事
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-pdf/src/writes.rs:72-86`、`apps/ely-pdf/src/view.rs:241-247`(`read` 丢弃编辑前的回复而不调 `then`)
|
||||
- 修法:改用带版本核对的 `off`,不符即提示 `pdf.object.changed`。
|
||||
|
||||
### P-11 撤销或重做在 MuPDF 侧失败后 worker 的步数不再同步
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`crates/ely-pdf-engine/src/bin/ely-pdf-worker/main.rs:62-86`、`:161-165`
|
||||
- 修法:失败时 `doc.sync(&self.mupdf, None)`。
|
||||
|
||||
### P-12 `unhook` 中途抛出时泄漏保留的对象
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`crates/ely-pdf-engine/shim/ely_insert.c:17-49`、`:352-356`(`n` 只在返回时发布,`fz_always` 以 0 释放)
|
||||
- 修法:以 `int *n` 传入并逐个递增。
|
||||
|
||||
### P-13 优化导出的“之前”大小取磁盘文件而非当前文档
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-pdf/src/output.rs:217-224`
|
||||
- 修法:量 worker 的快照,或标签有改动时不写“之前”。
|
||||
|
||||
### P-14 文字块含控制字符时 JSON 回复不可解析
|
||||
- 严重度:低 | 置信度:可能
|
||||
- 位置:`crates/ely-pdf-engine/shim/ely_text.c:240`(`%q` 不是 JSON 转义;别处用的是 `ely_json_string`)
|
||||
- 现象:ToUnicode 坏掉的文件文字里有 U+0001–U+001F,`text_blocks` 解析失败,编辑工具永远读不到该页,并落入 P-08 的循环。
|
||||
- 修法:`text`、`font` 用 `ely_json_string` 写。
|
||||
|
||||
## D ElyDoc(Writer)
|
||||
|
||||
### D-01 导出或打印对话框开着时关闭其标签即崩溃
|
||||
- 严重度:高 | 置信度:已确认
|
||||
- 位置:`apps/ely-doc/src/actions.rs:84-86`(`self.tab(*tab).expect("a modal keeps its tab")`),对话框由 `apps/ely-doc/src/output.rs:38-48` 打开;关闭路径 `crates/ely-office/src/close.rs:38-42`、`:142-154`、`:191-221`
|
||||
- 现象:开着“导出…”或“打印…”对话框按 Cmd+W、点窗口关闭钮或 Cmd+Q,标签被移除后下一帧渲染 `expect` 失败,进程崩溃;Cmd+Q 时先于其余脏标签的保存询问崩溃,这些标签只剩 10 秒一次的恢复点。
|
||||
- 原因:`Dialog::Output` 记着标签号,`dialog_element` 每帧无条件解包;没有任何地方在标签关闭时清 `self.dialog`(`Docs` 未实现 `Host::tab_closed`),`release_tab` 还把 `output()` 排队的 `when_settled` 续动作丢掉。其他对话框(`Ask`、`Link`、`Reference`、外壳的 `Unsaved`)都是防御性写法。
|
||||
- 复现:打开任一文档 → 文件›导出… → Cmd+W。
|
||||
- 修法:`dialog_element` 在 `tab()` 为 `None` 时清掉对话框并返回 `None`;`Docs` 实现 `tab_closed` 丢掉指向该标签的 `Dialog::Output`。
|
||||
|
||||
### D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录
|
||||
- 严重度:高 | 置信度:已确认
|
||||
- 位置:`apps/ely-doc/src/merge.rs:88-94`(`source()` 返回 `core.active`)、`:152-164`(预览)、`:178-195`(生成);`apps/ely-doc/src/merge/run.rs:351-371`(预览经 `load_into` 开新标签)、`crates/ely-office/src/open.rs:231`(新标签成为当前)
|
||||
- 现象:选 CSV、插字段、点“预览”(填好的副本开成新标签并成为当前标签,合并面板仍在右侧)再点“生成”:模板成了预览副本,字段早已替换成第 N 条记录,N 个输出文件内容相同,文件名还带预览标签名;再点预览是预览的预览。无任何提示。
|
||||
- 原因:`Merge` 不记来源标签,每次点击时取 `core.active`;单测 `a_merge_keeps_its_source_when_the_tab_changes` 直接传 `source`,没覆盖界面路径。
|
||||
- 复现:任一含 `«Name»` 的文档 → 邮件合并 → 三行 CSV → 预览 → 生成。
|
||||
- 修法:选 CSV 或插字段时记下来源标签号(或在 `Extra` 标记预览标签),面板显示来源名,预览与生成都用它,当前标签是预览副本时拒绝。
|
||||
|
||||
### D-03 界面线程同步持 worker 锁读内核(样式、字体、书签、公式、分节名)
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`apps/ely-doc/src/insert.rs:318-324`(`insert_section`)、`apps/ely-doc/src/refs.rs:81-86`(`read`,书签、交叉引用、公式对话框用)、`apps/ely-doc/src/props.rs:116-122`(`read_fonts`)、`:133-137`(`read_styles`,每次切标签与 `Reported` 都跑)
|
||||
- 现象:保存大文档、邮件合并几百条记录(每条载入、替换、另存、关闭都持锁)期间,切标签或打开“插入›书签…”整窗冻结到锁空出来为止。
|
||||
- 原因:违反 2026-10-05 决策“界面线程不碰 worker”,这四处直接 `worker.lock()` 而不走 `read_in`/`read_off`。
|
||||
- 修法:改为后台读取,回来时核对仍是同一标签与视图。
|
||||
|
||||
### D-04 一条批注的时间读不出,全部批注与修订都不显示
|
||||
- 严重度:中 | 置信度:可能(内核对无日期批注写 `0-00-00`,`utl::toISO8601` 的空日期即如此)
|
||||
- 位置:`apps/ely-doc/src/review.rs:164-167`、`:373-393`(`threads` 遇第一个解析失败即整体 `Err`)、`:179-181`
|
||||
- 现象:打开含无日期批注的 DOCX(`w:comment` 无 `w:date`),批注与修订两栏都空,并弹引擎错误提示;每次审阅事件重读再失败再弹。
|
||||
- 原因:单条解析失败被提升为整表失败,`and_then` 连已解析的 `changes` 一起丢掉。
|
||||
- 修法:逐条解析时间,失败的那条用占位时间;`changes` 不因批注失败而丢。
|
||||
|
||||
### D-05 邮件合并某条记录失败时内核文档泄漏
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-doc/src/merge/run.rs:464-470`
|
||||
- 原因:`fill`/`save_as` 以 `?` 提前返回,`close_doc` 不执行,`Doc` 无 `Drop`;每次重试多漏一份,worker 的 `N documents open` 计数漂移。
|
||||
- 修法:先取结果再一律 `close_doc`,最后传出首个错误。
|
||||
|
||||
### D-06 导航栏大纲点击按当前列表取下标,后台重读后可能越界
|
||||
- 严重度:低 | 置信度:可能
|
||||
- 位置:`apps/ely-doc/src/navigate.rs:277-283`(`navigator.outline[at]`)
|
||||
- 修法:用 `get(at)`,或在渲染时把目标矩形放进行的闭包。
|
||||
|
||||
### D-07 导航栏隐藏时仍随每次光标移动重读大纲与书签
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-doc/src/chrome.rs:236-245`(`left_view` 不看 `navigating` 就 `navigator.show(tab)`)、`apps/ely-doc/src/navigate.rs:79`、`:91-123`
|
||||
- 现象:关掉导航栏后每次按键仍排两次内核读取,与键入争 worker 锁(规则 9:空闲不做事)。
|
||||
- 修法:隐藏时 `show(None)`,显示时重读一次。
|
||||
|
||||
### D-08 Linux、Windows 上“打印…”可用,导出 PDF 后才报“此平台未构建”
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-doc/src/output.rs:28-30`、`:80-88`;`crates/ely-platform/src/print.rs:87-89`
|
||||
- 现象:菜单与页码范围对话框都正常,最后一步弹出带临时路径的错误提示。各文档应用同(见 X-13)。
|
||||
- 修法:平台未实现时禁用菜单项或在对话框里写明;错误文字去掉路径。
|
||||
|
||||
## M ElyMark(CEF)
|
||||
|
||||
### M-01 键入期间外部以相同内容重写文件,丢掉上次停顿之后键入的全部文字
|
||||
- 严重度:高 | 置信度:已确认
|
||||
- 位置:`apps/ely-mark/src/outside.rs:223-224`(`(true, _) | (false, false) => take_disk`)、`:243-268`(`take_disk`)、`apps/ely-mark/src/view.rs:184-196`(`refresh` 把 `changed`、`reported` 归零)、`apps/ely-mark/web/src/main.js:52-59`(全文停手 300 毫秒才送)、`:179-187`(`refresh` 清掉待送)
|
||||
- 现象:连续键入时外部进程以相同字节重写文件(`touch`、同内容的 `git checkout`/`stash pop`、云同步重盖、格式化器输出相同),编辑器以旧文字重建,上次停顿以来键入的字全部消失,标签还显示已保存;无提示。
|
||||
- 原因:页面只在停手 300 毫秒后送全文,键入中 Rust 侧 `doc.current` 是停顿前的文字;`disk.text == current` 的分支不看 `dirty`(它含 `edit_pending`),直接 `take_disk`。与 agents.md “有未保存内容(含页面还没送回的编辑)即不动磁盘与编辑器”相悖。
|
||||
- 复现:打开 `a.md` 连续键入几秒,期间在终端 `touch a.md`。
|
||||
- 修法:`disk.text == current` 而视图未落定时不 `take_disk`:等 `when_settled` 后重判,或当作 `Outside::Changed`;落定且相同时只换指纹不动编辑器。
|
||||
|
||||
### M-02 `refresh` 归零版本计数却不换代,在途的一键让标签一直“未落定”
|
||||
- 严重度:中 | 置信度:可能
|
||||
- 位置:`apps/ely-mark/src/view.rs:193`、`:264-272`(`Loaded` 路径有换代)、`apps/ely-mark/web/src/main.js:179-187`、`crates/ely-web-content/src/engine.rs:127-143`;受影响 `crates/ely-office/src/close.rs:96-99`、`save.rs:325-326`、`apps/ely-mark/src/compare.rs:381-382`
|
||||
- 现象:干净标签被外部改动刷新后,Cmd+S、Cmd+W、Cmd+Q、比较与自动保存都静静等 `when_settled`,直到再键入一个字才一起执行。
|
||||
- 原因:刷新前排队的一键在渲染进程里先产生 `changed = N+1`,再执行 `refresh` 丢掉待送全文;Rust 已归零又收到 `changed`,没有 `text` 跟上,`settled()` 为假;`refresh` 不像 `Loaded` 那样换代,旧回报没被丢。
|
||||
- 修法:`refresh` 自己换一代(`self.load += 1` 传给 `ely.refresh(text, load)`)。
|
||||
|
||||
### M-03 干净标签接过磁盘文字后关闭仍问“是否保存”
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`apps/ely-mark/src/outside.rs:243-268`(`take_disk` 不更新 `tab.mark`)、`apps/ely-mark/src/view.rs:192`、`apps/ely-mark/src/engine.rs:133-136`、`crates/ely-office/src/close.rs:107-128`
|
||||
- 现象:文件在别处被改、ElyMark 静默重载后,Cmd+W 对没动过的标签弹未保存询问;比较对话框选“用磁盘上的”后同。
|
||||
- 原因:`request_close` 比较 `Engine::history`(磁盘文字散列)与 `tab.mark`(载入或上次保存时的散列),不等即问。
|
||||
- 修法:`take_disk`(与合并采用后)同步 `tab.mark = mark(&disk.text)`。
|
||||
|
||||
### M-04 第二次打印或导出 PDF 悄悄取消第一次,没有文件也没有错误
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`apps/ely-mark/src/export.rs:93-97`、`:276`(打印与导出共用 `print-{id}.html`)、`:315-334`(`self.printing = Some(..)` 覆盖)
|
||||
- 原因:`Desk.printing` 只有一个槽,赋新值即丢掉上一个的隐藏 `WebView` 与订阅,第一份的 `Event::Printed` 无人接。
|
||||
- 修法:按作业编号保存多份,或一份未完时拒绝第二份并提示。
|
||||
|
||||
### M-05 点页面里的链接什么也不发生
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`apps/ely-mark/src/view.rs:274`(`Event::Leave(url) => log::info!(..)`)、`crates/ely-web-content/src/fence.rs:319-324`
|
||||
- 现象:预览与可视模式里点 `http(s)`/`mailto` 链接:不跳转、不开浏览器、不提示。PRD 允许用户主动打开外部链接,agents.md 说“交给应用”,应用没处理。
|
||||
- 修法:`Leave` 时 `http`、`https`、`mailto` 交系统打开,其他协议提示拒绝(同 ElyPDF 的链接策略)。
|
||||
|
||||
### M-06 `on_app_quit` 无条件删恢复点与崩溃锁,AppKit 直接终止时丢未保存内容
|
||||
- 严重度:中 | 置信度:可能(依赖 GPUI 对非 Quit 动作的终止也跑 `on_app_quit`)
|
||||
- 位置:`apps/ely-mark/src/desk.rs:244-248`、`apps/ely-mark/src/restore.rs:145-155`(`Keeping::close`)
|
||||
- 现象:注销、关机或 Dock 的“退出”经 `terminate:` 结束进程,`proceed(After::Quit)` 的未保存流程不跑,退出观察者却删掉 `session.lock` 与 `recovery/`;下次启动读到“干净结束”,什么也不回来。外壳的 `Recovering::end` 有同样假设(见 X-01)。
|
||||
- 修法:退出观察者只在无标签(或全部干净)时清理,否则留下锁与恢复点。
|
||||
|
||||
### M-07 崩溃恢复的未命名副本按“第一个显示的未命名标签”配对,内容可能落错标签
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-mark/src/restore.rs:195-201`、`apps/ely-mark/src/desk.rs:226-228`
|
||||
- 修法:`Waiting` 条目记下为它新建的标签号,`restore_shown` 按号配对。
|
||||
|
||||
### M-08 页面重载后悬着的渲染回调永不完成,导出既不成功也不报错
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-mark/src/view.rs:264-272`(`Loaded` 不清 `renders`)、`:210-220`、`apps/ely-mark/src/export.rs:165-185`
|
||||
- 修法:`Loaded`、`Failed` 时把 `renders` 全部以错误结束。
|
||||
|
||||
### M-09 导出的保存面板出错被当作取消
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-mark/src/export.rs:129-131`(`let Ok(Ok(Some(path))) = chosen.await else { return log::info!(..) }`)
|
||||
- 修法:照 `Desk::open_picked` 分三种情形,错误走 `panel_failed`。
|
||||
|
||||
### M-10 关闭查找框只清当前标签的高亮
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-mark/src/finding.rs:116-124`(`close_find` 只对 `active_view` 发 `unfind`)、`:60-69`、`apps/ely-mark/web/src/main.js:132`、`:270-273`
|
||||
- 修法:对每个标签的视图发 `unfind`。
|
||||
|
||||
### M-11 页面把搜索词写进应用日志
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-mark/web/src/main.js:246`(`console.warn` 带 `place.query`)、`crates/ely-web-content/src/handlers.rs:272-275`(控制台警告进 `log::warn!`)
|
||||
- 原因:违反规则 4(日志不含正文)。
|
||||
- 修法:只记行号与是否标题。
|
||||
|
||||
### M-12 浏览器创建完成前 `WebView` 已释放,浏览器泄漏
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`crates/ely-web-content/src/view.rs:582-587`(`Drop` 时还没有浏览器)、`crates/ely-web-content/src/handlers.rs:349-354`(`on_after_created` 无条件存入)、`crates/ely-web-content/src/engine.rs:165-170`
|
||||
- 现象:开了标签在约 100 毫秒内关掉,渲染进程留到退出。
|
||||
- 修法:`on_after_created` 发现 `surface.closed` 即 `close_browser(true)`。
|
||||
|
||||
### M-13 编辑器构建失败时页面空白且不上报
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-mark/web/src/main.js:117-139`(`show` 无 `try/catch`)、`apps/ely-mark/web/src/visual.js:106-109`
|
||||
- 现象:Milkdown `create()` 或 `newTable` 抛出时没有 `shown` 也没有 `Failed`,`#document` 空着,`MarkView.shown` 永为假(跳转、外观等一直等)。
|
||||
- 修法:`build` 包 `try/catch`,可视失败退回源码并 `tell({kind:'failed'})`。
|
||||
|
||||
### M-14 `render_pdf` 在界面线程内嵌图片并写文件
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-mark/src/export.rs:276-282`(渲染回调里 `inline`、`read_to_string`、`fs::write`);对照 `:172-176` 的 `write_output` 已在后台
|
||||
- 修法:暂存步骤移到 `background_spawn`。
|
||||
|
||||
## G ElyTree(Git)
|
||||
|
||||
### G-01 打开“文件历史”或“逐行追溯”后,中间区不再响应任何别的选择
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`apps/ely-tree/src/view.rs:185-187`(`body` 先于 `match self.side` 返回 `look_pane`);入口 `apps/ely-tree/src/hist_pane.rs:43-66`、`apps/ely-tree/src/pane.rs:483`
|
||||
- 现象:按下差异标题栏的“文件历史”或“逐行追溯”后,点改动列表里另一个文件、历史里另一个提交、分支或储藏,中间区都不变;只有历史面板的小叉能退出。
|
||||
- 原因:`show()`(`pick.rs:302`)、`pick_commit`、`pick_place`、`set_side` 都不清 `self.look`,只有 `close_look` 与 blame 的 `show_commit` 清。
|
||||
- 修法:任何新选择都清 `self.look`,或只在 `side == Changes` 且所选文件不变时画它。
|
||||
|
||||
### G-02 进行中操作读取失败被吞掉;`git am` 冲突无提示条无错误
|
||||
- 严重度:中 | 置信度:已确认(`git am` 细节为可能)
|
||||
- 位置:`apps/ely-tree/src/ops.rs:81-98`(`doing`)、`apps/ely-tree/src/moves.rs:74`、`apps/ely-tree/src/conflict_pane.rs:62`、`:106-109`
|
||||
- 现象:`doing()` 出错时 `opened.doing = Some(Err(..))`,没有任何显示或日志;冲突文件列出来了,却没有“继续/跳过/中止”提示,冲突栏两侧退回泛称。
|
||||
- 原因:`rebase-apply` 一律当变基读 `onto`,`git am` 的 `rebase-apply/` 没有 `onto`,整个读取 `NotFound`;`describe()` 失败(如 `MERGE_HEAD` 指向已被清理的对象)同样整体失败。违反规则 2。
|
||||
- 复现:终端里 `git am` 停在冲突,在 ElyTree 打开该仓库。
|
||||
- 修法:`Some(Err)` 以提示条显示 Git 原话并记日志;无 `onto` 的 `rebase-apply` 视为 `git am` 进行中。
|
||||
|
||||
### G-03 新建的空仓库在“仓库”页显示两段 Git 致命错误(引用日志、LFS)
|
||||
- 严重度:中 | 置信度:可能
|
||||
- 位置:`apps/ely-tree/src/recover.rs:50-69`(`reflog … HEAD --`)、`apps/ely-tree/src/structure.rs:137-138`(`git lfs ls-files`)、`apps/ely-tree/src/places.rs:40-50`、`apps/ely-tree/src/side_places.rs:261-264`、`:279-281`
|
||||
- 现象:“新建仓库…”后仓库页出现 “fatal: bad revision 'HEAD'” 与 “Git can't resolve ref: HEAD” 两块红字;仓库没有问题,空列表才是正确状态。
|
||||
- 原因:HEAD 未诞生时显式传 `HEAD` 的 `reflog show` 以 128 退出,`run_or_one` 不接受;`git lfs ls-files` 同样先解析 HEAD。
|
||||
- 修法:`status.head.oid` 为空时两项直接给空列表。
|
||||
|
||||
### G-04 停止取回/拉取/推送失败的提示写成“克隆没有停下”
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-tree/src/network.rs:126`(`stop_remote` 复用 `tree.error.stop`)、`crates/ely-ui/src/i18n/tree.rs:53`
|
||||
- 修法:加一条不限动词的文案。
|
||||
|
||||
### G-05 合并结果保存失败报成“Git 失败”,`tree.error.save` 是死文案
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-tree/src/work.rs:424`(`(_, Err(error)) => failed("tree.error.write", …)`)、`crates/ely-ui/src/i18n/tree_ops.rs:127`、`:255`(定义却无人用,规则 6)
|
||||
- 修法:`written()` 加 `(Write::Save, Err)` 分支用 `tree.error.save`。
|
||||
|
||||
### G-06 首次提交后紧接着取消暂存,按过期的“已有提交”判定跑成 `git rm --cached`
|
||||
- 严重度:低 | 置信度:可能(竞争窗口为一次状态重读)
|
||||
- 位置:`apps/ely-tree/src/work.rs:352`(`born` 取自缓存的 `opened.status`)、`:426-427`(`read` 异步、`next_write` 同步)、`:85-93`、`:103-112`
|
||||
- 现象:新仓库里暂存、提交,列表刷新前按“全部取消暂存”:第二条写操作仍按 `oid: None` 跑 `rm --cached -f -r -q -- .`,把刚提交的全部文件标为删除(可恢复,但吓人)。
|
||||
- 修法:写操作执行前在后台 `rev-parse --verify -q HEAD` 判定,不用缓存状态。
|
||||
|
||||
### G-07 任一引用没有创建日期,整个分支页读不出
|
||||
- 严重度:低 | 置信度:可能
|
||||
- 位置:`apps/ely-tree/src/branches.rs:33`(`%(creatordate:unix)`)、`:106`(空串解析失败整体 `Err`)、`apps/ely-tree/src/side_refs.rs:227-235`
|
||||
- 复现:`git tag blobtag HEAD:README.md`,打开分支页。
|
||||
- 修法:日期可缺(显示“—”),或跳过非提交对象的引用。
|
||||
|
||||
### G-08 清理预览后文件被外部删除,对话框只给裸 OS 错误
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-tree/src/work.rs:270`(`symlink_metadata(&at)?`)、`:415-422`
|
||||
- 现象:标题“Git did not do it”,正文 “No such file or directory (os error 2)”,没有路径,Git 并未运行。
|
||||
- 修法:映射成带路径的“自预览后已变”文案。
|
||||
|
||||
### G-09 后台读取回来的对话框覆盖用户同时打开的对话框
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-tree/src/network.rs:186`(推送选远程)、`:237`(强制推送)、`apps/ely-tree/src/recovering.rs:116`(移动分支)
|
||||
- 现象:按“推送”(无上游)后立刻按“储藏改动…”并开始键入,`git remote` 回来时储藏对话框连同输入被换掉。
|
||||
- 修法:只在 `tree.ask` 仍为空(或仍是本次请求)时赋值。
|
||||
|
||||
### G-10 选中的分支、标签、位置是窗口级的,跨仓库标签串台
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-tree/src/tree.rs:107-108`(`picked_ref`、`picked_place` 在 `Tree` 上)、`apps/ely-tree/src/refs_pane.rs:25-33`、`apps/ely-tree/src/side_refs.rs:238-245`
|
||||
- 现象:仓库 A 选中 `main`,切到仓库 B,B 的分支页显示它自己的 `main` 为已选并带动作按钮。
|
||||
- 修法:移入 `Opened`,或 `activate()` 时清掉。
|
||||
|
||||
### G-11 已选边的冲突块再选另一边,提示“结果已手改”
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-tree/src/conflict.rs:219-224`
|
||||
- 修法:给“该块已选定”单独文案,或支持改选。
|
||||
|
||||
### G-12 符号链接父目录下,当前打开的工作树被当成别的工作树而可“移除”
|
||||
- 严重度:低 | 置信度:可能
|
||||
- 位置:`apps/ely-tree/src/places_pane.rs:253-255`、`:265-271`、`apps/ely-tree/src/structure.rs:245-247`
|
||||
- 原因:`here` 用 `git worktree list` 记录的原始路径比对已规范化的 `opened.repo.root`(macOS `/tmp` → `/private/tmp`)。
|
||||
- 修法:比对前 `canonicalize`。
|
||||
|
||||
### G-13 非 macOS 平台的死入口
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-tree/src/running.rs:167-173`(停止远程命令非 Unix 报未构建)、`apps/ely-tree/src/tools.rs:109-115`(“在终端中打开”非 macOS 报未构建)、`crates/ely-ui/src/i18n/tree_places.rs:147`、`:281`(“在访达中显示”文案在 Linux、Windows 照样显示)
|
||||
- 修法:按 `cfg` 禁用或隐藏,`reveal` 换平台中性文案。
|
||||
|
||||
### G-14 `repo.rs` 的孤立文档注释
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`apps/ely-tree/src/repo.rs:10`(“How many repositories the recent list keeps.” 的常量早已移到 `ely_files::recent`,注释挂到了 `struct Repo` 上)
|
||||
- 修法:删掉。
|
||||
|
||||
## K LOK 桥(`crates/ely-lok` 的 worker、客户端、事件)
|
||||
|
||||
T-01、T-04、T-07 与 S-01 的根源都在这一层,这里只列此外的问题。
|
||||
|
||||
### K-01 非 macOS 的 worker 不运行 VCL 主循环,内核的回调与异步输入永远不送达
|
||||
- 严重度:高 | 置信度:可能(代码已读;Linux、Windows 未运行)
|
||||
- 位置:`crates/ely-lok/src/worker.rs:149-164`(非 macOS 的 `serve` 只是阻塞的 `read_frame` 循环)、`patches/libreoffice/0001-lok-macos-tile-painting.patch`(`CallbackFlushHandler::scheduleFlush` 非 MACOSX 分支仍 `Application::PostUserEvent`)
|
||||
- 现象:Linux、Windows 上 worker 能答同步请求(载入、画图块、保存),但作废、光标、状态、对话框关闭等回调一条也推不出来,键盘与鼠标(内核经 `SfxLokHelper::post*EventAsync` 以用户事件派发)也到不了文档——编辑器是一张静止的图。树莓派是明确目标。
|
||||
- 原因:用户事件只在 `Application::Yield`/`lo_runLoop` 里派发;只有 macOS 路径以 GCD 加 `run_loop` 跑循环。
|
||||
- 修法:各平台都以 unipoll 的 `runLoop` 加轮询回调驱动请求队列,照 macOS 的做法。
|
||||
|
||||
### K-02 图块的代际在画完之后才取,画到一半的作废被盖掉
|
||||
- 严重度:中 | 置信度:可能
|
||||
- 位置:`crates/ely-lok/src/worker/input.rs:76-81`(`painted`:先 `paint`,再 `events::generation(id)`)、`crates/ely-lok/src/view.rs:466`(`tile.generation >= self.generation` 即接受)
|
||||
- 现象:`paintTile` 期间内核发出作废(Writer 画时懒排版、Impress 空闲任务),返回的像素是作废前的,却带着新代际;视图接受并不再重画,旧内容留到下一次无关的作废。
|
||||
- 修法:`paint()` 之前先读代际并盖到图块上。
|
||||
|
||||
### K-03 `rect_of` 宽容解析把读不出的载荷悄悄吞掉
|
||||
- 严重度:低 | 置信度:可能
|
||||
- 位置:`crates/ely-lok/src/events/parse.rs:25-34`,用于 `events.rs:212`、`:217-218`、`:221`、`:248`
|
||||
- 现象:不是 “x, y, w, h…” 的载荷(如 Impress/Writer 表格多选时 GRAPHIC_SELECTION 的 JSON)要么被静默丢掉(选区框与手柄消失、不报 fault),要么凑出四个数字就被误读。与规则 2 相悖。
|
||||
- 修法:严格解析开头的数字字段,读不出像 `found_of` 一样进 `faults`。
|
||||
|
||||
### K-04 `Sheets.current` 用 `as u64` 转有符号的 part
|
||||
- 严重度:低 | 置信度:可能
|
||||
- 位置:`crates/ely-lok/src/worker.rs:377`(`doc.part() as u64`、`count as u64`)、`crates/ely-lok/src/client/doc.rs:40`
|
||||
- 现象:`getPart()` 返回 -1(没有视图外壳,关闭或重排期间)时 `current` 成了 2^64-1;今天的应用用 `.get` 不会 panic,但列表与标签页会错。
|
||||
- 修法:`try_from`,为负即报错。
|
||||
|
||||
## X 共用外壳与平台(ely-office、ely-files、ely-ui、ely-platform、ely-ipc)
|
||||
|
||||
### X-01 设置窗口在前时 Cmd+Q 直接退出,跳过未保存询问并抹掉恢复点
|
||||
- 严重度:致命 | 置信度:已确认
|
||||
- 位置:`crates/ely-ui/src/run.rs:77`(应用级 `on_action(Quit → cx.quit())`)、`crates/ely-ui/src/commands.rs:170`(`secondary-q` 不带键位上下文)、`:150`(菜单项)、`crates/ely-ui/src/settings/window.rs`(设置窗口根上没有 `Quit` 处理)、`crates/ely-office/src/recover.rs:59-71`、`crates/ely-files/src/crash.rs:29-46`
|
||||
- 现象:文档应用开着设置窗口(Cmd+,)时按 Cmd+Q 或选应用菜单“退出”,进程立即结束:未保存文档没有询问;`on_app_quit` 跑 `Keeping::end()` 把这次运行标为干净结束,下次启动 `Crash::check` 读到干净、`prune` 删光恢复点,内容也找不回。
|
||||
- 原因:动作先沿聚焦窗口的树派发,没人处理才落到应用级监听;文档宿主只在自己窗口根上截 `Quit`(走 `Closing::proceed(After::Quit)`),设置窗口的 `Shell` 不截,于是直接 `cx.quit()`。
|
||||
- 复现:ElyDoc 新建文档键入几字,Cmd+, 打开设置,Cmd+Q。
|
||||
- 修法:全局 `Quit` 改为让每个文档窗口走自己的退出流程(遍历 `cx.windows()` 派发),或设置窗口把 `Quit` 转发给主窗口宿主;`Keeping::end` 只在全部标签干净时删恢复点。
|
||||
|
||||
### X-02 共用 `settings.json` 读不出时 Doc、Table、Show 根本起不来
|
||||
- 严重度:高 | 置信度:已确认
|
||||
- 位置:`crates/ely-office/src/lok.rs:135`(启动任务里 `set_author(&user::name()?)`)、`:82`(`restart` 同)、`crates/ely-platform/src/user.rs:7`(`Shared::load()?`)
|
||||
- 现象:`<数据目录>/ElyOffice/settings.json` 不能解析、`version` 大于 1 或某字段类型不对时,三个 LOK 应用都以“the document engine did not start: …settings.json…”的致命对话框退出(码 1);引擎崩溃后的“重新打开文档”同样失败。agents.md 规定设置读不出“即用默认值、记下原因”,`settings::install` 也确实这样做,唯独这条路径把它变成致命错误。
|
||||
- 复现:往 settings.json 写 `{"version": 2}`,启动 ElyDoc。
|
||||
- 修法:`start`/`restart` 里设置读不出时退回 `user::account()` 并记日志(设置窗口已有 Banner 说明);或让 `user::name()` 用 `ely_ui::settings::current` 已读好的值。
|
||||
|
||||
### X-03 第二个实例启动即清掉运行中实例的工作目录与恢复点
|
||||
- 严重度:高 | 置信度:机制已确认;频率为可能(Linux 桌面每个文件起一个进程;macOS `open -n` 或开发构建)
|
||||
- 位置:`crates/ely-office/src/lib.rs:219-226`(`fresh_work` 无条件 `remove_dir_all`),调用 `crates/ely-office/src/lok.rs:128`、`apps/ely-pdf/src/viewer.rs:102`、`apps/ely-mark/src/desk.rs:162`;`crates/ely-office/src/recover.rs:59-71` 与 `crates/ely-files/src/crash.rs:40-46`(锁里 pid 活着=干净结束)
|
||||
- 现象:实例 B 启动时 (a) 删掉 `<data>/work`——A 的每个 `tab.work`——此后 A 的每次保存都在消失的目录里写 `save.{format}` 失败(`files.rs:48-50`),状态变“保存失败”、自动保存停止;(b) `Crash::check` 见 A 的 pid 活着判为干净,`prune` 删掉 A 的全部恢复点与 `index.json`;(c) `Crash::open` 把 `session.lock` 改成 B 的 pid,A 正常退出时删掉 B 的锁,B 之后崩溃被当作干净结束。
|
||||
- 复现:Linux 从文件管理器连开两个 .docx;macOS `open -n -a ElyDoc`;然后在第一个实例 Cmd+S。
|
||||
- 修法:数据目录加单实例锁(flock),已有实例在时把文件交给它;或工作目录按进程分(`work/<pid>`)、恢复点按锁 pid 存活判定不清理。
|
||||
|
||||
### X-04 属性栏字号菜单的 `expect("sizes are numbers")` 是脆弱点,但当前不可达
|
||||
- 严重度:低 | 置信度:已确认(上游源码核实)
|
||||
- 位置:`crates/ely-office/src/panel.rs:220-228`、`:283-285`
|
||||
- 说明:上游 `FontHeightPayload`(`sfx2/source/control/unoctitm.cxx:960-966`)只发 `=<数字>`(禁用时为 0),不发 `disabled`;混合选区经补丁 0003 的 JSON 走 `MIXED` 被过滤。所以这里不会 panic。保留为脆弱点:任何将来改动内核状态格式都会让整个属性栏崩溃,`points` 应改为可失败。
|
||||
|
||||
### X-05 恢复点写不成时每 10 秒弹一次提示
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`crates/ely-office/src/recover.rs:227-231`、`crates/ely-files/src/recovery.rs:105-116`
|
||||
- 现象:快照超过恢复点容量上限(默认 64 MB,可设到 32 MB;带视频的 PPTX、大 XLSX),或磁盘满、数据目录只读,只要标签还脏,每个计时周期都弹“office.recovery.failed”。
|
||||
- 原因:失败时 `slot.written` 不变、也没有像 `keeping.behind` 那样记住上次失败,下个周期 `due` 又含该标签。
|
||||
- 修法:按槽记上次失败文字,变了或成功才再提示;超容量时写 `written = Some(edited)` 并解释一次。
|
||||
|
||||
### X-06 `session.lock` 空或损坏即永久关闭恢复功能
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`crates/ely-files/src/crash.rs:21-25`(非原子写)、`:29-50`(其他内容返回 `InvalidData`)、`crates/ely-office/src/recover.rs:59`、`:134-141`
|
||||
- 现象:断电截断 `session.lock` 后,每次启动都显示“office.recovery.off: bad session lock”、不写恢复点;没有任何代码重写该文件,用户得手删。
|
||||
- 修法:解析不出当作 `Marker::Crashed`(给出恢复点并重写锁),锁文件原子写。
|
||||
|
||||
### X-07 加密文档的恢复点每次写入都解密全部已有点,且持 worker 锁
|
||||
- 严重度:中 | 置信度:可能
|
||||
- 位置:`crates/ely-files/src/recovery.rs:120-121`、`:164-190`、`:193-199`、`:225-237`;调用 `crates/ely-office/src/recover.rs:201-214`(在 `read_in` 内)
|
||||
- 现象:带密码的 PDF 每 10 秒写点时读并 AES-GCM 解密每个 `.enc`(至多 20 份×文档大小)只为列出;`RecoveryKey::load()`(钥匙串加 1 秒锁循环)也每次都跑;PDF 视图排队的编辑与读取随之停顿(树莓派上更久)。
|
||||
- 修法:按文件名列点,恢复时才解密;密钥每次运行读一次。
|
||||
|
||||
### X-08 另存为在等待关闭或退出时被拒绝,关闭链悬空,ElyMark 的会话清单冻结
|
||||
- 严重度:中 | 置信度:已确认
|
||||
- 位置:`crates/ely-office/src/save.rs:219-246`(`identity` 出错、`office.error.format`、`office.error.taken`、`office.error.convert` 四处提前返回不调 `close_cancelled`),对照 `:191-194`、`:419-424` 有调;`apps/ely-mark/src/desk.rs:85-88`、`apps/ely-mark/src/restore.rs:282-285`、`:341-343`、`:362-366`
|
||||
- 现象:退出或关窗途中对未命名标签答“保存”、选了不可保存的扩展名或别的标签正打开的文件:弹一条提示,退出静静停住;ElyMark 的 `keeping.leaving` 留在 `true`,`keep()` 不再写 `session.json`,到下次尝试退出前打开、关闭、改名的标签都不进清单,崩溃后恢复缺它们。
|
||||
- 复现:ElyMark 新建未命名文档,Cmd+Q → 保存 → 选 `notes.txt`。
|
||||
- 修法:`write_as` 的每个提前返回在 `then.is_some()` 时调 `close_cancelled`。
|
||||
|
||||
### X-09 保存期间落地的编辑可能让自动保存停摆
|
||||
- 严重度:低 | 置信度:可能(与事件次序有关)
|
||||
- 位置:`crates/ely-office/src/open.rs:241-251`(只在 `ModifiedStatus` 假→真时 `session.edit()`)、`crates/ely-office/src/save.rs:68-70`、`:131-132`、`crates/ely-files/src/session.rs`(票据按写入时的修订)
|
||||
- 现象:`save_owned` 清掉内核 modified、随后导出期间键入的 `=true` 若在导出续动作调 `ely_files::save` 之前处理,`reported` 看到 `(true, true)` 不调 `edit()`,`finish_save` 置 `Saved`;`tab.dirty()` 为真而 `state != Dirty`,自动保存不再触发直到手动保存。
|
||||
- 修法:导出开始时取票据,或每次 `Engine::edited` 递增都 `session.edit()`。
|
||||
|
||||
### X-10 别的标签的文件所在卷消失时,本标签的另存为失败并被标为保存失败
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`crates/ely-office/src/save.rs:32-41`(`holder` 对每个标签 `same_file(open, target)?`)、`:231-234`、`:429-450`(`report` → `save_failed()`)
|
||||
- 修法:`holder` 里解析不出的别的标签路径视为“不是同一文件”并记日志;写入前的拒绝不调 `save_failed()`。
|
||||
|
||||
### X-11 worker 的 stderr 中继遇到第一行非 UTF-8 即永久停止
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`crates/ely-ipc/src/relay.rs:31-34`(`lines()` 出错即 `return`)
|
||||
- 现象:LibreOffice 或 MuPDF 打出一行非 UTF-8(Linux 上 Latin-1 文件名)后,之后的结构化日志、崩溃栈与 `Relay::last_error` 全丢。
|
||||
- 修法:`read_until(b'\n')` 加 `from_utf8_lossy`,出错继续。
|
||||
|
||||
### X-12 恢复路径在界面线程做文件 I/O
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`crates/ely-office/src/recover.rs:89-98`(`flush_index` 的暂存写加 `sync_all`,`keep_tab`/`forget_tab` 调)、`:287`、`:316`(`restore` 同步调钥匙串与拷贝)
|
||||
- 现象:每次开关标签都在界面线程 fsync `index.json`;崩溃后首启在界面线程查钥匙串(最多等 1 秒锁)并逐个拷贝恢复点。慢介质(树莓派、SD 卡)上可见卡顿(规则 9)。
|
||||
- 修法:移到后台执行器,`in_window` 续。
|
||||
|
||||
### X-13 非 macOS 构建里仍提供只会报“未构建”的入口
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`crates/ely-platform/src/print.rs:87-89`(打印,各文档应用“文件›打印…”与 Cmd+P 照常)、`crates/ely-platform/src/trust.rs:23`(PDF 签名对话框)、`crates/ely-platform/src/media.rs:52`(ElyShow 插入媒体)、`crates/ely-files/src/rename.rs:63`(改名)
|
||||
- 修法:按 `cfg` 或能力标志禁用菜单项,或补齐平台实现。
|
||||
|
||||
### X-14 `agents.md` 引用的 `docs/engineering-risks.md` 不存在
|
||||
- 严重度:低 | 置信度:已确认
|
||||
- 位置:`agents.md`(三处引用“证据见 `docs/engineering-risks.md` R-01”等);`docs/` 下无此文件
|
||||
- 修法:补回文件或改引用。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
随发行说明发布,每个版本一份。支持某格式不等于支持它的全部功能;下表只写已验证的内容、已知限制与保存行为。
|
||||
|
||||
版本:Ely Office 0.1.1(首版六个应用:ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark)。日期 2026-10-09。
|
||||
版本:Ely Office 0.1.2(首版六个应用:ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark)。日期 2026-10-09。
|
||||
内核:LibreOffice 26.2.6.3(8221e31,加 `patches/libreoffice` 0001–0041);MuPDF 1.28.5(8ad45e9,加 `patches/mupdf` 0001–0005);Git 2.56.0(a018953)与 git-lfs 3.8.0;CEF 154.0.32。
|
||||
验证机:macOS 27.0 arm64。Windows 与 Linux 未验证。本机没有 Microsoft Office:与 Office 的交叉检查未做(待做)。
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ batch=${1:-1}
|
||||
dist=${ELY_DIST:-$root/dist}
|
||||
version=$(awk -F'"' '/^version = / { print $2; exit }' "$root/Cargo.toml")
|
||||
arch=$(uname -m)
|
||||
releases=https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice/releases
|
||||
[ -z "${ELY_NOTARY_PROFILE:-}" ] || [ -n "${ELY_SIGN_IDENTITY:-}" ] || {
|
||||
echo "release.sh: notarizing needs ELY_SIGN_IDENTITY, a Developer ID" >&2
|
||||
exit 1
|
||||
@@ -28,7 +29,12 @@ awk -F'\t' -v batch="$batch" '$4 == batch { print $1 "\t" $2 }' "$root/packaging
|
||||
codesign --verify --deep --strict "$dist/$name.app"
|
||||
dmg=$dist/$name-$version-$arch.dmg
|
||||
rm -f "$dmg"
|
||||
hdiutil create -quiet -volname "$name" -srcfolder "$dist/$name.app" -format UDZO "$dmg"
|
||||
# The app beside Applications: drag one onto the other.
|
||||
stage=$(mktemp -d)
|
||||
ditto "$dist/$name.app" "$stage/$name.app"
|
||||
ln -s /Applications "$stage/Applications"
|
||||
hdiutil create -quiet -volname "$name" -srcfolder "$stage" -format UDZO "$dmg"
|
||||
rm -rf "$stage"
|
||||
if [ -n "${ELY_NOTARY_PROFILE:-}" ]; then
|
||||
codesign --force --timestamp --sign "$ELY_SIGN_IDENTITY" "$dmg"
|
||||
xcrun notarytool submit "$dmg" --keychain-profile "$ELY_NOTARY_PROFILE" --wait
|
||||
@@ -37,5 +43,11 @@ awk -F'\t' -v batch="$batch" '$4 == batch { print $1 "\t" $2 }' "$root/packaging
|
||||
echo "release.sh: $dmg is not notarized (no ELY_NOTARY_PROFILE)" >&2
|
||||
fi
|
||||
echo "$dmg"
|
||||
# What the app's update check reads from the latest release.
|
||||
sha=$(shasum -a 256 "$dmg" | cut -d' ' -f1)
|
||||
url="$releases/download/v$version/$(basename "$dmg")"
|
||||
printf '{"version": "%s", "assets": {"macos-%s": {"url": "%s", "sha256": "%s"}}}\n' \
|
||||
"$version" "${arch/arm64/aarch64}" "$url" "$sha" >"$dist/$slug.json"
|
||||
echo "$dist/$slug.json"
|
||||
done
|
||||
rm -rf "$root/target/release/incremental"
|
||||
@@ -33,7 +33,7 @@ fetch() { # name url rev [sparse paths...]
|
||||
echo "fetch $name $rev"
|
||||
}
|
||||
|
||||
fetch Ely-GPUI-Components https://github.com/ZacharyZhang-NY/Ely-GPUI-Components.git b0dbfd429c17819f6c143fd365852244d497514a
|
||||
fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git 5d6efd46ec0c93b381ec7578561a1273c366025b
|
||||
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 \
|
||||
|
||||
+2
-2
@@ -15,10 +15,10 @@ mkdir -p tasks/reviews
|
||||
rm -f "$verdict"
|
||||
|
||||
# The list that holds the item.
|
||||
list=$(grep -l -- "] $id " tasks/TASKS.md site/TASKS.md site/video/TASKS.md | head -n 1)
|
||||
list=$(grep -l -- "] $id " tasks/TASKS.md tasks/BUGS.md site/TASKS.md site/video/TASKS.md | head -n 1)
|
||||
[ -n "$list" ] || { echo "review.sh: no item $id in any task list" >&2; exit 2; }
|
||||
prompt="You review item $id of Ely Office, round $round. Work dir is the project root; agents.md holds the rules.
|
||||
Read the line for $id in $list and the PRD.md sections it cites; the items before it in that list are done.
|
||||
Read the line for $id in $list and the PRD.md sections it cites (for tasks/BUGS.md: its entry in docs/BugList.md); the items before it in that list are done.
|
||||
Scope: $scope
|
||||
Find defects only: behavior that breaks the PRD, errors swallowed, silent fallbacks, dead code, files over 500 lines, comments over one line or ten words, boxes inside boxes or useless subtitles in UI, abstractions with one user, missing or weak tests, claims no evidence backs. Build and run the tests yourself.
|
||||
Copies of the tree you build for probes use their own CARGO_TARGET_DIR, never ./target: crates bake their source path in. Delete those copies and their target dirs before you write the verdict; keep only small logs.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { TEXT, type Key, type Lang } from "../src/text.ts";
|
||||
|
||||
const REPO = "https://github.com/ZacharyZhang-NY/ElyOffice";
|
||||
const REPO = "https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice";
|
||||
const APPS = ["ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyTree", "ElyMark"];
|
||||
const RELEASES = `${REPO}/releases/latest`;
|
||||
|
||||
@@ -78,13 +78,13 @@ test.describe("English locale", () => {
|
||||
await expect(lang).toHaveText("中文");
|
||||
});
|
||||
|
||||
test("downloads go to the GitHub release page, never to a file", async ({ page }) => {
|
||||
test("downloads go to the release page, never to a file", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator(".tour .get")).toHaveCount(APPS.length);
|
||||
for (const i of APPS.keys()) await expect(page.locator(".tour .get").nth(i)).toHaveAttribute("href", RELEASES);
|
||||
await expect(page.locator("#download a")).toHaveCount(1);
|
||||
await expect(page.locator("#download [data-t=github]")).toHaveAttribute("href", RELEASES);
|
||||
await expect(page.locator(".hero > a")).toHaveText("GitHub");
|
||||
await expect(page.locator("#download [data-t=get]")).toHaveAttribute("href", RELEASES);
|
||||
await expect(page.locator(".hero > a")).toHaveText("Source");
|
||||
await expect(page.locator(".hero > a")).toHaveAttribute("href", REPO);
|
||||
const files = await page.locator("a[href]").evaluateAll((as) =>
|
||||
as.map((a) => (a as HTMLAnchorElement).href).filter((h) => /\.(dmg|tar\.gz|zip|exe|AppImage)$/.test(h)),
|
||||
|
||||
+6
-6
@@ -10,7 +10,7 @@ import { film } from "./film.ts";
|
||||
import { said, speak, type Lang } from "./text.ts";
|
||||
import { RELEASES, VERSION } from "./release.ts";
|
||||
|
||||
const REPO = "https://github.com/ZacharyZhang-NY/ElyOffice";
|
||||
const REPO = "https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice";
|
||||
|
||||
const app = document.querySelector<HTMLElement>("#app");
|
||||
if (!app) throw new Error("index.html has no #app");
|
||||
@@ -18,10 +18,10 @@ if (!app) throw new Error("index.html has no #app");
|
||||
const hero = document.createElement("header");
|
||||
const title = document.createElement("h1");
|
||||
title.textContent = "Ely Office";
|
||||
const github = document.createElement("a");
|
||||
github.href = REPO;
|
||||
github.textContent = "GitHub";
|
||||
hero.append(title, said("p", "tagline"), said("p", "platforms"), github);
|
||||
const source = document.createElement("a");
|
||||
source.href = REPO;
|
||||
source.textContent = "Source";
|
||||
hero.append(title, said("p", "tagline"), said("p", "platforms"), source);
|
||||
const { section, host, svg } = tour(hero);
|
||||
rhythm(host, svg);
|
||||
|
||||
@@ -31,7 +31,7 @@ const footer = document.createElement("footer");
|
||||
footer.id = "download";
|
||||
const version = document.createElement("h2");
|
||||
version.textContent = `Ely Office ${VERSION}`;
|
||||
const get = said("a", "github");
|
||||
const get = said("a", "get");
|
||||
get.href = RELEASES;
|
||||
footer.append(version, said("p", "platforms"), said("p", "needs"), said("p", "unsigned"), get);
|
||||
|
||||
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
// The published release; downloads live on its GitHub page.
|
||||
export const VERSION = "0.1.1";
|
||||
export const RELEASES = "https://github.com/ZacharyZhang-NY/ElyOffice/releases/latest";
|
||||
// The published release; downloads live on its release page.
|
||||
export const VERSION = "0.1.2";
|
||||
export const RELEASES = "https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice/releases/latest";
|
||||
+2
-2
@@ -16,7 +16,7 @@ const en = {
|
||||
platforms: "Out now for macOS. Windows and Linux are coming soon.",
|
||||
needs: "macOS 14 or later, Apple silicon.",
|
||||
unsigned: "Not notarized. If macOS blocks the app, open System Settings › Privacy & Security and click Open Anyway.",
|
||||
github: "Download from GitHub",
|
||||
get: "Download",
|
||||
other: "中文",
|
||||
roads: "Drag a file down the road.",
|
||||
fileDoc: "ElyDoc file",
|
||||
@@ -52,7 +52,7 @@ const zh: typeof en = {
|
||||
platforms: "macOS 版已发布。Windows 与 Linux 版即将推出。",
|
||||
needs: "macOS 14 或更新,Apple 芯片。",
|
||||
unsigned: "未经公证。macOS 拦下应用时,打开“系统设置 › 隐私与安全性”,点“仍要打开”。",
|
||||
github: "前往 GitHub 下载",
|
||||
get: "前往下载",
|
||||
other: "English",
|
||||
roads: "把文件沿着路拖过去。",
|
||||
fileDoc: "ElyDoc 文件",
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<h1 data-text="title">Ely Office</h1>
|
||||
<h1 data-text="name">Ely Office 0.1.0</h1>
|
||||
<p class="version" data-text="version" data-soft>Out now for macOS · Windows and Linux soon macOS 现已推出 · Windows 与 Linux 即将推出</p>
|
||||
<p class="url" data-text="url" data-soft>github.com/ZacharyZhang-NY/ElyOffice/releases</p>
|
||||
<p class="url" data-text="url" data-soft>git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice/releases</p>
|
||||
<div class="cap" data-text="offline"><p class="en">Works with the network off.</p><p class="zh">断网照常工作。</p></div>
|
||||
<div class="cap" data-text="open"><p class="en">Open source, AGPL.</p><p class="zh">开源,AGPL 许可。</p></div>
|
||||
<div class="cap" data-text="local"><p class="en">Your files stay in your folders.</p><p class="zh">文件留在你自己的文件夹里。</p></div>
|
||||
|
||||
@@ -163,7 +163,7 @@ function end(ctx, t) {
|
||||
const line = { x: left, align: "left", a };
|
||||
text(ctx, { ...line, s: "macOS now · Windows and Linux soon", y, size: 34, color: "#c4c2c0" });
|
||||
text(ctx, { ...line, s: "macOS 现已推出 · Windows 与 Linux 即将推出", y: y + 46, size: 28, color: "#acaaa8" });
|
||||
const url = "github.com/ZacharyZhang-NY/ElyOffice";
|
||||
const url = "git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice";
|
||||
text(ctx, { ...line, s: url, y: y + 122, size: 30, color: "#f5f5f7", a: fade(t0 + BAR), weight: 400 });
|
||||
}
|
||||
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# 缺陷清单进度
|
||||
|
||||
`docs/BugList.md` 的 93 条,按清单次序逐条处理:先核对当前代码,已由先前改动修好的写明出处,其余修好、审查、提交、推送后勾上。
|
||||
|
||||
## 0.1.2 先做(用户 2026-10-09 定:先修致命与高,加自动更新,发布 0.1.2;其余随后按清单次序)
|
||||
|
||||
次序:S-03、P-01、X-01、D-01、D-02、P-02、P-03、P-04、M-01、X-02、X-03、K-01(Linux、Windows,本机不能运行)、UPD-1、REL-1、REL-2。K-01 不进 0.1.2:只影响 Linux 与 Windows 的 worker,本机不能运行验证,0.1.2 只发 macOS;留在下面的清单里,未勾。T-05 已改一半(审查查出缩放后尺寸不重测),暂放,补丁存在草稿区。
|
||||
|
||||
- [x] UPD-1 自动更新:应用内检查新版本、下载、校验,经用户同意后替换并重启;发布时生成更新清单,不再要点 GitHub 链接 — 启动检查默认关、菜单“检查更新…”、退出后再装、失败记录先于重开、经 Gitea API 找清单;R1–R4 授权、提交点、保存询问、版本绑定、横幅位置、取消退出、记录先后;R5 两处(启动检查失败记日志、以替身 open 断言先后)已修并以变异验证,五轮上限,未再审
|
||||
|
||||
- [x] T-01 内核的图块与命中按像素网格,Ely 的光标、选区、表头按文档 twips,越远越错位 — R2.16 已修(图块 twips 与打印 twips 互换)
|
||||
- [x] T-02 单元格光标露出边缘即把视图滚到中央,点击与拖选时视图跳动 — R1 拖选中仍滚、超宽格跳回起点;R2 PASS
|
||||
- [x] T-03 视口比工作表宽时网格先居中再左移一次 — 载入回复带文档种类,表格不居中;R1 测试漏几何到达前的首帧;R2 PASS
|
||||
- [x] T-04 从不调用 `setClientZoom`,内核命中与几何按默认缩放算 — R2.16 已修(worker 每次报可见区前 setClientZoom)
|
||||
- [ ] T-05 文档尺寸按内核像素取整,表尾被裁
|
||||
- [ ] T-06 滚轮向下被内核平铺区卡住
|
||||
- [ ] T-07 表头点选、行高列宽拖动是“按编号的命令”,而每次点击与拖选都推进修订,紧接着的表头操作被当作过期拒绝
|
||||
- [ ] T-08 自动筛选的列菜单对光标所在的表而不是按钮所在的表筛选、排序
|
||||
- [ ] T-09 导入文本文件时整文件的 UTF-8 校验在界面线程跑
|
||||
- [ ] T-10 CSV 导入对话框开着时打开任何别的对话框,待导入的文件静默消失
|
||||
- [ ] T-11 对话框比其标签活得久:导出、定义名称、重命名不可见却“开着”,挡住导入;名称表的“转到”激活已关闭的标签
|
||||
- [ ] T-12 打印区域是整列或整行时页面设置无法应用
|
||||
- [ ] T-13 打印失败报成“导出失败”(非 macOS 必现)
|
||||
- [ ] T-14 导入扫描把整个文件的每个字段都建成字符串
|
||||
- [ ] T-15 非 UTF-8 文本的默认编码跟系统区域而不是界面语言
|
||||
- [ ] S-01 切换幻灯片后主画布不重画
|
||||
- [x] S-02 `deck.rs` 超过 500 行 — 随 S-03 把 Extra 移到 extra.rs,deck.rs 463 行
|
||||
- [x] S-03 ElyShow 的缩放一两帧后弹回“适合幻灯片”,缩放功能不可用 — 只在标签首次观察时适合一次;R1 PASS
|
||||
- [ ] S-04 选屏对话框被别的对话框顶掉后,`show_starting` 卡住、放映副本泄漏
|
||||
- [ ] S-05 一条批注的时间恰为午夜或只有日期,整个批注栏不显示
|
||||
- [ ] S-06 导出或打印的“从–到”填 0 时导出全部幻灯片
|
||||
- [ ] S-07 母版视图下演示检查的行按幻灯片序号跳转
|
||||
- [ ] S-08 刚编辑过幻灯片就进备注栏键入,备注可能丢
|
||||
- [ ] S-09 改尺寸失败后 `size_asked` 一直留着
|
||||
- [ ] S-10 开始放映的两处拒绝无提示(规则 2)
|
||||
- [x] P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃 — worker 的拒绝改为自有错误类型 Refused;R1 测试不跨平台、不看提示;组件库 f834f55 加 Toaster::shown;R2 PASS
|
||||
- [x] P-02 每次编辑落地清掉全部图块与缩略图,页面空白、刚画的墨迹消失 — 图块随 R2.6;缩略图在编辑后留到重画;R1、R2 测试未证实际绘制;R3 PASS
|
||||
- [x] P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图 — 包住文字块的对象让位于文字,移动须过 3 个窗口像素;R1 漏掉同点的小图;R2 PASS
|
||||
- [x] P-04 `TEXT_BLOCKS` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应 — 整页一遍干跑判定全部文字块(1000 块 1.5 s→0.1 s);R1 转向文字后错位、共用原点只计一块;R2 PASS
|
||||
- [ ] P-05 连续快画或快点被拒:“文档在此间已改动,未执行”
|
||||
- [ ] P-06 默认字体“原字体”在 Type 1/Type 3 字体的文件上失败,只给引擎原话
|
||||
- [ ] P-07 含连字的文字块永远不可编辑
|
||||
- [ ] P-08 页面读取失败后每帧重试、每帧弹提示
|
||||
- [ ] P-09 识别文字的写入被拒后仍弹“成功”
|
||||
- [ ] P-10 预览回复被丢弃时“应用”静默无事
|
||||
- [ ] P-11 撤销或重做在 MuPDF 侧失败后 worker 的步数不再同步
|
||||
- [ ] P-12 `unhook` 中途抛出时泄漏保留的对象
|
||||
- [ ] P-13 优化导出的“之前”大小取磁盘文件而非当前文档
|
||||
- [ ] P-14 文字块含控制字符时 JSON 回复不可解析
|
||||
- [x] D-01 导出或打印对话框开着时关闭其标签即崩溃 — Docs 实现 tab_closed,关标签即撤下其导出对话框;R1 PASS
|
||||
- [x] D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录 — 选数据时记下模板标签,预览副本不作模板;R1 测试绕过选 CSV 入口;R2 PASS
|
||||
- [ ] D-03 界面线程同步持 worker 锁读内核(样式、字体、书签、公式、分节名)
|
||||
- [ ] D-04 一条批注的时间读不出,全部批注与修订都不显示
|
||||
- [ ] D-05 邮件合并某条记录失败时内核文档泄漏
|
||||
- [ ] D-06 导航栏大纲点击按当前列表取下标,后台重读后可能越界
|
||||
- [ ] D-07 导航栏隐藏时仍随每次光标移动重读大纲与书签
|
||||
- [ ] D-08 Linux、Windows 上“打印…”可用,导出 PDF 后才报“此平台未构建”
|
||||
- [x] M-01 键入期间外部以相同内容重写文件,丢掉上次停顿之后键入的全部文字 — 先等页面送出未送的键;同文只接过文件与字节、标签为已保存;磁盘回到上次保存的文字时保留编辑并恢复自动保存;R1–R4 字节、自动保存、在途写入、已保存基线;R5 PASS
|
||||
- [ ] M-02 `refresh` 归零版本计数却不换代,在途的一键让标签一直“未落定”
|
||||
- [ ] M-03 干净标签接过磁盘文字后关闭仍问“是否保存”
|
||||
- [ ] M-04 第二次打印或导出 PDF 悄悄取消第一次,没有文件也没有错误
|
||||
- [ ] M-05 点页面里的链接什么也不发生
|
||||
- [ ] M-06 `on_app_quit` 无条件删恢复点与崩溃锁,AppKit 直接终止时丢未保存内容
|
||||
- [ ] M-07 崩溃恢复的未命名副本按“第一个显示的未命名标签”配对,内容可能落错标签
|
||||
- [ ] M-08 页面重载后悬着的渲染回调永不完成,导出既不成功也不报错
|
||||
- [ ] M-09 导出的保存面板出错被当作取消
|
||||
- [ ] M-10 关闭查找框只清当前标签的高亮
|
||||
- [ ] M-11 页面把搜索词写进应用日志
|
||||
- [ ] M-12 浏览器创建完成前 `WebView` 已释放,浏览器泄漏
|
||||
- [ ] M-13 编辑器构建失败时页面空白且不上报
|
||||
- [ ] M-14 `render_pdf` 在界面线程内嵌图片并写文件
|
||||
- [ ] G-01 打开“文件历史”或“逐行追溯”后,中间区不再响应任何别的选择
|
||||
- [ ] G-02 进行中操作读取失败被吞掉;`git am` 冲突无提示条无错误
|
||||
- [ ] G-03 新建的空仓库在“仓库”页显示两段 Git 致命错误(引用日志、LFS)
|
||||
- [ ] G-04 停止取回/拉取/推送失败的提示写成“克隆没有停下”
|
||||
- [ ] G-05 合并结果保存失败报成“Git 失败”,`tree.error.save` 是死文案
|
||||
- [ ] G-06 首次提交后紧接着取消暂存,按过期的“已有提交”判定跑成 `git rm --cached`
|
||||
- [ ] G-07 任一引用没有创建日期,整个分支页读不出
|
||||
- [ ] G-08 清理预览后文件被外部删除,对话框只给裸 OS 错误
|
||||
- [ ] G-09 后台读取回来的对话框覆盖用户同时打开的对话框
|
||||
- [ ] G-10 选中的分支、标签、位置是窗口级的,跨仓库标签串台
|
||||
- [ ] G-11 已选边的冲突块再选另一边,提示“结果已手改”
|
||||
- [ ] G-12 符号链接父目录下,当前打开的工作树被当成别的工作树而可“移除”
|
||||
- [ ] G-13 非 macOS 平台的死入口
|
||||
- [ ] G-14 `repo.rs` 的孤立文档注释
|
||||
- [ ] K-01 非 macOS 的 worker 不运行 VCL 主循环,内核的回调与异步输入永远不送达
|
||||
- [ ] K-02 图块的代际在画完之后才取,画到一半的作废被盖掉
|
||||
- [ ] K-03 `rect_of` 宽容解析把读不出的载荷悄悄吞掉
|
||||
- [ ] K-04 `Sheets.current` 用 `as u64` 转有符号的 part
|
||||
- [x] X-01 设置窗口在前时 Cmd+Q 直接退出,跳过未保存询问并抹掉恢复点 — 退出一律经主窗口宿主的流程(route_quit),延后到动作结束、先把宿主窗口提到前面;R1 主窗口 Cmd+Q 不问;R2 询问被设置窗口挡住;R3 PASS
|
||||
- [x] X-02 共用 `settings.json` 读不出时 Doc、Table、Show 根本起不来 — 作者名取启动时读入的设置;R1 重启失败丢作者、脚本退出码、client.rs 超行;R2 作者更新的先后与停机期间的更新;R3 PASS
|
||||
- [x] X-03 第二个实例启动即清掉运行中实例的工作目录与恢复点 — 每个应用一个实例锁,第二份被拒并报出持锁进程(Unix fcntl F_GETLK);R1–R3 进程号竞争、Windows 断言;R4 PASS
|
||||
- [ ] X-04 属性栏字号菜单的 `expect("sizes are numbers")` 是脆弱点,但当前不可达
|
||||
- [ ] X-05 恢复点写不成时每 10 秒弹一次提示
|
||||
- [ ] X-06 `session.lock` 空或损坏即永久关闭恢复功能
|
||||
- [ ] X-07 加密文档的恢复点每次写入都解密全部已有点,且持 worker 锁
|
||||
- [ ] X-08 另存为在等待关闭或退出时被拒绝,关闭链悬空,ElyMark 的会话清单冻结
|
||||
- [ ] X-09 保存期间落地的编辑可能让自动保存停摆
|
||||
- [ ] X-10 别的标签的文件所在卷消失时,本标签的另存为失败并被标为保存失败
|
||||
- [ ] X-11 worker 的 stderr 中继遇到第一行非 UTF-8 即永久停止
|
||||
- [ ] X-12 恢复路径在界面线程做文件 I/O
|
||||
- [ ] X-13 非 macOS 构建里仍提供只会报“未构建”的入口
|
||||
- [ ] X-14 `agents.md` 引用的 `docs/engineering-risks.md` 不存在
|
||||
|
||||
## 0.1.2 发布
|
||||
|
||||
- [x] REL-1 dmg 里有指向“应用程序”的快捷方式,可直接拖入安装(用户 2026-10-09 报) — release.sh 在 dmg 里放“应用程序”替身,随 UPD-1 审查
|
||||
- [ ] REL-2 修完上列各条后打包、发布 0.1.2,官网指向新发布页
|
||||
+1
-1
@@ -196,5 +196,5 @@
|
||||
- [x] R2.16 ElyTable(用户 2026-10-09 报):单击单元格跳到别处(如 A5876),无法拖选多格 — R1 缩放与 DPI 变化漂移、拖选与远处测试缺;R2 测试跑在文字文档上;R3 PASS
|
||||
- [x] R2.17 ElyPDF(用户报):编辑文字时报“这种字体画不出‘V’”,而所改文字里没有 V;改字对应的文字块须与面板所示一致 — R1 CMap 漏条目数、共用字形的字被改写、文件超 500 行;R2 部分 ToUnicode 不退回 cid_to_ucs;R3 PASS
|
||||
- [x] R2.18 官网(用户定):下载一律指向 GitHub 的 v0.1.x 发布页,不直接链 DMG — R1 截图文案死代码(连同 screen() 与截图一并删去);R2 PASS;已上线
|
||||
- [ ] R2.19 发布 0.1.1:版本号、打包、GitHub Release、官网指向新发布页
|
||||
- [x] R2.19 发布 0.1.1:版本号、打包、GitHub Release、官网指向新发布页 — v0.1.1 已发布(六个 arm64 dmg 与两个源码包,releases/latest),官网显示 0.1.1;release-macos.sh 通过
|
||||
- [x] R2.20 ElyShow:放映图层一次回复超过 64 MB 帧上限(大屏上 4 张以上图层的幻灯片报 “layers pass 67107840 bytes”,放映起不来) — R1 单层仍超帧上限、暂存不随文档释放;R2 测试只核长度;R3 PASS
|
||||
@@ -0,0 +1 @@
|
||||
PASS
|
||||
@@ -0,0 +1,3 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-doc/src/merge/tests.rs:174(相关:apps/ely-doc/src/tests.rs:471):模板绑定的回归测试绕过了生产入口 `choose_csv`。`field_here` 直接写入 `Merge.source`,随后测试直接向 `preview_record`、`generate` 传入模板编号;新增的 `source()` 断言验证的是辅助函数预写入的状态。具体复现:在隔离副本中仅移除 merge.rs:84 的 `merge.source = Some(source)`,现有 5 项 `merge::` 测试仍全部通过。此时新会话选择 CSV 后 `Merge.source` 保持 `None`,预览与生成都会误报“模板已关闭”。通过 `choose_csv` 和 `simulate_path_prompt_response` 驱动的额外探针在原代码通过,在该变异下明确失败:实际 `None`,预期 `Some(0)`。证据见 `/tmp/ely-d02-mutation-existing.log`、`/tmp/ely-d02-mutation-probe.log`、`/tmp/ely-d02-probe-baseline.log`;原代码的 ElyDoc 全部 47 项测试、5 项 i18n 测试及应用构建通过。修复方向:增加通过 CSV 选择入口建立绑定的回归测试,预览后经生产模板解析路径生成至少两条不同记录,核对来源与输出内容,并让上述删除绑定的变异触发失败。
|
||||
@@ -0,0 +1 @@
|
||||
PASS
|
||||
@@ -0,0 +1,5 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-mark/src/outside.rs:231(adopt_disk:294):相同文本分支采用新指纹,却保留旧的 doc.source,后续保存使用旧 BOM、换行格式及原始字节,违反 PRD §14.3。真窗口复现两次:打开字节为 b"alpha\n\nbeta\n" 的文件,外部替换为 b"\xef\xbb\xbfalpha\r\n\r\nbeta\r\n",等待 changed outside; unsaved false,再按 Cmd+S;磁盘恢复为原来的 LF 文件,BOM 消失。证据:/tmp/ely-m01-probe-rerun.log 的 bytes expected/actual。修复方向:相同文本采用磁盘版本时同步 doc.source,保持编辑器实例及待处理输入。
|
||||
|
||||
apps/ely-mark/src/outside.rs:271(keep_edits:278):磁盘恢复上次保存文本后,只 rebase 会话并清除提示,先前冲突停止的 PolicyEngine 持续处于 stopped 状态;后续键入也无法恢复默认自动保存,违反 PRD §6.2,并撤下了处理该状态的入口。真窗口复现两次:打开 alpha/beta 文档,键入 X,外部写入 external 触发冲突,再把磁盘恢复为原始文本;等待 local edits stay 后键入 Y、停手三秒,磁盘仍是原文,autosaves=0;手动 Cmd+S 才写出 b"XYalpha\n\nbeta\n"。证据:/tmp/ely-m01-probe-rerun.log 及 /tmp/ely-m01-restart.log。修复方向:解除冲突时同步恢复自动保存策略,并为保留的未保存编辑安排停手保存。
|
||||
@@ -0,0 +1,5 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-mark/src/outside.rs:234(keep_edits:267):保留本地编辑的分支只接过磁盘指纹,丢弃读到的 Source,后续保存仍使用旧 doc.source 的 BOM、换行格式和原始字节,违反 PRD §14.3。真窗口独立复现三次:打开 b"alpha\n\nbeta\n",在开头键入 X,停手保存前外部写入 b"\xef\xbb\xbfalpha\r\n\r\nbeta\r\n";日志出现 local edits stay,自动保存却写出 b"Xalpha\n\nbeta\n"。删掉 X 后再次保存也写回旧 LF 字节。证据:/tmp/ely-m01-r2-probe-results.log、/tmp/ely-m01-r2-stable-1-results.log、/tmp/ely-m01-r2-stable-2-results.log。修复方向:keep_edits 接收并采用本次磁盘 Source,保留编辑器、当前文字与编辑历史;回归覆盖存在未保存输入时的 BOM/CRLF 基线重写。
|
||||
|
||||
apps/ely-mark/src/outside.rs:273(autosave.rs:56):重建保存策略时保留了失败自动保存的 extra.writing,save_after_pause 安排的任务随后被 writing.is_some() 阻挡,保留的编辑持续未保存,违反 PRD §6.2。真窗口碰撞探针连续复现两次:打开 alpha/beta 文档并键入 X,在 autosaves tab 日志出现后立即以原文重写文件;保存指纹检查报告 meets an external change,随后 rebase 报 local edits stay。停手四秒,autosaves 仍只有首次失败的一次,磁盘仍为 b"alpha\n\nbeta\n";手动 Cmd+S 才写出 b"Xalpha\n\nbeta\n"。证据:/tmp/ely-m01-r2-collision-2-results.log、/tmp/ely-m01-r2-collision-3-results.log 及对应 save-collision.log。修复方向:恢复策略时结清旧自动保存的 writing 状态与修订归属,再安排保留编辑的保存;回归覆盖自动保存撞上同内容外部重写后,持续停手即可完成保存。
|
||||
@@ -0,0 +1,3 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-mark/src/outside.rs:300(同文本分支:231;autosave.rs:20):adopt_disk 重建保存策略时保留失败自动保存的 extra.writing,下一轮编辑把旧修订结算到新策略,导致该轮自动保存被跳过,违反 PRD §6.2。真窗口独立复现两次:打开 b"alpha\n\nbeta\n",在开头键入 X,首次 autosaves tab 日志出现时外部写入 b"Xalpha\n\nbeta\n",保存指纹检查报告 meets an external change,同文本分支接过磁盘;随后键入 Y 并停手三秒,磁盘仍为 b"Xalpha\n\nbeta\n",autosaves 仍只有一次;再键入 Z 才自动保存 b"XYZalpha\n\nbeta\n"。原因是 autosave 将旧 writing=1 作为成功保存结算,新策略的 persisted 变成 1,随后 Y 的 edit 也得到 revision=1,tick 因两者相等跳过保存。证据:/tmp/ely-m01-r3-native-results.log、/tmp/ely-m01-r3-collision-current-1.log、/tmp/ely-m01-r3-collision-current-2.log。修复方向:adopt_disk 重建策略时同步清除旧 extra.writing,并覆盖同文本碰撞后首轮编辑停手即可保存的回归场景。
|
||||
@@ -0,0 +1,3 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-mark/src/outside.rs:239(adopt_disk:302-309):同文本接管更新会话、Source 和保存策略时,保留了旧的 MarkView.saved 与 tab.modified;当前内容已全部写在磁盘上,标签仍参与未保存冲突判定,违反 PRD §14.4、FILE-02。真窗口独立复现三次:打开 b"alpha\n\nbeta\n",在开头键入 X,自动保存前外部写入 b"Xalpha\n\nbeta\n";等待 session: reloaded 并停手三秒,再由外部追加 b"\ngamma\n"。应用记录 changed outside; unsaved true、停止自动保存,AX 树显示 “t.md changed on disk; your unsaved text is kept” 和 Compare… 按钮;按 §14.4 此时应作为干净文件接收外部更新。证据:/tmp/ely-m01-r4-adopt-clean-1.log、/tmp/ely-m01-r4-adopt-clean-2.log、/tmp/ely-m01-r4-adopt-ax-native.log、/tmp/ely-m01-r4-adopt-ax.log。修复方向:同文本接管时同步视图的已保存基线与标签修改状态,保留页面及编辑历史;回归覆盖“外部写入当前编辑文本,随后再次外部修改”的状态转换。
|
||||
@@ -0,0 +1 @@
|
||||
PASS
|
||||
@@ -0,0 +1,5 @@
|
||||
FAIL
|
||||
|
||||
apps/ely-pdf/src/tests/output.rs:38:新增测试直接使用 Unix 专属的 `std::os::unix::fs::PermissionsExt` 和 `Permissions::from_mode`,测试函数与上层测试模块均缺少平台条件。Windows 编译 ElyPDF 测试目标时会报 E0433、E0599,阻断该平台测试。已将这两处用法原样交给已安装的 `x86_64-pc-windows-msvc` 目标执行 `rustc --emit metadata`,复现两项错误,证据见 `/tmp/ely-p01-r1-windows.log`。修复方向:用 `#[cfg(unix)]` 限定该 Unix 权限测试,保证 Windows 测试目标能够编译。
|
||||
|
||||
apps/ely-pdf/src/tests/output.rs:55:新增权限错误回归测试只断言目标文件不存在,固定推进帧后结束,缺少失败处理完成与用户可见错误的断言,静默吞错仍能通过,漏检规则 2 与 PRD UX-05、UX-06 要求的错误反馈。在独立源码副本中将 `Viewer::failed` 改为仅记录探针日志后返回,实际收到 `PermissionDenied`,此测试仍为 `1 passed`;证据见 `/tmp/ely-p01-r1-mutation.log` 与 `/tmp/ely-p01-r1-observed-error.log`。修复方向:接入真实错误提示接收者,等待失败处理完成并断言权限错误提示,使吞错变异触发测试失败。
|
||||
@@ -0,0 +1 @@
|
||||
PASS
|
||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user