1 Commits
Author SHA1 Message Date
雷电芽衣 6588acbc88 Relicense Ely Office's own code from AGPL-3.0 to MIT
Applied on top of the released v0.1.1 so the tag says MIT, as main does
(271468d). Bundled engines keep their licences: LibreOffice (MPL-2.0),
Git (GPL-2.0) and MuPDF (AGPL-3.0).
2026-10-20 01:33:47 -04:00
1264 changed files with 16911 additions and 178117 deletions

No files matched your search

+4
View File
@@ -1,2 +1,6 @@
[target.'cfg(all(windows, target_env = "msvc"))']
rustflags = ["-C", "target-feature=+crt-static"]
[env]
# cef-dll-sys builds against the CEF that scripts/fetch-cef.sh fetched.
CEF_PATH = { value = "engines/cef", relative = true }
-63
View File
@@ -1,63 +0,0 @@
name: Bug Report
description: Something behaves, draws or builds wrong.
title: "[BUG REPORT] "
labels: [bug]
body:
- type: dropdown
id: subject
attributes:
label: App
options:
- ElyOffice
- ElyDoc
- ElyTable
- ElyShow
- ElyPDF
- ElyMark
- ElyTree
validations:
required: true
- type: dropdown
id: where
attributes:
label: Where
options:
- macOS
- Windows
- Linux
validations:
required: true
- type: textarea
id: what
attributes:
label: What happened
description: A screenshot or a short recording helps most.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1.
2.
3.
validations:
required: true
- type: textarea
id: expected
attributes:
label: What you expected
validations:
required: true
- type: input
id: version
attributes:
label: "Version"
placeholder: "0.1.8"
- type: textarea
id: logs
attributes:
label: Logs
description: Terminal output or error messages.
render: shell
-5
View File
@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Ely Office
url: https://office.elygpui.com
about: "Downloads and what each app does."
@@ -1,42 +0,0 @@
name: Feature Request
description: Something new, or something that should do more.
title: "[FEATURE REQUEST] "
labels: [enhancement]
body:
- type: dropdown
id: subject
attributes:
label: App
options:
- ElyOffice
- ElyDoc
- ElyTable
- ElyShow
- ElyPDF
- ElyMark
- ElyTree
validations:
required: false
- type: textarea
id: use
attributes:
label: What it is for
description: The problem, and the moment you need it in.
validations:
required: true
- type: textarea
id: behavior
attributes:
label: How it should work
- type: textarea
id: references
attributes:
label: References
description: Links or screenshots of it elsewhere.
- type: checkboxes
id: searched
attributes:
label: Checked
options:
- label: I searched the open issues for the same request.
required: true
-11
View File
@@ -1,11 +0,0 @@
name: Other
description: Questions, docs, ideas, anything else.
title: "[Other] "
labels: [question]
body:
- type: textarea
id: what
attributes:
label: What's on your mind
validations:
required: true
-35
View File
@@ -1,35 +0,0 @@
<!--
Title: start with the type, for example
[FEATURE REQUEST] Add dark mode to the settings page
[BUG REPORT] Fix a crash when opening an empty file
[Other] Update the README install steps
-->
## Type
- [ ] **[FEATURE REQUEST]**: a new feature or an improvement
- [ ] **[BUG REPORT]**: a bug fix
- [ ] **[Other]**: docs, refactoring, CI, dependencies or chores
## What and why
<!-- One or two sentences. Link the issue: Closes #123 -->
## Bug details
<!-- [BUG REPORT] only; delete this section otherwise. -->
- Steps to reproduce:
- Expected:
- Actual:
- Cause and fix:
## How it was tested
<!-- Commands you ran, and what you checked by hand. -->
## Checklist
- [ ] The title starts with `[FEATURE REQUEST]`, `[BUG REPORT]` or `[Other]`
- [ ] Tests and the build pass locally
- [ ] Docs are updated if behaviour changed
+77
View File
@@ -0,0 +1,77 @@
# 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
+48 -59
View File
@@ -1,69 +1,58 @@
# A tag builds, packages and uploads all three platforms.
# Tags only: no pull request ever reaches the self-hosted runners.
# Builds macOS apps for a release tag and attaches them.
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: Release tag to attach the packages to, such as v0.1.0
required: true
permissions:
contents: write
jobs:
macos:
# A GUI-session agent: daemons see no login keychain.
runs-on: [self-hosted, macos-signing]
timeout-minutes: 720
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:
- name: Build, package and upload for macOS
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The dev clone's engines: LibreOffice from scratch outlasts the runner.
ELY_SHARE: /Users/zacharyzhang/Documents/GitHub/ElyOfficeSuite
ELY_PATH: /Users/zacharyzhang/.cargo/bin:/opt/homebrew/bin
# Repository variables; both live in the runner's keychain.
ELY_SIGN_IDENTITY: ${{ vars.ELY_SIGN_IDENTITY }}
ELY_NOTARY_PROFILE: ${{ vars.ELY_NOTARY_PROFILE }}
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Engines
run: |
dir=$HOME/ely-ci/ElyOffice
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
git -C "$dir" fetch --force --tags origin
git -C "$dir" checkout --force "${{ github.sha }}"
"$dir/scripts/ci.sh" macos "${{ github.ref_name }}"
windows:
runs-on: [self-hosted, ely-windows]
timeout-minutes: 360
steps:
- name: Build, package and upload for Windows
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ELY_SHARE: /c/Users/zhang/Documents/Github/ElyOffice
ELY_PATH: /c/Program Files/CMake/bin:/c/Users/zhang/AppData/Local/Microsoft/WinGet/Packages/Ninja-build.Ninja_Microsoft.Winget.Source_8wekyb3d8bbwe
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: |
dir=$HOME/ely-ci/ElyOffice
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
git -C "$dir" fetch --force --tags origin
git -C "$dir" checkout --force "${{ github.sha }}"
"$dir/scripts/ci.sh" windows "${{ github.ref_name }}"
linux:
# WSL on the Windows runner keeps a built kernel.
runs-on: [self-hosted, ely-windows]
needs: windows
timeout-minutes: 360
steps:
- name: Build, package and upload for Linux
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WSLENV: ELY_RELEASE_TOKEN
scripts/fetch-references.sh
scripts/build-mark-content.sh
- name: Tests that need no window
run: |
wsl -d Ubuntu --exec bash -lc '
set -euo pipefail
dir=$HOME/ely-ci/ElyOffice
[ -d "$dir/.git" ] || git clone https://github.com/ELY-Open-Source/ElyOffice.git "$dir"
git -C "$dir" remote set-url origin https://github.com/ELY-Open-Source/ElyOffice.git
git -C "$dir" fetch --force --tags origin
git -C "$dir" checkout --force "${{ github.sha }}"
ELY_SHARE=$HOME/Documents/Github/ElyOffice "$dir/scripts/ci.sh" linux "${{ github.ref_name }}"
'
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
+2 -6
View File
@@ -1,8 +1,8 @@
# Build output and downloads
/target/
/dist/
/engines
/reference
/engines/
/reference/
node_modules/
.DS_Store
.ruff_cache/
@@ -16,7 +16,3 @@ node_modules/
/docs/acceptance.md
/docs/test-map.md
/assets/papirus_icons-master.zip
/Gitea.md
/.work/
/TyporaRef
/oauth.env
-12
View File
@@ -1,12 +0,0 @@
# Contributing
## Opening a pull request on GitHub
1. Fork https://github.com/ELY-Open-Source/ElyOffice and clone your fork:
```bash
git clone https://github.com/<your-account>/ElyOffice.git
```
2. Commit your changes on a branch and push it to your fork.
3. Open a pull request against `main`. To update it, push to the same branch.
Generated
+253 -1171
View File
File diff suppressed because it is too large. Load diff
+6 -26
View File
@@ -5,24 +5,22 @@ members = ["apps/*", "crates/*"]
exclude = ["engines", "reference"]
[workspace.package]
version = "0.1.9"
version = "0.1.1"
edition = "2024"
rust-version = "1.95"
license = "MIT"
publish = false
[workspace.dependencies]
embed-resource = "=3.0.11"
csv = "1"
encoding_rs = "0.8"
jiff = "0.2"
anyhow = "1"
directories = "6"
dunce = "1"
ely-gpui-component = { git = "https://github.com/ELY-Open-Source/Ely-GPUI-Components", rev = "cd365e92ea0c4467c439b8253963461ba0f6d155" }
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "b0dbfd429c17819f6c143fd365852244d497514a" }
env_logger = "0.11"
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit"] }
futures = "0.3"
libc = "0.2"
log = "0.4"
@@ -39,48 +37,30 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg",
semver = { version = "1", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
html5ever = "0.39"
sha2 = "0.10"
tempfile = "3"
ureq = "3"
windows-sys = { version = "0.60", features = ["Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_Globalization", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Threading", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_UI_Shell", "Win32_Security_Authentication_Identity"] }
windows = { version = "0.62", features = ["Data_Pdf", "Graphics_Imaging", "Storage_Streams", "Win32_Graphics_Gdi", "Win32_Graphics_Printing", "Win32_Storage_Xps", "Win32_System_Memory", "Win32_System_WinRT", "Win32_UI_Controls_Dialogs", "Win32_UI_Input_KeyboardAndMouse"] }
lopdf = { version = "0.45", default-features = false }
zbus = { version = "5", default-features = false, features = ["async-io"] }
windows-sys = { version = "0.60", features = ["Win32_UI_WindowsAndMessaging", "Win32_Globalization", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Threading", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_UI_Shell", "Win32_Security_Authentication_Identity"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["NSLocale", "NSPathUtilities", "NSString", "NSURL", "NSDictionary", "NSValue", "NSError", "NSRunLoop", "NSDate", "NSUserDefaults"] }
objc2-av-foundation = { version = "0.3", default-features = false, features = ["std", "AVPlayer", "AVPlayerItem", "AVPlayerItemOutput", "AVAsset", "objc2-core-media", "objc2-core-video", "objc2-core-foundation"] }
objc2-core-media = { version = "0.3", default-features = false, features = ["std", "CMTime"] }
objc2-core-video = { version = "0.3", default-features = false, features = ["std", "CVBuffer", "CVImageBuffer", "CVPixelBuffer"] }
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFString", "CFCGTypes"] }
core-video = "0.5"
cef = { git = "https://github.com/tauri-apps/cef-rs", rev = "a712ad9bacd234c050631a95b9745b27d346b7cc", default-features = false }
core-foundation = "0.10"
objc2 = "0.6"
security-framework = "3.7"
objc2-pdf-kit = { version = "0.3", default-features = false, features = ["PDFDocument", "PDFPage", "objc2-app-kit"] }
imap-proto = "0.17"
nom = "8"
mail-builder = { version = "1.0", default-features = false }
mail-parser = { version = "0.11", features = ["full_encoding"] }
mail-send = { version = "0.6", default-features = false, features = ["ring", "tls12"] }
rcgen = "0.14"
rustls = { version = "0.23", default-features = false, features = ["ring", "tls12", "std"] }
rustls-platform-verifier = "0.7"
tokio = { version = "1", features = ["net", "time", "io-util", "rt", "macros"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
ely-files = { path = "crates/ely-files" }
ely-ipc = { path = "crates/ely-ipc" }
ely-lok = { path = "crates/ely-lok" }
ely-mail-engine = { path = "crates/ely-mail-engine" }
ely-office = { path = "crates/ely-office" }
ely-pdf-engine = { path = "crates/ely-pdf-engine" }
ely-platform = { path = "crates/ely-platform" }
ely-web-content = { path = "crates/ely-web-content" }
ely-ui = { path = "crates/ely-ui" }
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
[patch.crates-io]
gpui-pre = { path = "vendor/gpui-pre" }
gpui-pre-windows = { path = "vendor/gpui-pre-windows" }
+43 -39
View File
@@ -22,12 +22,12 @@ Ely Office 提供八个桌面应用。用户可以写文档、计算表格、制
| ElyTable | 电子表格编辑与计算 | LOK | XLSX、ODS、CSV |
| ElyShow | 演示文稿编辑与放映 | LOK | PPTX、ODP |
| ElyPDF | PDF 阅读、编辑、批注、表单和页面处理 | MuPDF;OCR 复用 Tesseract | PDF |
| ElyMail | 邮件收发、本地存储和搜索 | Rust 的 mail-send、mail-parser、mail-builder(IMAP 与 POP3 由引擎自写),加本地同步与存储 | 邮件、附件、草稿 |
| ElyMail | 邮件收发、本地存储和搜索 | MailCore 2,加本地同步与存储 | 邮件、附件、草稿 |
| ElyTree | Git 图形化版本管理 | Git CLI | 本地 Git 仓库 |
| ElyMark | 本地 Markdown 编辑 | Ely-GPUI-Components 的 Markdown 文档组件,markdown-rs 解析;参考 MarkText、ColaMD、Markion | Markdown 与本地附件 |
| ElyMark | 本地 Markdown 编辑 | Milkdown 与 CodeMirror 6;参考 MarkText、ColaMD 的编辑与文件处理方式 | Markdown 与本地附件 |
| ElyAgent | 独立的 Agent 配置与会话管理 | 嵌入的 Kigi CLI Rust 代码 | 配置、会话记录、运行状态 |
LOK、MuPDF 和 GPUI 是已确定的方向。邮件协议 crate、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 编辑框架、本地 HTML 宿主和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能。它不是其他七个应用的聊天侧栏,也不是整个套件的控制中枢。[S19]
@@ -35,7 +35,7 @@ ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能
1. 首个正式版交付全部八个应用及本文定义的功能,不发布只有入口和占位界面的套件。
2. Doc、Table、Show 共用 LOK 适配实现。PDF 使用 MuPDF。不开设替代内核路线。
3. GPUI 负责应用界面。专业内容区域可以使用内核渲染结果,但外观和行为必须遵守 Ely 规范;不用 CEF 与 Electron。
3. GPUI 负责应用界面。专业内容区域可以使用内核渲染结果或本地 Web 内容区域,但外观和行为必须遵守 Ely 规范。
4. 应用可以独立启动、更新和卸载。安装 ElyMark、ElyTree 或 ElyAgent 不得强制安装 LOK。
5. 用户文件采用通用格式。应用数据库只保存索引、配置和运行记录,不取代用户文件。
6. 不建设云端文档平台、微服务系统、统一业务数据库或跨应用 Agent 编排系统。
@@ -375,18 +375,16 @@ XFA 动态表单、任意嵌入脚本执行、专业印前分色及 PDF 到 Offi
### 12.1 定位与后端
提供多账号邮件客户端,不建设邮件托管服务。协议与 MIME 用 Rust crate(用户 2026-10-19 定,取代 MailCore 2):IMAP(imap-proto 解析)与 POP3 由 Ely 自写(async-imap 与 async-pop 有数处缺陷,C12.1 审查查出),mail-send 发 SMTP,mail-parser 读 MIME,mail-builder 写 MIME。它们在应用进程的后台任务里运行,不另起工作进程。Ely 负责账号流程、本地存储、搜索、同步队列和界面。
提供多账号邮件客户端,不建设邮件托管服务。MailCore 2 负责 IMAP、POP、SMTP 和邮件解析等能力。Ely 负责账号流程、本地存储、搜索、同步队列和界面。MailCore 2 官方列出 C++ API 与三端支持,但发行前仍须验证实际构建和依赖。[S10]
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文与写信不用 CEF 与 Electron(用户 2026-10-19 定):正文以 html5ever 解析,净化后按块的子集由 GPUI 绘制(段落、标题、列表、引用、表格、图片、链接、粗斜体与颜色),脚本、定位、浮动与样式表之外的版式不画,不当作完整页面渲染;写信用组件库的 Markdown 文档组件,发出纯文本与由 markdown-rs 生成的 HTML 两部分。
内置登录:Gmail 与 Microsoft 走 OAuth;iCloud 不给第三方 OAuth,走应用专用密码向导(服务器 `imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)[S10]。OAuth 的客户端 ID 在构建时注入,缺 ID 的构建里该登录写明不可用。
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文和富文本撰写区使用本地内容宿主,不能把正文 HTML 解析当成完整页面渲染。
### 12.2 功能要求
| 编号 | 功能 | 完成要求 |
| --- | --- | --- |
| MAIL-01 | 账号 | 支持多个 IMAP/SMTP 账号、POP3 账号、服务器配置、TLS 与连通性检查 |
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程,iCloud 的应用专用密码向导;支持服务商允许的应用密码或自定义认证配置 |
| MAIL-02 | 授权 | 提供 Gmail 与 Microsoft 邮箱的 OAuth 流程;支持服务商允许的应用密码或自定义认证配置 |
| MAIL-03 | 邮箱视图 | 支持统一收件箱、账号独立视图、文件夹、未读、标记和附件筛选 |
| MAIL-04 | 阅读 | 支持纯文本、HTML、内嵌图片、附件、原始邮件头与会话线程 |
| MAIL-05 | 写信 | 支持收件人、抄送、密送、回复、回复全部、转发、签名、附件和纯文本/富文本撰写 |
@@ -476,11 +474,9 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
提供以本地 Markdown 文件为中心的编辑器。支持可视化编辑、源码编辑和只读预览。文件可由其他编辑器、Git 或 Agent 修改,不要求导入专有知识库。
界面用 Ely-GPUI-Components 的 Markdown 文档组件,全部原生 GPUI:全文只在一个源码编辑器里,可视模式按块渲染,按下的块以原文编辑;源码与预览共用同一份文本。解析用 markdown-rs,公式用 RaTeX,Mermaid 用 mermaid-rs-renderer。文本即文件原文,不经编辑模型序列化。[S14][S15][S16]
默认使用 Milkdown 承担可视化编辑,CodeMirror 6 承担源码编辑。两者接入同一个文件会话,只允许当前模式提交修改,不同时维护两份独立可写文档。Milkdown 的 Markdown 与编辑模型转换需要单独处理源码保留,不能把序列化输出默认视为无损原文。[S14][S15][S16]
参考 MarkText 的 Markdown 编辑行为、ColaMD 的本地文件同步和内容保留规则、Markion 的 GPUI 原生 Markdown 编辑,不复制其整个应用窗口。不用 Electron,不用 CEF 或其他网页内容宿主。[S17][S18]
解析、渲染、编辑命令与右键菜单以 Typora 1.14.10 的默认设置为对照,不输于它;可视模式仍是按下的块以原文编辑,不改为所见即所得的行内编辑(用户 2026-10-13 定)。Typora 是参照,不求一一相同:验收看每项功能可用,缺 Typora 的某项、次序或措辞不同不算缺陷(用户 2026-10-14 定)。[S25]
参考 MarkText 的 Markdown 编辑行为、ColaMD 的本地文件同步和内容保留规则,不复制其整个应用窗口,不把 Electron 作为 Ely 的桌面外壳。当前 ColaMD 仍包含 Milkdown 与 CodeMirror 相关依赖,但具体实现必须按选定提交核对,不凭包名推断编辑行为。[S17][S18]
### 14.2 功能要求
@@ -489,7 +485,7 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
| MARK-01 | 本地文件 | 新建、打开、保存、重命名和另存;支持直接打开文件或文件夹 |
| MARK-02 | 编辑模式 | 可视化、源码和只读预览可切换;切换本身不修改文件内容 |
| MARK-03 | Markdown | 支持 CommonMark 与 GFM 常用语法:标题、列表、任务、引用、链接、图片、表格和代码块 |
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;照 Typora 默认显示 `:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块与 `[toc]` 目录;`==`、`^`、单个 `~` 照 GFM,不作高亮、上下标;Typora 与 GitHub 读法不同处随 GitHub(表情名与转义、标签与强调交叉);资源随包提供 |
| MARK-04 | 扩展语法 | 支持脚注、数学公式、Mermaid 与 YAML front matter;资源随包提供 |
| MARK-05 | 导航 | 提供文件树、文档大纲、多标签、最近文件和按标题跳转 |
| MARK-06 | 编辑工具 | 支持查找替换、源码正则搜索、缩进、列表续写、表格行列操作和代码高亮 |
| MARK-07 | 附件 | 支持拖放和粘贴图片,默认复制到文档旁的资源目录并写入相对路径 |
@@ -500,8 +496,6 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
| MARK-12 | 会话恢复 | 恢复打开标签、未保存内容、光标、滚动和视图设置 |
| MARK-13 | Git 配合 | 在仓库内显示文件是否有改动,并可调用 ElyTree 查看;不内置第二套 Git 客户端 |
| MARK-14 | 大文件 | 大文件采用延迟渲染或源码视图,说明限制,不截断保存内容 |
| MARK-15 | 格式命令 | 段落与格式菜单照 Typora 的项与快捷键:标题与段落、引用与列表、插入块、行内格式与清除格式;每条命令一步撤销,用不了的地方说明原因 |
| MARK-16 | 右键菜单 | 右键处的块、链接、图片、表格或代码决定菜单内容,照 Typora 的布局(剪贴板与格式为图标行);预览只读时只留复制类 |
数学与 Mermaid 默认仅渲染文档中已保存的源代码,不执行任意本地命令。语法错误显示原文与错误位置,不清空代码块。
@@ -535,7 +529,7 @@ AI 或脚本修改文件与其他外部修改使用同一套处理规则。ElyMa
### 15.2 接入基线
后端以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP(用户 2026-10-05 定)。提供商登录(订阅账号或 API 密钥)、模型与端点配置沿用 Kigi 的实现,不另写一套。不解析终端装饰文本来伪造结构化事件。
后端以 Kigi CLI(https://github.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP(用户 2026-10-05 定)。提供商登录(订阅账号或 API 密钥)、模型与端点配置沿用 Kigi 的实现,不另写一套。不解析终端装饰文本来伪造结构化事件。
会话恢复只在 Kigi 的会话存储支持并通过验证时启用。不能把本地历史回放当成后端会话已经恢复。
@@ -611,6 +605,7 @@ ely-office/
ely-lok/ # LOK 接入;仅 Doc/Table/Show 使用
ely-pdf-engine/ # MuPDF 与 OCR 接入
ely-mail-engine/ # 邮件协议、同步和本地存储
ely-web-content/ # 本地 HTML 与 Markdown 内容宿主
assets/
tests/
packaging/
@@ -630,19 +625,23 @@ C/C++ 异常、原生错误与资源释放在 FFI 边界处理,不能跨 Rust
邮件同步、Git 命令、OCR、缩略图与索引构建在后台任务中执行,不阻塞 UI 事件循环。后台指应用运行期间的本地任务,不表示默认安装常驻系统服务。
### 17.3 内容渲染
### 17.3 本地 Web 内容宿主
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文按块的子集由 GPUI 绘制,写信用 Markdown 文档组件(§12.1)。
Mail 的 HTML 正文和富文本撰写、Mark 的编辑与预览需要成熟 Web 内容渲染。本 PRD 的默认实现采用 CEF 离屏内容区域,只在这两个应用引入。CEF 提供离屏画面和输入事件接口,适合接入自绘界面,但 GPUI 的实际接入仍须三端验证。[S22]
Ely 主题 token 用于内容区。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示。
CEF 只处理内容区域,不绘制应用标题栏、菜单、文件树和设置页面。不引入 Electron 应用外壳,不运行面向浏览器的本地文档服务器。CEF 必需的辅助进程随应用发行。
邮件阅读不运行正文脚本。Markdown 文档里的 HTML 不运行;数学与图只渲染保存的源码。外部文档内容不能调用任意本地命令。
Ely 主题 token 同步到内容区样式。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示,不出现框架默认主题。
邮件阅读环境禁用脚本及文件桥接。Markdown 编辑环境只运行随包代码,通过受限桥接访问当前用户打开的文件与资源。外部文档内容不能调用任意本地命令。
不同时维护 CEF、系统 WebView 和全原生 Markdown 三种路线。若 CEF 原型不能满足三端要求,先修订该接入决策并更新 PRD,不能在应用内部临时混用多套未验收宿主。
### 17.4 不建设的技术设施
不引入 Kubernetes、Docker 运行依赖、远程数据库、消息中间件、事件溯源系统、通用插件市场、统一知识图谱、分布式搜索或独立账号中心。
应用间不做 RPC 服务发现。Agent 不以独立后台服务器为前提。Git 不同时接入多个 Git 内核。Markdown 只有一个解析内核(markdown-rs)。新抽象必须服务于已有的重复需求。
应用间不做 RPC 服务发现。Agent 不以独立后台服务器为前提。Git 不同时接入多个 Git 内核。Markdown 不叠加第三个编辑内核。新抽象必须服务于已有的重复需求。
## 18. 数据、配置与隐私
@@ -736,7 +735,7 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
| PERF-08 | 资源释放 | 连续打开关闭 20 次后,活动文档对象与句柄回到基线,内存无持续单向增长 |
| PERF-09 | 多会话 | 4 个 Agent 会话同时流式输出时,其他会话选择和日志滚动仍满足交互目标 |
不提前宣传固定内存占用或比其他 Office 更快。发布报告列出单应用与内核子进程的总内存,不能只测 GPUI 主进程。
不提前宣传固定内存占用或比其他 Office 更快。发布报告列出单应用、内核子进程及内容宿主的总内存,不能只测 GPUI 主进程。
### 20.2 稳定性
@@ -768,8 +767,9 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
| --- | --- |
| MuPDF | 按锁定版本的 AGPL 条款提供对应源码、修改、集成和构建资料;不把公开 UI 仓库当成已完成合规 [S8][S9] |
| LibreOffice/LOK | 保留 MPL 2.0 及附带依赖的相应声明;发布对受约束文件的修改与所需资料 [S5] |
| MailCore 2 | 保留其许可、版权和免责条款,并检查其传递依赖 [S24] |
| Git | 作为独立程序分发并遵守其 GPLv2 等实际适用条款;提供所发行版本的源码取得方式 [S23] |
| Markdown | 保留 markdown-rs、RaTeX(含随带字体)、mermaid-rs-renderer 及其依赖的许可;逐项检查实际发行包 [S14][S15][S16] |
| Markdown 与内容宿主 | 保留 Milkdown、CodeMirror、CEF 及其依赖的通知;逐项检查实际发行包 [S14][S16][S22] |
| OCR、字体、词典和媒体组件 | 记录每个资源的来源、版本和再分发条件,不从系统目录随意拷贝受限资源 |
MuPDF 的 AGPL 路线允许符合条件的开源集成;“项目开源”本身不足以证明整个发行包已经符合条件。是否收费与源码义务分开处理。[S8][S9]
@@ -842,14 +842,14 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
| LOK 三端嵌入 | Doc/Table/Show 的编辑、保存、中文输入与窗口操作录像及日志 | 修复适配或更新设计决策;不能只交付查看器 |
| LOK 高级功能 | 母版、放映、图表、修订、输入法、打印和无障碍的接口映射及用例 | 补齐桥接或维护必要上游补丁,不跳回上游原生 UI |
| MuPDF 编辑 | 字体、对象修改、表单、删除及保存的样本结果 | 修复对象编辑与输出;不得改为视觉覆盖冒充编辑 |
| 邮件协议 crate | 三端 TLS、OAuth、IMAP 与 POP3 同步、SMTP 发送和 MIME 测试 | 修复或换 crate;换后端须显式修订选型 |
| Markdown 原生编辑 | 可视、源码、预览的输入、焦点、缩放、输入法和辅助技术测试 | 未通过不能以 CEF、WebView 或 Electron 代替 |
| MailCore 2 | 三端可重复构建、TLS、OAuth、同步和 MIME 测试 | 处理依赖及适配问题;替换后端必须显式修订选型 |
| 本地 Web 内容 | CEF 与 GPUI 的输入、焦点、缩放、弹层和辅助技术测试 | 接入未通过不能以独立 Electron 窗口代替 |
| Markdown 原文保留 | 无编辑保存、模式切换、局部修改和外部冲突的字节级差异 | 修复映射与保存;未知语法回到原文模式,不删除 |
| 邮箱服务注册 | 正式 OAuth 配置、适用审核状态与用户流程 | 不将未获许可的内置登录描述为可用 |
| Agent 后端差异 | 每个预设后端的版本、启动、控制、日志和恢复能力表 | 按真实能力显示,禁止模拟未提供的接口 |
| 打包与许可 | 三端依赖清单、源码对应关系、资源再分发检查 | 发行前解决,不以“整个项目开源”代替检查 |
选择版本时记录源码提交、编译选项和补丁。升级 LOK、MuPDF、GPUI 或 Markdown 解析与渲染库后运行相关回归测试。不要跟随默认分支自动升级正式构建。
选择版本时记录源码提交、编译选项和补丁。升级 LOK、MuPDF、GPUI、CEF 或编辑框架后运行相关回归测试。不要跟随默认分支自动升级正式构建。
## 24. 交付物与完成定义
@@ -870,10 +870,10 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
核对日期:2026-10-01。来源用于确认接口、项目结构和许可条件,不证明 Ely 已完成接入。产品功能、默认设置与性能目标由本文定义。正式实现应记录所采用版本的固定提交和许可证,而不是只引用默认分支。
[S1] Ely GPUI Components,README,组件库使用方式、主题及许可证。
`https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/README.md`
`https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/README.md`
[S2] Ely GPUI Components,TASKS,组件分类与 host/prove 等状态定义。
`https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/TASKS.md`
`https://github.com/ZacharyZhang-NY/Ely-GPUI-Components/blob/main/TASKS.md`
[S3] LibreOfficeKit 官方文档,C/C++ 接口、分块渲染和输入回调。
`https://docs.libreoffice.org/libreofficekit.html`
@@ -896,8 +896,8 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
[S9] GNU Affero General Public License 第 3 版原文。
`https://www.gnu.org/licenses/agpl-3.0.en.html`
[S10] Apple,iCloud 邮件用于其他邮件应用的服务器设置。
`https://support.apple.com/en-us/102525`
[S10] MailCore 2 官方介绍,协议、API 与平台范围。
`https://etpan.org/mailcore2.html`
[S11] Google,Gmail IMAP/POP/SMTP OAuth 2.0 与权限要求。
`https://developers.google.com/workspace/gmail/imap/xoauth2-protocol`
@@ -908,14 +908,14 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
[S13] Git 官方文档,git-status 与 porcelain 输出格式。
`https://git-scm.com/docs/git-status`
[S14] markdown-rs 官方仓库,CommonMark、GFM 与扩展语法解析,许可证。
`https://github.com/wooorm/markdown-rs`
[S14] Milkdown 官方仓库,框架结构与许可证。
`https://github.com/Milkdown/milkdown`
[S15] RaTeX 官方仓库,LaTeX 公式排版与许可证。
`https://github.com/erweixin/RaTeX`
[S15] Milkdown 官方 Transformer 文档,编辑模型与 Markdown AST 转换。
`https://milkdown.dev/docs/api/transformer`
[S16] mermaid-rs-renderer 官方仓库,Mermaid 图的 Rust 渲染与许可证。
`https://github.com/1jehuang/mermaid-rs-renderer`
[S16] CodeMirror 官方开发入口与仓库迁移说明;应用依赖按实际发布包锁定。
`https://github.com/codemirror/dev`
[S17] MarkText 官方仓库,编辑模式、格式能力与许可证。
`https://github.com/marktext/marktext`
@@ -933,8 +933,12 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
[S21] Tesseract 官方 API 示例与本地 OCR 接入。
`https://tesseract-ocr.github.io/tessdoc/APIExample.html`
[S22] Chromium Embedded Framework 官方说明与离屏渲染接入。
`https://github.com/chromiumembedded/cef`
`https://chromiumembedded.github.io/cef/general_usage.html`
[S23] Git 官方 COPYING 文件。
`https://raw.githubusercontent.com/git/git/master/COPYING`
[S25] Typora 官方快捷键说明;对照的程序为 Typora 1.14.10 Windows 安装包(本机 `TyporaRef`,不入库)。
`https://support.typora.io/Shortcut-Keys/`
[S24] MailCore 2 官方 LICENSE 文件。
`https://raw.githubusercontent.com/MailCore/mailcore2/master/LICENSE`
+3 -2
View File
@@ -9,7 +9,7 @@ Offline-first desktop apps in Rust and GPUI. Files stay in your folders; no acco
| ElyShow | Presentations (PPTX, ODP) | LibreOfficeKit, system media |
| ElyPDF | PDF reading and editing, OCR, signatures | MuPDF, Tesseract |
| ElyTree | Git client | Git |
| ElyMark | Markdown | markdown-rs, RaTeX, mermaid-rs-renderer |
| ElyMark | Markdown | Milkdown, CodeMirror in CEF |
| ElyMail, ElyAgent | Second release | — |
## Build (macOS)
@@ -20,7 +20,8 @@ scripts/fetch-references.sh # upstream sources at pinned commits
scripts/build-libreoffice.sh # patched LibreOffice 26.2.6.3 (about an hour)
scripts/build-mupdf.sh # patched MuPDF 1.28.5
scripts/build-git.sh && scripts/fetch-git-lfs.sh
scripts/fetch-tessdata.sh
scripts/fetch-tessdata.sh && scripts/fetch-cef.sh
scripts/build-mark-content.sh # ElyMark's page
cargo build -p ely-lok --bins -p ely-pdf-engine --bins
cargo run -p ely-doc # or ely-table, ely-show, ely-pdf, ely-tree, ely-mark
```
+60 -115
View File
@@ -14,33 +14,32 @@
| Doc / Table / Show | LibreOfficeKit,独立工作进程 |
| Show 媒体 | 各平台原生:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(LOK 不放媒体) |
| PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 |
| Mail | mail-send、mail-parser、mail-builder,IMAP(imap-proto 解析)与 POP3 由引擎自写(`crates/ely-mail-engine`,应用进程内的后台任务,无 worker)+ SQLite(FTS5);正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制;写信用 `documents::MarkdownDocument`,发出纯文本与 HTML;Gmail、Microsoft 走 OAuth,iCloud 走应用专用密码 |
| Mail | MailCore 2 + SQLite;正文用 CEF 离屏内容区 |
| Tree | Git CLI,参数数组启动 |
| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析与导出 HTML,RaTeX 公式,mermaid-rs-renderer 图;PDF 经 `ely-pdf-worker` 由 MuPDF 排版 |
| Mark | Milkdown + CodeMirror 6,CEF 离屏内容区 |
| Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 |
## 目录
```text
apps/ 八个应用,各自一个二进制
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端);ely-mail-engine(邮件协议、同步与本地存储)
apps/ 八个应用,各自一个二进制;ely-mark/web 是其网页源码(锁定版本,esbuild 构建)
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端)ely-web-content(CEF 离屏页面,ElyMark 与 ElyMail 共用);ely-mail-engine 随其任务建
reference/ 上游源码,只读,不属于本项目,不审查
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本;content/<应用> 是网页内容的构建产物
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
vendor/ crates.io 上游 crate 的副本加本项目的修复,经工作区 `[patch.crates-io]` 换入(gpui-pre、gpui-pre-windows);首个提交为原样,其后是修复
packaging/ 三端打包脚本,apps.tsv 是八个应用的二进制名、名字、桌面分类与发布批次;icons/ 由图标母版渲染各端格式;libreoffice/ely.xcd 是内核的默认配置层
assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns、png、ico,规则见其 README
dist/ 打包产物,不入库
docs/ 兼容清单、无障碍、帮助、许可、性能记录;engineering-risks.md、qa.md 等内部笔记只在本机,不入库
docs/ 工程风险与已知限制
scripts/ fetch-references.sh 取上游 doctor.sh 查工具 review.sh 开分栏审查 build-*.sh 构建引擎
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely;mail/sample.eml:带编码主题、分组抄送、纯文本加 HTML、cid 图片与附件的邮件,LF 行尾,测试换成 CRLF)
tests/ scripts 与 engines 下是脚本测试;engines 缺二进制时报 SKIP;fixtures 是测试共用的样本(media/clip.mp4:1 秒 H.264/AAC;pdf/aes128.pdf:AES-128 加密,打开密码 user、所有者密码 owner;pdf/signer.p12 与 signer.der:自签名测试证书,密码 ely)
tasks/ TASKS.md 进度真相
site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木;中英两种文案在 src/text.ts);site/video 是发布视频的分镜、配乐、录屏与合成脚本,产物在 site/video/out(不入库)
```
## 组件映射
依赖 `ely-gpui-component`(pin cd365e9,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 b0dbfd4,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
| 需要 | 用 |
| --- | --- |
@@ -55,7 +54,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
| Table | `tables::{PivotTable, FilterBuilder, SortBuilder}` `misc::{CsvImporter, ExportDialog}` `editor::{CompletionMenu, SignatureHelp, FindWidget}` `forms::{CheckboxGroup, ColorPalette}` |
| Mail | `mail::{MailboxList, MailList, MailThreadView, MailReader, MailComposer, SignatureEditor, ScheduleSend}` |
| Tree | `git::{ChangesList, CommitInput, CommitList, lanes, DiffViewer, ConflictResolver, BlameView, BranchList, TagList, StashList, FileHistory}` |
| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode, MarkdownReader, MarkdownBlock, alert, is_toc, BlockCommand, InlineFormat, TableEdit, EditRefusal, TableRefusal, DocumentTarget, TargetBlock}` `editor::{CodeEditor, FindWidget}`;导出与复制调用可视同一组读法;段落、格式与表格菜单经文档的 `block`、`format`、`edit_table`;右键菜单听 `DocumentEvent::Pointed` |
| Mark | `editor::{CodeEditor, FindWidget}` `documents::{MarkdownRenderer, ZenMode}` |
| Agent | `agent::{ToolCallCard, PermissionPrompt, ToolApprovalDialog, AgentStepList, FileChangeCard, MultiFileDiffReview, ArtifactPanel, CostBreakdown}` `chat::{MessageList, PromptInput, StreamingMarkdown, ConversationList}` |
| 动效 | `motion::{duration, Flip, Reorder, Transition, Stagger, ProgressBar, Spinner, Skeleton}` |
| 主题 | `theme::{Theme, Mode}`;Omarchy 色板由 `ely-ui` 映射成组件库的调色板 |
@@ -71,8 +70,12 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
| gpui 无障碍只有 `aria_value` 等,无文本选区与插入点:LOK 文档的光标与选区到不了辅助技术 | `LokView` 只桥接当前段落文本(`Role::MultilineTextInput`)。上游待办:gpui 补文本选区 AX |
| 选中对象的框与手柄:LOK 不画进图块 | `LokView` 按图形选区画框与 8 个手柄;按住手柄拖动经 `setGraphicSelection` 交内核(缩放;裁剪模式下裁剪) |
| 虚拟化 PDF 页面栈(`DocumentViewer` 一次铺全部页,不合 PERF-05) | `crates/ely-pdf-engine`、`apps/ely-pdf` |
| CEF 离屏内容视图 | `crates/ely-web-content` |
| CEF 页面的无障碍:离屏渲染的页面不进系统 AX 树(CEF 另给 AX 事件,GPUI 无从挂接) | 未桥接;页面文字在辅助技术里不可见。上游待办:gpui 可挂外部 AX 子树 |
| 组件库的大纲项(`lists::Outline`)与提示(`feedback::Alert`)无无障碍角色:屏幕阅读器读不到 | 上游待办;ElyMark 的搜索失败改为结果列表里的行。`feedback::Banner` 已修(fac20ab:危险与警告为 alert,其余为 status,名为其文字) |
| `documents::ZenMode` 把正文夹进 Sm 宽的列,与 ElyMark 的正文宽度设置冲突 | ElyMark 不用它,专注模式原生重做(C14.12) |
| `documents::ZenMode` 把正文夹进 Sm 宽的列,与 ElyMark 的正文宽度设置冲突 | ElyMark 专注模式自己收起标签条、左栏与状态栏,正文宽度由页面设置 |
| CEF 页面的弹出层(`select` 下拉、日期框)在离屏模式另画一层 | 不画;Ely 的页面不用原生 `select`,用自绘菜单 |
| macOS 的 CEF 离屏输入不带 AppKit 编辑命令:Blink 只凭它们处理 Cmd+方向键与 Cmd+Backspace | `ely-web-content` 的 `edit.rs`:页面没处理这些键时照 AppKit 标准键位执行 |
| 表格公式栏与名称框(网格是 LOK 图块,`tables::Spreadsheet` 不用) | `apps/ely-table` |
| Doc/Table/Show 工具栏(`documents::FixedFormatToolbar` 绑 `TextInput`,不接 LOK) | 各 app,用 `shell::Toolbar` 组合 |
| CSV 导入的编码、分隔符、引号、列类型(`misc::CsvImporter` 只读逗号 CSV、映射到属主字段,文案写死英文) | `apps/ely-table` 的 `text.rs`,内核按选项导入 |
@@ -80,11 +83,10 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
| 幻灯片缩略图条(`documents::PageThumbnailList` 只收图片路径,无隐藏标记、右键菜单与重排) | `apps/ely-show` 的 `slides.rs`,内核按页绘制到内存图 |
| PDF 页面缩略图(同上,且一次铺全部页,不合 PERF-05) | `apps/ely-pdf` 的 `side.rs`,`uniform_list` 只画可见行,worker 按需渲染 |
| 演示的参考线:LOK 不把参考线画进图块,也无参考线接口 | 补丁 0027 读写参考线;`apps/ely-show` 的 `guides.rs` 画在画布上,拖动移动,拖出幻灯片即删除 |
| gpui 文本里不能嵌图片:行内公式无从排版 | 组件库 `render/math_text.rs`:以不断行的空格占出公式的宽(整个公式作一个词折行),排好的公式画在其上,按下处读回公式的原文 |
| `A11yChecker` 的 `audit` 与对照表是私有的;Omarchy 色板要照同一套规则 | `ely-ui` 的 `omarchy/palette.rs` 抄了对照表。上游待办:公开 `audit` |
| 组件无 role/aria 语义:菜单、列表等控件在 AX 树仍不可见 | 上游已修核心控件(7380afb:Button、TextInput、Tabs、面板行、对话框等);其余上游待办。项目自绘控件已自带 |
| gpui 无 aria-disabled API(`AriaProperties` 无 disabled 字段) | 组件 7380afb 起以本地化 aria_description 表达;上游待办:gpui 补 API |
| 组件内部文案未接 i18n(Ely 用到的组件已接:提示、默认占位、补全空行、字号示例,6df9ab0;查找框、排序与筛选、密码开关,a4bf696;`i18n/tests/shipped.rs` 防回退;其余组件之外:欢迎页标题、菜单项等) | 面板族文案已接 i18n(7380afb,内建 en/zh-CN),`CommentSidebar` 与 `RelativeTime` 同(408f2b0);其余上游待办 |
| 组件内部文案未接 i18n(面板族之外:欢迎页标题、菜单项、`SortBuilder` 与 `FilterBuilder` 等) | 面板族文案已接 i18n(7380afb,内建 en/zh-CN),`CommentSidebar` 与 `RelativeTime` 同(408f2b0);其余上游待办 |
| LOK 的 `.uno:StyleApply` 状态给界面名(`Default Paragraph Style`),样式列表给程序名(`Standard`),两者对不上 | ElyDoc 属性栏:列表外的当前样式显示为占位文字;更新样式用状态里的界面名。上游待办:状态带程序名 |
## gpui 规则
@@ -121,66 +123,55 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
scripts/doctor.sh 工具链版本
scripts/fetch-references.sh 取上游源码到 reference/
scripts/fetch-tessdata.sh 取 OCR 语言数据到 engines/tessdata(钉提交,核对散列)
scripts/fetch-cef.sh 取 CEF minimal 包到 engines/cef(随 cef-rs 钉的版本,核对 SHA-1)
scripts/build-git.sh 构建钉住的 Git 到 engines/git(ElyTree 随包)
scripts/fetch-git-lfs.sh 取 git-lfs 放进 engines/git(核对散列)
scripts/review.sh ID 轮次 路径 审查一条任务(herdr 右侧分栏,codex gpt-6.1-sol,effort xhigh,fast)
scripts/review.sh ID 轮次 路径 审查一条任务
scripts/apply-lo-patches.sh engines/lo-src 找出源码已到补丁序列第几个,补上其余(构建脚本调用)
tests/scripts/lo-patches.sh 补丁序列从中途接着打、再打一次为空操作
tests/scripts/media-targets.sh 媒体播放接口在 Linux、Windows 目标可编译(缺目标时 SKIP)
tests/scripts/linux-idle.sh 真窗口 ElyTree:打开仓库后、Omarchy 换主题与改设置后,空闲时不读主题、设置,不跑 git status;照样跟随换主题、改设置与改文件,经内存映射的写入也跟随
tests/scripts/linux-backends.sh Linux 构建带 Wayland 与 X11;Hyprland 上 ElyTree 在两者上都映射出窗口、画出页面与起始页文字(截窗口量色,tesseract 读字)
packaging/linux/container.sh 命令 在 Ubuntu 26.04 镜像里运行(引擎、应用、打包;目标在 target/ubuntu)
tests/scripts/linux-glibc.sh Linux 的应用、worker 与引擎要求的 glibc 不高于 2.43
packaging/linux/package.sh 应用 出该应用的 AppImage、deb、rpm 与 Arch 包到 dist/(在容器里运行)
packaging/source.sh dist 本提交的源码包与随包的 copyleft 上游源码(打过补丁的 MuPDF、Git),文件名带提交号;工作区有未提交改动即拒绝(macOS 的 release.sh 与 CI 的 Linux 调用,发布只上传 Linux 的;Windows 的检出把符号链接写成文件,不出源码包)
tests/scripts/ci-share.sh ci.sh 只经指向 ELY_SHARE 的链接用共享引擎:自建目录、指向别处或目标带换行的链接即拒绝
tests/scripts/ci-upload.sh ci.sh 的上传:macOS 建源码包而不上传,Linux 上传,重跑遇已有的同名文件即失败、什么也不传(假 curl)
tests/scripts/linux-packages.sh deb、rpm、Arch 包在干净的 Ubuntu 26.04、Fedora 44、Arch 容器里装上,每个 ELF 都找得到库
tests/scripts/linux-pins.sh Linux 打包工具(appimagetool、linuxdeploy、运行时、nfpm)的下载与钉的散列相符
tests/scripts/linux-installed.sh installed|appimage Hyprland 上装好的六个应用或 AppImage:一个窗口、随 Omarchy(Catppuccin)、读出页面文字(PDF、Markdown 经各自引擎),ElyTree 随换主题到 Latte(grim 按窗口截取)
tests/scripts/linux-distros.sh deb 在 Ubuntu 26.04、rpm 在 Fedora 44 容器里装上,各应用在 Xvfb(Mesa 软件 Vulkan)下画出窗口、随 Omarchy、读出页面文字(与 linux-installed.sh 共用 linux-common.sh)
cargo test -p ely-show --test compose 切换半程帧经 Metal 取像素(主线程,仅 macOS)
tests/scripts/*.sh *.py 脚本测试(缺工具时报 SKIP)
packaging/macos/bundle.sh ely-doc debug 出 dist/ElyDoc.app(带图标、文档类型与该应用的引擎)
packaging/macos/release.sh [批次] 该批各应用的 release 包与 dmg,另有装全部的 ElyOffice dmg;ELY_SIGN_IDENTITY 加 ELY_NOTARY_PROFILE 即公证
packaging/windows/bundle.sh 由 dist 里各应用的安装程序出合集 ElyOffice 安装程序(先跑 installer.sh)
packaging/windows/templates.sh 以本构建的内核重写 packaging/windows/new 的空白 DOCX、XLSX、PPTX(右键“新建”用)
packaging/macos/release.sh [批次] 该批各应用的 release 包与 dmg;ELY_SIGN_IDENTITY 加 ELY_NOTARY_PROFILE 即公证
packaging/icons/masters.py 写出 assets/icons 的全部 SVG 母版(图标设计只改这里)
packaging/icons/build.sh 由 assets/icons 的母版渲染 icns、png、ico(只在 macOS)
tests/scripts/icons.sh 八个图标齐全、未过期,macOS 为 squircle,其余无底形
tests/scripts/release-macos.sh dist 里首批六个 .app:以访达的方式打开文件(启动与运行中),引擎都取自包内,带齐许可,不含系统会登记的内嵌包,每个二进制要求的系统不高于应用所写;源码包齐全
tests/engines/*.sh 引擎探测(LOK、MuPDF);LOK 探测本构建的内核,不挂载官方镜像
tests/scripts/hardened-macos.sh ELY_SIGN_IDENTITY 签名(hardened runtime)的 ElyMark 照样画出页面
tests/engines/*.sh 引擎探测(LOK、MuPDF、libetpan);LOK 探测本构建的内核,不挂载官方镜像
scripts/inert-lo.sh 路径.app 构建出的 LibreOffice.app 去掉文档类型、URL 类型、快速查看与聚焦插件并注销(build-libreoffice.sh 调用)
scripts/extract-official-lo.sh 用 7zz 把官方镜像的 Frameworks 与 Resources 解到 engines/lo-official(不挂载,无包,“未打补丁即拒绝”的单测用)
tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构建出的包不声明任何文件类型
tests/scripts/lokview.sh 真窗口里的 LokView:输入法、键入、点击、拖动,AX 树里的段落文本;ELY_CLIPBOARD_TEST=1 再测复制粘贴(会覆盖系统剪贴板)
tests/scripts/doc-shell.sh 真窗口 ElyDoc:空白标签进 AX 树,启动后 Cmd+N 打开新建对话框(需辅助功能权限)
tests/scripts/doc-props.sh 真窗口 ElyDoc 属性栏:段前段后连续键入、回车、失焦,读 AX 值与命令数(需辅助功能权限)
tests/scripts/doc-review.sh 真窗口 ElyDoc:无日期的批注逐条写明作者与原因(AX 可读),多条时批注与修订列表仍在窗口内(需辅助功能权限)
tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限)
tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
scripts/build-mark-content.sh 按锁文件构建 ElyMark 页面到 engines/content/ely-mark(不跑安装脚本)
tests/scripts/mark-content.sh 真窗口:构建的页面离线渲染各包(可视、预览、源码)
tests/scripts/mark-session.sh 真窗口 ElyMark:不改保存逐字节相同,键入一字只改该块(需辅助功能权限)
tests/scripts/mark-modes.sh 真窗口 ElyMark:文档旁图片、三种模式往返、预览只读、跨模式撤销、改名、新建(需辅助功能与屏幕录制权限)
tests/scripts/mark-places.sh 真窗口 ElyMark:文件夹树、大纲跳转、文件夹搜索、最近列表(需辅助功能权限)
tests/scripts/mark-output.sh 真窗口 ElyMark:专注模式与外观持久、四种导出、打印、Git 状态与打开 ElyTree、大文件(需辅助功能权限与 pdftotext;ELY_CLIPBOARD_TEST=1 再测富文本复制)
tests/scripts/mark-outside.sh 真窗口 ElyMark:外部替换、改名、删除、冲突比较、自动保存、退出与崩溃后的恢复(需辅助功能权限)
tests/scripts/mark-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、拖动内侧边缘变宽、宽度存入设置、重启后照旧(需辅助功能权限)
tests/scripts/storage.sh 真窗口 ElyMark:设置里的存储列出各类大小,日志当场清空,恢复副本下次启动前清掉,恢复点数写入设置(需辅助功能权限)
tests/scripts/storage.sh 真窗口 ElyMark:设置里的存储列出各类大小,日志当场清空,缓存下次启动前清掉,恢复点数写入设置(需辅助功能权限)
tests/scripts/recovery.sh 真窗口 ElyDoc:恢复点、崩溃后副本、连续两次崩溃、只杀引擎后重新打开(需辅助功能权限)
tests/scripts/pdf-recovery.sh 真窗口 ElyPDF:加锁文件的封存恢复点、空闲引擎退出后重开时再问密码(需辅助功能权限)
tests/scripts/autosave.sh 真窗口 ElyDoc:自动保存开关、停手即写、外部改动不覆盖(需辅助功能权限)
tests/scripts/print.sh 真窗口 ElyDoc:打印用文档自己的纸张,取消不动文档(需辅助功能权限)
cargo test -p ely-platform --test print Windows 经“Microsoft Print to PDF”出作业,核对纸张、方向与页面位置;Linux 起私有会话总线与替身打印门户,核对页面设置、令牌与交出的字节、取消(缺 dbus-daemon 时 SKIP)
tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住整块屏幕(菜单栏与程序坞之上,需辅助功能权限)
tests/scripts/dock-macos.sh dist 里 ElyDoc、ElyTable、ElyShow 与包外的 target/debug/ely-doc 各启动数次:LOK worker 从未登记为程序坞应用,图块照出
tests/scripts/windows-bundle.sh 合集安装程序逐个静默运行所选应用的安装程序;某个被拒即停下并报出其名与退出码(缺 makensis 时 SKIP)
tests/scripts/windows-installer.sh 假应用的安装程序:所选目录原有的文件经两次安装与卸载都在,旧版按注册表清理,文件被占用或程序在运行时什么也不改,写不进时放回旧版(缺 makensis 时 SKIP)
tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认
tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限)
tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用)
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:GitHub API 的最新版本与全部发布的下载总数,加上 Gitea 上迁移前的下载(Gitea 不通时只计 GitHub;都不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev
tests/scripts/webview.sh 真窗口 CEF 页面:输入法组字与提交、键入、焦点、主题变量、缩放、不可离开所在文件夹
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(只有静态资源),地址 https://ely-office.zhangyanghaha0407.workers.dev
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
cargo check --workspace
@@ -193,26 +184,27 @@ 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)、组件库 d5ecc5a(2026-10-11 起)。
- 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并推送(2026-10-09 起推到 git.zacharyzhang.com,HTTPS,令牌在本机钥匙串)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。
- 2026-10-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-06:审查者为探针复制的源码树须用自己的 `CARGO_TARGET_DIR`(`scripts/review.sh` 写明);写完结论前删掉这些副本与其构建目录(曾积到 /tmp 近 900 GB,磁盘写满、链接失败):曾编进本仓库 `target/`,ely-lok 里烘焙的源码路径指向已删的副本,单测找不到 worker。
- 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。2026-10-10(用户定):`--yolo`、`service_tier="fast"`、effort xhigh,每条最多五轮;herdr 直接启动 codex 程序,不经 shell 别名。2026-10-11(用户定):改回 effort high,每条最多三轮。
- 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`(另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定义删Line truncated
- 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
- 2026-10-04:LOK 命令与对话框。界面只经 `ely_lok::commands::COMMANDS` 发 UNO 命令,worker 拒绝表外命令;内核弹出的对话框(`LOK_CALLBACK_WINDOW` created 或 JSDialog)在回调里即刻关闭并经 `LokError` 报给应用,需要的对话框由 Ely 自建。起因:Calc 的 Backspace 弹同步模态“删除内容”,曾卡死 worker。
- 2026-10-04:ElyDoc 查找替换、拼写、比较、邮件合并(DOC-12 至 14)。查找经 `.uno:ExecuteSearch`:查全部计数并高亮,下一处按全部的矩形定当前序号;替换一处后重新计数,无当前项时“替换”先选下一处。拼写建议来自右键时内核给的上下文菜单,只取拼写部分,`ContextMenu::manual` 在插入点下打开。比较把当前文档存成工作副本、开成新标签、对它发 `.uno:CompareDocuments`,两个源文件都不动。邮件合并的字段在正文里写成 `«列名»`(同 Word 的显示,已定),预览与生成都是对模板副本整体替换;生成逐行写 DOCX 或 PDF 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。
- 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,交系统打印面板(`ely_platform::print`,三端见 R7.6)。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
- 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,macOS 用 PDFKit 的 `NSPrintOperation` 弹系统打印面板(`ely_platform::print`),其他平台报错,未实现。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。
- 2026-10-04:ElyDoc 的标签、会话、打开、保存、另存为(按文件身份比对)、关闭询问、冲突、密码与标签条移入 `crates/ely-office`:应用实现 `Host`(格式表 `Kind`、每标签附加数据、内核回报钩子),共用逻辑以扩展 trait 提供(`use ely_office::prelude::*`)。ElyTable 用同一外壳。共用文案在 `office.*`。
- 2026-10-04:ElyTable(C09.1)。新建为空白 XLSX(首个工作表名随界面语言),保存保留 xlsx/ods/xls/fods,另存为 XLSX(默认)或 ODS,xlsm/xltm 进风险模式。上方名称框与公式栏(回车分别发 `.uno:GoToCell` 与 `.uno:EnterString`,Esc 交回网格),中间行列表头按内核几何自绘(点选整行整列,拖边改尺寸),下方工作表标签(右键与“工作表”菜单:插入、重命名、复制、左右移、隐藏、显示、删除;删除前列出引用该表的公式数与命名区域数)与状态栏(保存状态、内核给的选区统计)。“单元格”菜单:插删行列与单元格、合并拆分(多个数据格时先问移入或保留隐藏)、行高列宽(厘米)与最适、隐藏显示行列、冻结。填充柄拖到目标格发 `.uno:AutoFill`。选区统计文字由内核给出,当前为英文(内核界面语言未随 Ely 切换),待处理。
- 2026-10-04:ElyTable 公式、计算、名称(C09.2,TABLE-03 至 06)。公式语法为 Excel A1(用户定):`Sheet2!A1`、逗号分隔参数,由安装层 `packaging/libreoffice/ely.xcd` 设定;载入选项 `Language=en-US,DeviceFormFactor=desktop`,内核界面与数字区域都是 en-US(小数点、月/日/年输入)。单元格内编辑由内核完成:键入时内核给出匹配的函数(`editor::CompletionMenu` 显示在单元格下,上下键选,Tab 补全,经 `completeFunction` 入有序队列),参数提示(`editor::SignatureHelp`,取内核的用法文字),点选单元格插入引用并按内核颜色画框;内核只为点选的引用与再次编辑(F2)的已有公式画框,新键入的引用不画(同桌面版)。第一下 Esc 关提示,第二下取消(同内核)。语法错误的公式照原样输入,状态栏按界面语言解释当前格的错误(内核英文错误串换成界面文案,同统计);循环引用同此提示。“公式”菜单:自动计算开关(关时状态栏显示手动计算)、重新计算、全部重新计算、定义名称(名称、引用位置默认为所选区域的绝对引用、范围为工作簿或某表;名称先经内核判定)、名称列表(改引用、转到所命名的绝对区域、删除,失效引用标出;内核给出每个名称的区域,`.uno:SelectedRange` 给所选区域)、追踪错误、追踪引用与从属单元格、移去箭头。名称框可直接输入名称跳转,跳转后显示该名称。手动计算在 ods、xlsx、xls、fods 存后重开都保持。
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案 a4bf696 起接 i18n(原为英文)。
- 2026-10-04:ElyTable 格式、数据整理、规则(C09.3,TABLE-07 至 09)。补丁 `0016-lok-calc-formats-and-conditions.patch`(数字格式、条件格式、两处 sdi 参数)、`0017-lok-calc-filter-validation-columns.patch`(筛选、验证、分列)、`0018-lok-calc-reads-requests-search.patch`(读值、请求、查找全部):数字格式按代码设(`.uno:NumberFormat` 的 `FormatCode`),`.uno:NumberFormatCode` 读当前格代码与类别(常规按区域块首判定)或校验代码;标准筛选收 JSON 条件(`.uno:DataFilterStandardFilter` 的 `Query`,含按列取值),`.uno:AutoFilterValues` 照内核自己的筛选菜单给出条目、他列隐藏与本列勾选;数据验证收 JSON 规则(`.uno:Validation` 的 `Rule`),`.uno:Validation` 读当前格规则,`.uno:DataSelectValues` 给序列;条件格式收 JSON(单元格规则配内核单元格样式、色阶、数据条、按键删除,`cellsh.sdi` 的同名槽也加参数),`.uno:ConditionalFormats` 列出本表;分列收 `Options`(ScAsciiOptions 串);`.uno:SelectedRange` 另给所选区域内数据区的四角、首行文字与内核的标题判定。LOK 下内核不再弹自己的筛选菜单与序列下拉,改发 `LOK_CALLBACK_TOOLTIP`(`autofilter`、`datalist`,带列行),无效输入发 `validityerror`(标题、内容、样式、是否接受;接受与否取内核对话框的默认:信息接受,警告与停止拒绝),Ely 以 `ely_lok::Request` 接收后自建菜单与提示。查找全部按格报矩形(内核原把相邻命中并成一块,计数偏少)。查找替换移入 `ely-office`(`Finding`,ElyDoc 与 ElyTable 共用,`Host::search_leaves` 供 ElyDoc 记返回位置)。“粘贴为值/公式”只在系统剪贴板仍是本视图上次的复制时进行(内核自己的单元格剪贴板;Ely 复制的 HTML 末尾带一个进程内唯一的标记注释,以标记而不是文字识别,同样文字的外来复制不算);其他内容(含别的工作簿复制的单元格)明确拒绝并提示改用粘贴:内核无法从外来内容单取值或公式。排序最多三个关键字(内核限制)。右侧格式面板(数字、对齐、边框、填充)、“数据”菜单(排序、筛选、删除重复项、分列、查找替换、粘贴为值或公式、数据验证)、“格式”菜单(格式面板开关、条件格式)。组件库 `SortBuilder`、`FilterBuilder` 的文案未接 i18n,中文界面下仍为英文(上游待办)。
- 2026-10-04:右侧属性栏宽取 `theme.sheet_size()`(380),左栏仍是 `sidebar_width`。`PropertyGrid` 名称列固定 120,240 宽时编辑器只剩约 76。颜色经色块弹出 `ColorPalette`,一次点击一条命令。
- 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-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-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-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(用户定),2026-10-18 改(用户定):Doc 页面、Table 单元格、Show 幻灯片、PDF 页面在任何外观下都照文件原色(白底即白底),只有页面四周的界面随深浅色;邮件正文、Mark 编辑区、Agent 输出随系统深浅色。LOK:`ely.xcd` 只有 `COLOR_SCHEME_ELY_LIGHT`(文档底色白、自动文字色黑、网格、链接等);方案按视图记(LOK 下全局改动只作用于当前视图),每个 `LokView` 首帧经有序队列发一次 `.uno:ChangeTheme`(`ely_lok::SCHEME`,不算编辑),ElyShow 放映与输出的副本同样各发一次;旧版写进内核配置的 `COLOR_SCHEME_ELY_DARK` 已删,内核读不到即退回自动方案,首帧的那次改回 Ely 方案。页面底色 `ely_lok::paper()` 为白,同方案的 `DocColor`。MuPDF 照原样画,不反相。图块作废时旧图仍显示,新图到达后替换,编辑不闪。
- 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 实测仍为白底。图块作废时旧图仍显示,新图到达后替换,编辑与主题切换都不闪。
- 2026-10-01:主题。`Appearance` 为系统、浅色、深色。系统档在有 Omarchy 时跟随 Omarchy(`ThemeSettings::omarchy_sync`)。读 Omarchy 的 `$HOME/.local/state/omarchy/current/theme/colors.toml`(Omarchy 写死此路径,不随 `XDG_STATE_HOME`),目录由 `ELY_OMARCHY_DIR` 可改。读法与回退链照 `reference/omarchy/bin/omarchy-theme-color`:松散引号、`colorN`、`purple`、`selection_background`、`theme_type`、`light.mode`、亮度和 382 比。缺的色相用 Ely 自己的色并警告。色板照组件库 `A11yChecker` 的规则收口:正文、状态色、代码色在各自的表面上 4.5:1,焦点与图表色在页面上 3:1;先拉近前景再拉到纯黑白,最后逐项核对,核对不过的主题整套忽略。切换时 Omarchy 先暂存 `next-theme` 再整体替换 `theme`,用文件监视跟随,不用它的钩子。新主题无 `colors.toml` 时退回 Ely 色板。`ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION` 是覆盖开关,设置页接入前先用环境变量。
- 2026-10-05:组件库 8658999(组件库重写历史后的 main,加本项目三个提交):属主不经 `Close` 移除的浮层(一起移除的嵌套浮层同样,回到最外层打开前之处)(对话框动作只清自身状态、异步校验通过后才关)照样把焦点交回打开前之处;原先焦点落在根上,键入无处可去,须先点一下网格。同批带入上游的 ColumnShell、输入法组字时光标重新闪烁、`Icon::from_path`、MIT 许可。旧 pin 2f02c82 已不在 main 上,只能按完整哈希取得。`Select::label` 与 `TextInput::label` 给控件无障碍名称而不绘制;对话框里描述列表旁的控件以此命名,开关用自身的 `label`。7186179 加 `NumberInput::label`(经内部文本框的 `TextInput::set_label`),同批带入上游的 rustfmt 修正。80c46b1:`TemplatePicker` 的列表是一个 Tab 停靠点,上下、Home、End 移动,回车使用,行带列表项语义,搜索提示、全部、使用、无匹配的文案进内建中英目录。7df3045:`SelectableList` 在 AX 树里是列表(`label` 命名),`ListItem` 是列表项(标题为名,带说明、选中、在可选列表中的序号与总数);原先行无角色,不在 AX 树里。af54877:`NumberInput::blank` 显示占位文字而非数字(混合选区),空着回车或失焦不提交,键入后照常。408f2b0:`CommentSidebar` 与 `RelativeTime` 的文案进内建中英目录;属主不处理“解决”时(无 `on_resolve`)不分未解决与已解决,一列显示全部。bcf0378:`CommentSidebar` 的批注列是列表,每条是 Tab 停靠点(列表项语义,名为作者与正文,带选中、序号与总数),回车或空格选中。71bfc93:`init_for_tests`(`test-support` 下)装主题与键位、不要资源,应用单测可渲染 Ely 组件。617a8d6:`FindWidget` 在查询框里按 Esc 即关闭(调 `on_close`)。2ce3368:`ContextMenu` 可定宿主大小。3d08744:`DescriptionList` 的值占满所在列,其中的文本框随之全宽;同批带入上游的图表、分页改动。a6b1bb0:`SignaturePad` 的“在此签名”与“清除”进内建中英目录;同批带入上游 P01 至 P15(各组件的越界与失效状态记日志而不 panic)。490b022:`ChangesList` 分列冲突、已暂存、改动、未跟踪、已忽略(各有其动作:冲突与已忽略只可打开),标题与提示进内建中英目录;同批带入上游 P16 至 P20。7bb53f4(ElyTree 用):`ChangesList` 行数可缺、按区报按下、当前行;`DiffViewer` 外算的段、块按钮、可选的变动行、逐行读给辅助技术;`CommitList`、`FileHistory`、`BlameView`、`ThreeWayMerge` 只画可见行,`CommitList` 近尾加载;`DescriptionList::text`;`SegmentedControl` 各段为单选;`BranchList`、`TagList` 可选中、只画属主处理的行内动作;`marked`、`conflicts`、`resolve` 按标记宽度(`MARKER` 为默认 7),a284c71 起只按 LF 断行;c5799cc:`StashList` 文案进内建中英目录,行可选(`selected`、`on_pick`),只画属主处理的行内动作;7fab072 至 3741d5d:分支、标签、储藏的行分两行(上行名称与行内动作,下行说明与行尾文字),窄时名称与说明先让;行内动作一律显示(原先只在悬停时显示,键盘停在其上时看不见),改动列表同;各 git 行带透明的 1 像素边框,焦点框才画得出(原先 `focus_ring` 无边框宽度,键盘焦点不可见);同批带入上游 P21 至 P29。4f8f3bd:`Changed::lines` 可缺(二进制文件、文件夹不计行数,不显示);37c5187 缩短其注释。19b0b29:`DiffViewer::from_stretches`(别处算好的差异,如 Git 自己的)、`hunk_action`(每块标题上一个按钮)、`selected` 与 `on_line`(改动行可按,带 Shift),`diff::mark_words` 给外来的行标出改动的词,“行内”“并排”“N 行未改动”进内建中英目录;30fea9e:有块按钮时各行同高,底色相接。edd2c12:`ChangesList` 每次按下带分区(`ChangeSection`),行是 Tab 停靠点(列表项语义),`selected` 标出当前文件。2e080b8:`SegmentedControl` 每段是单选按钮(名为其文字,带选中状态),原先不在 AX 树里。e226d79:差异查看器的行模型与配色移入 `git/rows.rs`(文件不超过 500 行);7b807a9 缩短其注释。51d2aea:`CommitList`、`FileHistory`、`BlameView` 只画视口内的行(`uniform_list`,填满所在的框;`CommitList` 收 `Rc` 的提交与 `lanes` 算好的图,`on_end` 在接近末尾时要下一页),各行是 Tab 停靠点(列表项语义,选中状态;Blame 每段首行可按,名为作者、时间与摘要),`FileHistory` 的行数可缺;`CommitInput` 的“提交到”“修改最近提交”进内建中英目录。e6220ad 与 3fc5a46:提交行窄时作者、提交号与时Line truncated
- 2026-10-05:ElyTable 透视表与图表(C09.4,TABLE-10、11)。补丁 `0019-lok-calc-pivot-tables.patch` 与 `0020-lok-calc-charts.patch`:`.uno:DataPilotExec` 收 `Pivot`(JSON:源区域、行、列、筛选、值字段与函数 sum/count/average/max/min),光标在透视表上即原地修改,否则从源区域在新工作表创建;行、列、筛选里的空名表示内核的数据布局字段(多个值字段横排或竖排的位置),读出与写回都保留,已在表中的行、列、筛选字段保留各自的小计函数,读出的布局原样写回不改表;照内核布局对话框的步骤(`ConvertOrientation`,保留各字段的成员、排序与版式),新表的筛选按钮、钻取、展开收起都关(同对话框初值);放到新工作表时插表与透视表是两步撤销(同桌面版)。刷新 `.uno:RecalcPivotTable` 在内核里本就不进撤销(它改缓存)。`.uno:InsertObjectChart` 收 `Chart`(JSON:类型 column/bar/line/scatter/pie、标题、图例、X/Y 轴标题;`other` 保持原类型),LOK 下不开图表向导、插入为一步撤销、插入后图表选中;给出的区域每段都要解析得了、所指工作表都要存在,否则插入与修改都拒绝、文档不动(内核原会改用示例数据,修改时 `ChartModel::setArguments` 抛出未捕获的异常使 worker 退出);`.uno:ChangeChartData` 也收 `Chart`,区域与外观合成一步撤销(撤销保存整个图表的图示与主标题的副本,堆积等外观以外的设置一并恢复)(列表动作带当前视图号,否则 LOK 拒绝撤销别的视图的动作)。读取:`.uno:Chart`(选中图表的对象名、区域、表头、外观)、`.uno:PivotTable`(光标处透视表的名称、位置、源区域、字段与布局),`.uno:SelectedRange` 加 `data`(选区内数据的绝对区域)。界面在“插入”菜单:数据透视表、刷新数据透视表、图表;对话框在光标处有透视表、或有选中图表时为修改,否则以选中的数据(单格扩到数据区)新建,源与图表区域取 `data`,不含空行。为对话框发起的读取(`Tabs::ask_in`)回来时,发起的工作簿已不是当前标签即丢弃,不为隐藏的工作簿开对话框(ElyTable 全部对话框、合并询问)。没有数据时恢复原光标(`select.data` 会把单格标成区域,回车随之只在格内打转)。公式语法是 Excel A1,区域写 `Sheet1!$A$1:$C$5`。
@@ -226,12 +218,13 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-06:ElyShow 输出与演示检查(C10.6,SHOW-13、14,QA-12)。补丁 `0037-pdf-impress-handouts.patch`:PDF 导出的 `ExportHandouts`(每页张数 1、2、3、4、6、9)照打印的讲义版式(`SdPage::CalculateHandoutAreas`,3 张带笔记线)把 `PageRange` 内未隐藏的幻灯片排到讲义页上;其他张数不出页,导出失败。讲义版式会改文档的讲义页,所以讲义与图片都在另存的 ODP 副本上做(`output::on_copy`,做完关闭并删除)。`Pdf::handouts` 经 worker 的 `EXPORT_PDF` 传入。导出对话框:内容 PDF、图片、讲义,范围全部、当前、从–到,讲义每页张数(默认 3);打印对话框:幻灯片或讲义,生成同一 PDF 后交系统打印面板。图片每张未隐藏的幻灯片一个 PNG,宽 1920,副本设浅色方案,文件名 `名称-序号.png`,所选文件夹里任一目标已存在即整体不写。PPTX/ODP 由保存与另存为负责。补丁 `0038-lok-impress-slide-check.patch`:读 `.uno:SlideCheck`(每张幻灯片与母版:文字所用字体族、链接图片与 OLE 链接及是否缺失、内核不能显示的对象:读不出的图片、无数据的 OLE;缺失的链接图片只作为链接报一次)。演示检查以内核报的缺失字体(`FONTS_MISSING`)为准,按所用字体定位到幻灯片或母版;媒体缺失与媒体链接取自 `.uno:SlideShow`;点一行转到所在第一张幻灯片。系统打印面板只经 `ely_office::print_later` 打开:它在前台任务里、GPUI 放开应用之后运行(面板的模态循环会回调 GPUI,在实体更新里打开即借用冲突崩溃),三个应用共用。图片输出先以 `create_new` 占下全部目标(不跟随符号链接),任一冲突或失败都删去本次占下的文件。输出与检查的对话框由后台读取打开,回来时须仍是最近一次请求(`Deck::ask_dialog`、`dialog_due`)且没有别的对话框;对话框种类或当前标签一有变化,此前的请求即作废(`watch_dialogs`,随文档通知)。图片的张数与隐藏取自写出的副本本身;PNG 写完显式 flush。读不出的图片按载入后的状态判定(`NONE` 或解码失败的 `Default` 占位);OLE 链接检查源文件是否在。
- 2026-10-06(用户定):ElyPDF 签名与权限。验证签名时证书信任取操作系统的信任库(macOS 钥匙串的信任设置;Windows、Linux 随其构建)。可视签名三种来源:手写、键入姓名、图片文件;可视签名不是数字签名。只凭用户密码打开、带所有者限制的文件照限制办(打印、复制、编辑等被拦并说明原因),输入所有者密码可解除;改保护须所有者权限。
- 2026-10-06(用户定):OCR 随包语言数据用 tessdata_fast,英文、简体中文、繁体中文(eng、chi_sim、chi_tra)。
- 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;邮件编号依次前移。
- 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;CEF 内容宿主随 ElyMark 先建(C14.0),邮件编号依次前移。
- 2026-10-06(用户定):`ely-office` 的外壳不再只服务 LOK:`Host` 带关联类型 `Engine`(`ely_office::Engine`:后台载入、视图、撤销历史的标记、写出、关闭,视图的落定、编辑计数、待落地、焦点与事件),`Tab` 与 `Core` 按引擎取文档、视图与 worker 类型。LOK 的实现在 `ely_office::lok`(`Lok`、`start`、只对 LOK 的 `Commands` 即 `run_active`/`run_in`;查找替换、面板、图表仍只限 LOK);MuPDF 的在 `apps/ely-pdf/src/engine.rs`(`Pdf`;worker 首次载入才起,`Mupdf::get`)。`Kind::macros` 为 `None` 表示该格式无宏,不扫文件。ElyPDF 每个标签一个 `PdfView` 实体(页面、图块、文字、选择、找到的词都在里面,自己绘制),`Viewer` 只管标签条、书签栏、查找框与状态栏;打开、密码、关闭询问与保存走外壳。外壳的密码框:对话框开着时后到的加锁文件排队(`Core::locked`),对话框关闭(含取消、提交)后下一个再问;每次询问一个身份(按输入框实体),连续询问的 Esc 各归各的;提交时对话框先关、再载入。ElyDoc 的加锁测试原先清的是应用自己的对话框字段,改为清外壳的。
- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1)。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。
- 2026-10-07:本地 Web 内容宿主(C14.0,§17.3)。`crates/ely-web-content`:CEF 154.0.32(Chromium 154.0.8037.58,cef-rs a712ad9,`default-features = false`),`scripts/fetch-cef.sh` 取 minimal 包到 `engines/cef`,`.cargo/config.toml` 的 `CEF_PATH` 让构建用它。进程:应用的 `main` 先调 `ely_web_content::subprocess()`(参数里有 `--type=` 即为 CEF 子进程,载入框架后 `execute_process`,不碰 GPUI);开发构建子进程即应用本身的二进制;macOS 包里是五个辅助应用(`<二进制名> Helper`、`(GPU)`、`(Renderer)`、`(Plugin)`、`(Alerts)`,`Contents/Frameworks`,`LSUIElement`,可执行文件为 `ely-web-helper`),框架在 `Contents/Frameworks`,`packaging/macos/bundle.sh` 对依赖 ely-web-content 的应用自动放入并先签内层;查找次序 `ELY_CEF`、包内、辅助应用所在、构建时的 `engines/cef`。启动:`start` 在 GPUI 里只做准备(载入框架、给 NSApp 的类补上 Chromium 要的 `isHandlingSendEvent`、`setHandlingSendEvent:` 与包住 `sendEvent:`,并加 `CrAppProtocol`、`CrAppControlProtocol`),`initialize` 与建浏览器都在消息泵任务里、GPUI 的更新之外进行(实测:在更新里同步调 CEF,CEF 转动 AppKit 的循环,GPUI 报 RefCell 已借用、整窗不画);浏览器异步建(`on_after_created` 交回)。凡在 GPUI 里对 CEF 的调用(输入、焦点、尺寸、脚本、缩放、重新载入、关闭)都排进主线程的队列,由泵在下一轮、更新之外依次执行(实测:在实体释放时同步关浏览器,CEF 转动 AppKit 的循环去校验菜单,GPUI 的菜单回调遇已借用的应用状态而中止,约三成复现)。泵:外部消息泵,按 `on_schedule_message_pump_work` 的时间,另照 cefclient 每轮后以 1/30 秒兜底(实测:只按请求时第一轮后 CEF 再不请求,页面不载入);空闲时 CPU 约 1.5%。离屏渲染须用 Alloy 风格(`runtime_style`;Chrome 风格不出画面)。同一缓存目录只允许一个实例,第二个启动时报出占用它的进程号。画面:`on_paint` 的 BGRA 直接成 GPUI 图像,上一帧在下一次绘制时从图集释放(`drop_image`);光标随页面。输入:鼠标点击、移动、滚轮(行按 40 点),键照 cefclient 的 macOS 做法:键盘按下带字符与系统键码发 `KEYDOWN` 再发 `CHAR`(退格、回车、方向等用 AppKit 的字符),可打出字的键等 GPUI 的输入法回调后以所打的字发同样两个事件,多字或组字经 `ime_commit_text`/`ime_set_composition`(无替换范围时用 CEF 的“无范围”,两端为最大值;传空指针 CEF 不收);松开带同一字符(不带时 Chromium 当作又按一次,实测退格删两个字);拷贝、剪切、粘贴、全选走框架命令。焦点随 GPUI 的焦点给页面。页面与应用之间:CEF 的消息路由,页面调 `window.elyQuery({request, onSuccess, onFailure})`(`onSuccess` 必须有,缺了路由不发),应用收到 `Event::Query(请求, 回复)`;应用以 `run_js` 调页面。主题:每次载入与主题变化时把调色板、代码色、字体与字号比例、深浅写成 `:root` 的 `--ely-*` 变量与 `color-scheme`。缩放 `set_zoom(倍数)`。页面只许载入所在内容文件夹里的文件:按真实路径判定(`..`、百分号转义、指向外面的符号链接都算外面,文件须存在),导航与每个资源请求(脚本、图片、样式、请求等)都过这道门,资源另许 `data:` 与本页的 `blob:`,其余一律取消;另在每个 CEF 进程以 `--proxy-server=127.0.0.1:9` 与 `--proxy-bypass-list=<-loopback>` 把一切网络(含本机回环与 WebSocket,后者不经资源回调)送往不通的代理;WebRTC 走 UDP 不经代理,故另以 `--force-webrtc-ip-handling-policy=disable_non_proxied_udp` 只许它经代理,并在每个页面上下文建立时、页面脚本之前删去 `RTCPeerConnection` 等构造(渲染进程的 `on_context_created`);页面的地址由真实路径逐字节转义生成(文件名含 `#`、`?` 照样载入);用户点出的链接以 `Event::Leave` 交给应用;页面的警告与错误写入日志。载入失败(主框架,停止不算)、渲染进程退出、CEF 起不来都报 `Event::Failed`,页面区改显示组件库的错误视图与“重新载入”(浏览器在即重载,否则重建;CEF 起不来时只说明),载入成功才报 `Event::Loaded`。浏览器关闭时断开它与表面的互相持有,页面的内存随之释放(释放时写日志)。真窗口(macOS,开发构建与 `dist/ElyMark.app`):页面以应用深色主题画出,点选、键入中英文、回车、退格、方向键、拷贝粘贴、`elyQuery` 往返;`tests/scripts/webview.sh` 以输入法回调组“你好”、键入、读回焦点、主题变量与缩放 1.25,核对页面跳不出Line truncated
- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1);状态栏显示所用 Git 的路径与版本。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。
- 2026-10-07:ElyTree 储藏、远程、工作树、子模块、LFS(C13.5,TREE-10 至 12,§13.3)。左栏第四页“仓库”:上方取回(`fetch --progress --all`)、拉取(`pull --progress`,照用户的 pull 设置)、推送、强制推送…;储藏(`stash list -z`,新的在前;“储藏改动…”问说明与是否包括未跟踪的文件,`stash push`),远程(名称与地址,`remote` 加 `config --get-regexp` 读出取回与推送地址),工作树(`worktree list --porcelain -z`,行名为文件夹名,有失效的可“清理”即 `worktree prune`),子模块(索引里的 gitlink 与文件夹自身 `.git` 的 HEAD:未检出、在记录的提交上、在别的提交上、冲突),LFS 文件(`git lfs ls-files --long --json`:工作树里是内容、对象在本机而工作树是指针、只有指针而对象不在本机);没有子模块或 LFS 文件时不列。各列表分别读出,一个读不出只在该处显示 Git 原话;切到此页时读,此后每次状态重读时重读,只取最新一次。选中一项,中间显示:储藏为提交详情的同一套(`hist::Picked`,历史与储藏共用读取与面板):改动的文件(对第一个父提交)与储藏的未跟踪文件(第三个父提交,各自对空树),选文件看差异,上方应用、弹出、删除…;远程为地址与推送地址,取回、修改…(改名 `remote rename`、改地址 `remote set-url`)、移除…(先问);工作树为路径与分支或提交、锁定与失效原因,打开(作为标签)、移除…(先问,主工作树与当前的不给);子模块为记录的与检出的提交,“检出记录的提交”(`submodule update --init`,经远程运行)与打开。用户输入的名称、地址、路径前一律加 `--`(推送的远程名同样)。表单与选择对话框(新建分支、标签、改名、身份、储藏、远程、工作树、选远程与上游)记下打开时的仓库,提交时写进那个仓库,不随当前标签变;储藏列表按每条三个字段读(说明可为空)。储藏的应用、弹出、删除按 `stash@{n}` 执行,执行前在写队列里核对它仍是所见的那个储藏(另有储藏插入即拒绝,什么也不做);弹出遇冲突时 Git 保留储藏,对话框显示 Git 原话。删除储藏、移除远程与工作树每次都问。分支页的本地分支多出“设置上游…”(列出远程分支,`branch --set-upstream-to=refs/remotes/名`)与“取消上游”。推送:有上游即 `push --progress`;没有上游时后台读出远程,问推到哪个远程(`push --set-upstream -- 远程 refs/heads/分支:refs/heads/分支`),没有远程或 HEAD 游离即说明。强制推送(§13.3):后台读出当前分支、上游的远程与远端分支、上次取回时远端的提交(租约)、此刻 HEAD 的提交(源)与远端有而源没有的提交,对话框写明远程、分支与将被覆盖的提交(或写明没有),确认后推送的就是所示的源提交(`push --force-with-lease=远端分支:租约提交 -- 远程 源提交:远端分支`),确认前本地分支再动也不改变推送内容;远端自上次取回后变了即被拒绝;没有上游或游离即说明不能。远程命令(取回、拉取、推送、子模块更新)是写队列里的一种(`Write::Remote`),经 `running::Running` 运行:Git 在自己的会话里(`setsid`,SSH 找不到终端,不会挂在终端提示上;凭据照用 credential helper 与 ssh-agent),进度逐行显示在中间上方的提示条,可停止(向整个进程组发 SIGTERM,Git 自行清理;已结束的不再发);用户停止时提示“已停止”、清掉其后的排队写,不当作失败;失败时对话框显示 Git 的 stdout 与 stderr(以回车结尾的进度行被下一行改写,只留最后一次;以换行结尾的行全部保留,钩子的每一行都在);拉取遇冲突照合并类命令判定为停下。关窗与退出时停下所有远程命令与克隆。克隆改用同一运行器。真窗口(macOS):仓库页五类列表、储藏详情与文件、工作树详情、分支行。未验证:Windows 与 Linux(停止远程命令在非 Unix 上报未构建);真实网络与 HTTPS、SSH 凭据;LFS 对象的下载(只显示状态)。
- 2026-10-07(用户定):跟随组件库最新的 main。组件库 K01 把 gpui 从 zed 仓库的提交换成 crates.io 的 `gpui-pre` =0.3.8(`gpui-pre-platform` 同版本),K02 让 Tab 绑在 FocusScope 的 ElyFocus 上下文下、更深的上下文先得;本项目工作区的 `gpui`、`gpui_platform` 随之改为这两个包(以 `package =` 保留原名,源码不变),组件库 pin 到 dabc1eb(其上加本项目一个提交:树与树行的无障碍角色)。升级后全工作区编译无改动。
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git(2026-10-15 起 Windows 同此:`git::own` 先找本应用旁的,再按 `apps` 的同级查找与 macOS 的登记找 ElyTree 旁的,都没有即 `git::NoGit`,ElyMark 的 Git 项写明要装 ElyTree;Windows 的 ElyMark 原随带一份,用户问起后去掉。Linux 的 ElyMark 照带:AppImage 自成一体,找不到别的映像里的 Git,deb、rpm、Arch 包与它同一 AppDir,R6.6)。Windows 安装程序只动自己装下的:装好后写下所装的每个文件与文件夹(`.ely-installed`,由 `packaging/windows/files.sh` 按分装目录生成),升级与卸载只移去其中的文件,文件夹空了才删;清单之前的版本只在注册表记下的安装目录里按旧的通配名与其文件夹里的全部文件移去;先查每个文件能否写(运行中或被占用即不能,文件夹里的也查),再逐个挪进 `.ely-old`(按清单次序编号,备份路径不随原路径变长,近 260 字的路径照样升级与卸载),任一项挪不动即放回、什么也不改、退出码 2;卸载时清单本身也先查、删不掉即放回全部;新文件的位置已有不属于本应用的文件即拒绝、什么也不改、退出码 2;写到一半失败即删去本次写下的、放回旧版;升级后不留旧的 `git` 与 CEF 文件(R6.11,`tests/scripts/windows-installer.sh`)。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);ElyMark 带 RaTeX 嵌入的 KaTeX 字体的 OFL 与说明;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、Line truncated
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据、CEF 与页面的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);CEF 的 LICENSE 与 Chromium 的 CREDITS、页面的第三方通知;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。正式签名(hardened runtime)时 CEF 的渲染与 GPU 辅助程序带 `allow-jit`,插件辅助程序带 `allow-unsigned-executable-memory` 与 `disable-library-validation`(`packaging/macos/entitlements`;原先不带,V8 保留不到代码区,页面载入失败);`tests/scripts/hardened-macos.sh` 以 `ELY_SIGN_IDENTITY` 签 ElyMark 并核对页面画出。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
- 2026-10-08(用户定):深色主题不用纯黑或近黑,底色取柔和的灰 `#252525`。组件库 fd0efc7:深色色板 bg `#252525`、surface `#2c2c2c`、sunken `#202020`、overlay `#323232`、hover `#383838`、active 与 border `#3f3f3f`、border_strong `#555555`;为让说明文字在较亮的 hover 与 active 上仍有 4.5:1,muted 提到 `#c4c2c0`、subtle 与代码注释提到 `#acaaa8`;色盲友好图表的紫与橙改为 `#9d8dec`、`#ffd93d`(3:1 且各色两两分得开)。`ely.xcd` 的深色方案随之:页面 `#2c2c2c`、页外 `#252525`、边界与网格 `#555555`、域底纹 `#3f3f3f`。跨应用同步外观、设置里的浅色/深色/跟随系统、Omarchy 下随 Omarchy 主题,本来已有(共用 `settings.json` 加文件监视;`settings.sh` 核对 ElyPDF 选深色后 ElyTree 即刻变深);Omarchy 的读法与 OmaPhoto 相同(`~/.local/state/omarchy/current/theme/colors.toml`、`mode` 与具名颜色)。
- 2026-10-08:图像的显存与内存(C20.2 长时测试查出)。GPUI 把每张画过的 `RenderImage` 留在窗口的图集里,直到调 `window.drop_image`;LOK 的图块、PDF 的图块与缩略图、演示的缩略图、动画预览与放映图层原先都不释放,ElyDoc 编辑保存 60 轮后应用进程涨到约 2 GB(内核不涨)。组件库 `primitives::Retired`:不再画的图先交给它,下一次绘制时释放(`release` 只放别处已不持有的,`release_all` 给自知不再画的属主,`append` 接过关闭者的);LokView、PdfView 在替换、挤出、清空图块时交出旧图,绘制前释放;ElyShow 每个标签一份,关闭的标签与结束的预览交给 Deck;放映按窗口各一份(观众屏、演讲者视图),离场中的幻灯片还在画时其图层等它结束。修后同样 100 轮在 340 至 440 MB 之间起落。
- 2026-10-08:性能基准(C20.1,§20.1)。`tests/perf/bench.py` 在发行构建上测 PERF-01 至 08(每项 30 次,P50、P95,含全部子进程的内存),样本由 `tests/perf/samples.py` 按固定种子生成到 `engines/bench`,记录写到 `docs/perf/<日期>-<机型>.md`(格式见其 README);`ELY_STALLS=1` 开主线程忙碌记录(只供测量,空闲的应用照样不做事)。实测改动:Doc、Table、Show 原先要等内核启动完(约 2.5 至 3.9 秒)才开窗口,ElyDoc 日常启动 P95 3.9 秒不达标;现 `Worker::launch` 只起进程,内核的版本与图块核对在第一个请求前做(`Worker::ready`,任何请求都先过它,未核对的 worker 收不到请求),`ely_office::start` 在后台核对并设作者名,失败即 `startup::fail`;窗口约 0.15 秒可操作,空白文档在内核首次载入后出现(中位约 2.7 秒,其中约 2.2 秒是内核首次载入文档)。启动参数改为按当前目录取绝对路径(原相对路径成了 `file://相对` 而打不开)。补丁 `0039-skia-macos-no-unused-font-manager.patch`:macOS Skia 首次画字时以全部系统字体再建一个无人使用的 `SkFontMgr`(约 370 个字体枚举加排序,主线程约 175 毫秒),新文档的第一个字与打开文档的首块带字图块因此迟到;去掉后 PERF-03 的 10 次对比 P95 由 2.6 秒降到 1.5 秒。PERF-02 的计时原以输入落地时的代际为基准,内核的作废事件先于落地回报到达时,该次编辑的图块不计,钟一直走到下一次按键(记下的是按键间隔约 250 毫秒);现按编辑的修订认定(`view/clock.rs`:修订不低于该编辑的批次所升的代际,其图块即为所求),实测中位 2 毫秒、P95 5 毫秒。PERF-08:关闭的标签原把其图块留在 GPUI 的图集里(每个文档约 20 MB 显存,40 次新建关闭后应用涨约 250 MB);`Engine::retire` 交出关闭视图的全部图像(LOK 图块、PDF 图块与缩略图、ElyMark 页面的画面),外壳在窗口的下一帧释放,修后 40 次在 454 至 462 MB 之间。历史首屏改为从按下“历史”计(原从进程启动计,含打开仓库)。R1 后基准更严:PERF-03、04 到视图收下的第一块图块(`lok: tile (x, y) shown`),其间有图块画失败即整次作废;DOCX 样本原以单独一段的分页符分页,内核只排出 60 余页(`Heading1` 段上的 `pageBreakBefore` 同样丢页),改为分页符放在标题段内,PERF-03 之前先核对恰为 100 页(视图记录页数);PERF-02 每键须有自己的计时,缺即失败;PERF-08 循环前记基线,记内核中的文档数(worker 每次载入关闭写 `N documents open`),文档数须回到基线,文件数自第一次之后不变(第一次打开模板对话框时进程一次性载入两个系统字体与两个 AppKit 本地化表)。worker 的启动核对失败后保持失败,此后一切请求被拒(原核对失败的状态与成功相同,第二次即放行)。空白文档就绪由约 2.5 秒降到约 1.0 秒,发生在本次重建内核之后,与 0039 无关(对比实测),原因未查明。
@@ -239,20 +232,28 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-08:兼容清单与往返验证(C19.1,§19.1、§19.2)。`docs/compatibility.md` 是随版本发布的兼容清单(版本、内核与补丁、验证机,各应用的编辑保存、导入受限与输出,已验证与限制),版本或格式行为变了即同步改它。往返用例在 `crates/ely-lok/src/client/tests/independent.rs`:Ely 编辑、保存、关闭、再打开、再编辑、再保存,再由 `tests/scripts/ooxml.py` 检查;它不用 LibreOffice,自己解 OOXML,预期值自己算(整数、日期序号、错误值、文本严格相等,浮点按用例声明的容差),DOCX 另交 macOS 的 `textutil`;没有 python3 时报 SKIP。与 Microsoft Office 的交叉检查未做(本机没有 Office)。
- 2026-10-08:恢复副本、引擎停止与自动保存(C06.5,§6.2、§6.3)。Doc、Table、Show、PDF 共用 `ely_office::Recovering`:每 10 秒为有改动、已落定、不在导出的标签经 `Engine::snapshot`(写副本,文档不动)写一个恢复点(`ely_files::Recovery`,限额取存储设置;加密文档封存),索引在数据目录 `recovery/index.json`;崩溃标记照 ElyMark 的 `Crash`,下次启动把崩溃留下的最新点开成未命名副本并标为未保存,提示写明只恢复了内容,光标与撤销历史没有。引擎进程退出(`Engine::ended`,客户端 `try_wait`)时 `ely_office::Stopping` 在上方显示提示条(原因、最近一次恢复点的时间)与“重新打开文档”:`Engine::restart` 换上新 worker(LOK 照原配置与作者名重起,PDF 下次打开再起),有改动的标签从最新点开成副本,无改动的从文件重开,有改动却没有恢复点的列出;被放下的标签迟到的失败不再提示,指向它们的关闭询问一并撤下。重开的副本接过原恢复点的文件夹(封存随之),旧点留到新标签写下新点为止;载入或解封失败的条目留在索引里,下次崩溃后的启动再给。需要密码的副本(加密 PDF 的恢复点照原加密写出)照样询问,答后仍是未命名副本(`Source::Locked`)。引擎是否退出在出错时与每次恢复计时时查,空闲的引擎退出也会显示。恢复索引经 `ely_files::save_atomic` 暂存后替换,写不进时上一份照旧可读;写不进即提示一次、每次计时重试。干净结束后的下次启动只删已接过或正常的条目,没能重开的留着再试。自动保存(设置“文档”页,每个应用一个开关,存在应用的 `settings.json` 的 `autosave`,默认关):只对本应用自有格式(`Kind::save_as`)、无风险(宏、签名、修复)、状态为“已编辑”的标签,编辑计数停 2 秒不变即走普通保存;保存失败、外部改动后状态不再是“已编辑”,不再自动写,等用户处理。Cmd+W 关闭标签是共用命令(`ely_ui::commands::CloseTab`,原来只有 ElyPDF 绑了;文档应用里 Cmd+W 落进 LOK,内核按自己的 Ctrl+W 关掉框架,worker 下一次绘制或读取即在已释放的文档上段错误);worker 另拒绝内核关闭框架与退出的快捷键(Mod1 加 W、F4、Q)。真窗口(macOS):`recovery.sh`(崩溃后副本回来、关闭询问;只杀引擎后提示条、重新打开、文字回来、关闭询问),`autosave.sh`(关时不写,开关存下,开后停手即写,外部改动后不覆盖)。未验证:Table、Show、PDF 的真窗口恢复(同一外壳);Windows 与 Linux。
- 2026-10-08(用户定):首版发布六个应用(ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark);ElyMail 与 ElyAgent 第二批发布。ElyMark(C14)做完即开始首版的完整测试与打包,C12、C15 留到第二批。
- 2026-10-08:数据管理(C18.3,§18.3)。设置的“存储”页(每个应用):本应用数据目录里的日志(`ely.log`)、恢复副本(`recovery`)各自的大小与清除(`ely_platform::storage`);日志当场清空(日志器以追加打开,截短不坏句柄),恢复副本在用着,写下记号(`.clear-recovery`),下次启动在设置与窗口之前删去(删不掉即启动失败、写明原因,记号留着下次再试,不打开没删掉的数据);每次清除先问,写明删什么、何时删;每个文档保留的恢复点(10、20、50)与空间(32、64、256 MB)存在应用设置的 `recovery`(严格读,不合即报错),ElyMark 写恢复点时照用(C06.5 的文档应用同)。日志超过 8 MB 时,启动时只留后 4 MB、从整行起。数据库的升级前备份与失败只读只属 ElyMail(第二批),首版的六个应用没有数据库;邮件与 Agent 历史导出同属第二批。组件库 ea4ed4c:`SettingsRow` 是以标题命名、以说明描述的组(原先行标题不进 AX 树,两个同名按钮分不出);待清除的状态以禁用的按钮写明。
- 2026-10-08:数据管理(C18.3,§18.3)。设置的“存储”页(每个应用):本应用数据目录里的日志(`ely.log`)、网页内容缓存(`web`,只 ElyMark 显示)、恢复副本(`recovery`)各自的大小与清除(`ely_platform::storage`);日志当场清空(日志器以追加打开,截短不坏句柄),缓存与恢复副本在用着,写下记号(`.clear-web`、`.clear-recovery`),下次启动在设置与窗口之前删去(删不掉即启动失败、写明原因,记号留着下次再试,不打开没删掉的数据);每次清除先问,写明删什么、何时删;每个文档保留的恢复点(10、20、50)与空间(32、64、256 MB)存在应用设置的 `recovery`(严格读,不合即报错),ElyMark 写恢复点时照用(C06.5 的文档应用同)。日志超过 8 MB 时,启动时只留后 4 MB、从整行起。数据库的升级前备份与失败只读只属 ElyMail(第二批),首版的六个应用没有数据库;邮件与 Agent 历史导出同属第二批。组件库 ea4ed4c:`SettingsRow` 是以标题命名、以说明描述的组(原先行标题不进 AX 树,两个同名按钮分不出);待清除的状态以禁用的按钮写明。
- 2026-10-08(用户定:图标照 Papirus 重画):每个应用是 Papirus 192 网格上的一个大的扁平物件(页、讲台、信封、终端),主体一种饱和色,折角为其浅色,内容为白色、至多一个点缀色,主体与内容带 Papirus 的柔和投影(下移 4 或 2、模糊、20% 黑)。macOS 为浅色 squircle 上的同一物件(占主体 86%),Linux 与 Windows 只有物件。48 像素以下用无投影、无小细节的简化版。母版由 `packaging/icons/masters.py` 写出,配色见 `assets/icons/README.md`。Papirus 图标包(`assets/papirus_icons-master.zip`,用户放入)只作参照,不随包。
- 2026-10-08(用户要求核对各系统的文件关联):文件类型只写在 `packaging/types.tsv`(应用、扩展名、MIME;`/` 为文件夹)。Linux 的桌面项由它写 `MimeType=`(原先没有,桌面环境从不提供 Ely 打开文件);Windows 安装程序由 `packaging/windows/types.sh` 生成的 NSIS 宏注册(原先没有):HKCU 下每个扩展名的 `OpenWithProgids` 与 `Applications\<程序>\SupportedTypes`,ProgID `ElyOffice.<名称>` 带图标与打开命令,ElyTree 在文件夹右键菜单加“Open in ElyTree”;只进“打开方式”,从不改默认(同 macOS 的 Alternate),卸载时删去。makensis 在本机编过 ElyDoc 与 ElyTree 的安装程序;Windows 与 Linux 上实际打开文件未验证。
- 2026-10-08(用户定:窗口缩窄时控件挤成一团、越出边框,不可接受):窄窗口的版面。外壳一次排两侧栏(`shell/panel.rs` 的 `room`,有单测):内容区至少留 360,放不下时两侧先缩到各自最窄,再先藏右栏、后藏左栏,窗口变宽即回来,用户拖的宽度不变。组件库 6d95b24 至 69265b4:`ScrollArea::vertical` 只纵向滚动、内容不得宽于区域(侧栏一律用它,原先横向不受限,整栏按自然宽度排后被裁);`PropertyGrid` 每行可折行,放不下时控件到标签下方,标签列 104;全部图标的 `SegmentedControl` 与 `Tabs` 放不下全部文字时只显示图标,文字作提示与无障碍名(宽度取上一帧量得的条宽与文字排版宽度);`StatusBar` 两侧与各项可缩,文字以省略号结尾;`Banner` 文字折行;`Button` 不再拒绝收缩,只在无法折行时缩并以省略号结尾;未聚焦的单行文本框显示文字开头(原插入点在末尾,看到的是尾部);`Switch::name` 只给无障碍名(行已有标签时不再重复画字);`CommitInput` 的勾选与按钮一行可折。各应用固定宽度的控件框(`w_32` 等)改为占满、以原宽为上限。`tests/scripts/layout.sh`:六个应用各开样本,640、800、1100 宽下读 AX 树,任何元素越出窗口左右即失败,并留截图供目视(栏内裁切 AX 看不出)。缩放(用户问为何 Doc、Table、Show 不能缩放;视图早有 `set_zoom`,界面从未接上):`ely_office::Zooming`,Cmd+= / Cmd+- / Cmd+0,“视图”菜单三项,状态栏右侧 `ZoomControl` 的属主模式(组件 69265b4:显示属主给的百分比、按下交属主;原只缩放整个界面);Cmd(Windows、Linux 为 Ctrl)加滚轮与触控板捏合以指针处为中心缩放,滚轮一格一档(`ely_ui::wheel`,2026-10-12 起;原认 Windows 键、一格三档),ElyPDF 与 ElyMark 同(`view/zoom.rs`,档位 25% 至 400%);ElyShow 的 Cmd+0 回到适合幻灯片,手动缩放即停止适合。缩放只在内存里随标签,不跨重启。
- 2026-10-08(用户定:窗口缩窄时控件挤成一团、越出边框,不可接受):窄窗口的版面。外壳一次排两侧栏(`shell/panel.rs` 的 `room`,有单测):内容区至少留 360,放不下时两侧先缩到各自最窄,再先藏右栏、后藏左栏,窗口变宽即回来,用户拖的宽度不变。组件库 6d95b24 至 69265b4:`ScrollArea::vertical` 只纵向滚动、内容不得宽于区域(侧栏一律用它,原先横向不受限,整栏按自然宽度排后被裁);`PropertyGrid` 每行可折行,放不下时控件到标签下方,标签列 104;全部图标的 `SegmentedControl` 与 `Tabs` 放不下全部文字时只显示图标,文字作提示与无障碍名(宽度取上一帧量得的条宽与文字排版宽度);`StatusBar` 两侧与各项可缩,文字以省略号结尾;`Banner` 文字折行;`Button` 不再拒绝收缩,只在无法折行时缩并以省略号结尾;未聚焦的单行文本框显示文字开头(原插入点在末尾,看到的是尾部);`Switch::name` 只给无障碍名(行已有标签时不再重复画字);`CommitInput` 的勾选与按钮一行可折。各应用固定宽度的控件框(`w_32` 等)改为占满、以原宽为上限。`tests/scripts/layout.sh`:六个应用各开样本,640、800、1100 宽下读 AX 树,任何元素越出窗口左右即失败,并留截图供目视(栏内裁切 AX 看不出)。缩放(用户问为何 Doc、Table、Show 不能缩放;视图早有 `set_zoom`,界面从未接上):`ely_office::Zooming`,Cmd+= / Cmd+- / Cmd+0,“视图”菜单三项,状态栏右侧 `ZoomControl` 的属主模式(组件 69265b4:显示属主给的百分比、按下交属主;原只缩放整个界面);Cmd+滚轮与触控板捏合以指针处为中心缩放(`view/zoom.rs`,档位 25% 至 400%);ElyShow 的 Cmd+0 回到适合幻灯片,手动缩放即停止适合。缩放只在内存里随标签,不跨重启。
- 2026-10-08(用户定:侧栏太占地方,要可调):侧栏宽度(C05.6)。外壳的左右侧栏(`shell/panel.rs`,八个应用共用)默认左 220、右 320(原取 `sidebar_width` 240 与 `sheet_size` 380;右 320 是 ElyDoc 属性栏不截断控件的最窄宽度,实测 280 截断数值框);内侧边缘是组件库的 `layout::resize_edge`,拖动改宽,左至少 160、右至少 280(原 220,属性栏放不下)、至多窗口的 45%;拖动停 0.5 秒后存入应用设置的 `panels`(`settings::panels`),退出时还没存的当场写入(窗口位置同),启动时严格读回(不是正数即在设置窗口写明并用默认),写入失败也在设置窗口写明;开合动画期间不显示拖动边。
- 2026-10-08:凭据与私密内容(C18.2,§18.2)。恢复密钥(加密文档的恢复副本用)存在系统凭据库(keyring:钥匙串、Windows 凭据、Secret Service);凭据库不可用或拒绝时改用本次运行的随机密钥,只在内存(`RecoveryKey { key, lasting }`,`lasting` 为假即提示恢复不跨重启),从不写成普通文件;用旧密钥封存的恢复点读不开即报“无法恢复”,不导出明文。PDF 打开密码与证书密码只在 worker 内存里、文档关闭即弃。日志:worker 的 stderr 经 `ely_ipc::relay` 按其级别并入应用日志(原先从访达启动时 worker 的日志丢失),两个 worker 与应用一样读 `ELY_LOG`、默认 info(LOK worker 原读 `RUST_LOG`,平时只出错误);日志调用不打印正文、密码、密钥;引擎(worker、内核、MuPDF)给的错误串可能引用文档,日志只记请求号与错误类别,全文只经回复交给界面;worker 的 stderr 只放行其结构化日志记录与崩溃栈帧,引擎自己打印的文字不进日志(只计数)。worker 启动失败也写成结构化记录,最后一条错误由 `Relay::last_error` 带进客户端的报错。诊断导出时姓名来源(账户名、设置)读不出即不导出并写明原因;日志尾部从整行开始。诊断(设置里的“诊断”,每个应用):先列出要导出的内容(本应用日志的最新 4 MB、macOS 上 7 天内本应用与其 worker 的崩溃报告,列表在 AX 树里),开关“去掉路径与个人姓名”默认开(凡路径与 URL 整段换成 `<path>`:有引号到引号为止,否则到行末;分段标题只写文件名;账户全名、登录名与作者名按从长到短换成 `<user>`),导出为一个文本文件(`ely_platform::diagnostics`),不上传。Git 服务的凭据照用用户自己的 credential helper 与 ssh-agent;打开文件不发任何请求。未验证:Windows 与 Linux 的凭据库与崩溃报告位置(那两端诊断只含日志)。
- 2026-10-08:配置(C18.1,§18.1)。通用偏好在 `<本机数据目录>/ElyOffice/settings.json`,各应用的在其数据目录的 `settings.json`;都经 `ely_platform::settings::Store`:带 `version`(本构建为 1;更新的版本或读不通即报错,不覆盖),写入在同目录的 `.settings.lock`(`ely_platform::lock::FileLock`,flock 或 LockFileEx,由 ely-files 移来共用)下先重读、只改所写的键、未知字段原样留下,临时文件 fsync 后原子替换;多个应用同时写不丢字段(单测八路并发)。通用字段(`settings::Shared`,类型不对即报错):外观(跟随系统、浅色、深色)、语言(跟随系统、English、简体中文;下次启动生效,设置里写明)、动效(跟随系统、减少、完整)、跟随 Omarchy(只在有 Omarchy 时显示)、作者名(空即账户名;`user::name` 先取它,ElyPDF 的批注、LOK 的批注与修订都用它)。环境变量 `ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION`、`ELY_LOCALE` 仍优先。`ely_ui::settings` 启动时读入(读不出即用默认值、记下原因),以 `notify` 监视文件夹,别的应用写入后即刻重读:外观、Omarchy、动效当场换,作者名经 `observe_global` 在后台交给 LOK worker(`set_author`)。设置窗口(应用菜单“设置…”,Cmd+,,每个应用一个)用组件库 `SettingsLayout`、`SettingsSection`、`SettingsRow`、`ThemeSelector`(组件 489ef31 起三项文字进内建中英目录)、`SegmentedControl`、`Switch`、`forms::Input`:通用;Doc、Table、Show、PDF 另有“文档”(作者名,失焦或回车写入);ElyTree 另有“Git”(Git 程序,空即随带的;写入前在后台核对 2.40 或更新,不是即在框下以 Banner 说明、不写;`ely_platform::git::locate` 次序为 `ELY_GIT`、此设置、随带的,版本核对移入 `ely_platform::git::system_version`,下次启动 ElyTree 生效)。文件读不出时窗口上方以 Banner 写明原因,改动不保存。作者名与 Git 程序每次提交都编号,写入经 `Store::set_if` 在文件锁下核对仍是最新的提交才落盘(等锁期间改了主意,旧写入作废);写入失败另以 Banner 写明,键入的值留着可重试;作者框在未编辑时跟随别的应用写入的名字。版本核对先看 `--version` 的退出状态(`git::version_of`)。各应用主窗口的位置、大小与最大化、全屏存在应用的设置里(`window`,停止移动 0.5 秒后写),启动时所记之处的中心仍在某块显示器上才用。未验证:Windows 与 Linux 的锁与窗口位置。
- 2026-10-08:进程与状态核对(C17.1,§17.2、§17.4)。帧(`ely-ipc`)由长度、标签、请求号与内容组成:客户端每个请求一个递增的请求号,worker 的回复带回同一个,不符即报错(`check_reply`;原先只靠一问一答的次序配对,丢一个回复即整串错位而不自知);LOK 主动推送的事件请求号为 0(`UNASKED`);PDF worker 的版本戳包含 `ely-ipc` 的源码。帧超过上限(64 MB)发送前即报错,什么也不写;worker 的结果超限时回一个错误,worker 不退出。LOK 的输出读不下去(坏帧、提前结束)时,原因经回复通道交给等待的请求,客户端停下并回收 worker 再报出(原先请求一直等进程结束)。会话:LOK 每次载入一个会话号(递增,原先用内核文档指针,释放后的地址被下一份文档复用,旧句柄读到新文档、事件被当作已关的丢掉);PDF 的 `Doc` 带打开它的 worker 会话,客户端发送前核对,别的 worker 的旧句柄被拒;worker 的文档号带进程前缀(进程号与启动时刻),别的进程的号在此不存在。修订(PDF):`Doc` 记着读到时的历史标记(标记相同即文档相同,撤销回到原标记),指向页、对象、位置的请求带上它,worker 在动内核之前核对,不符即以 `stale:` 拒绝;整份文档的请求(页数、保存、撤销、元数据、附件列表与添加、保护等)带“任意”;按键读取或移除附件带标记。ElyPDF 的编辑在排队时记下当时的句柄,落地后按新标记换句柄,过期的读取照旧按版本丢弃(画不出的块不因过期记为失败),过期的编辑提示“文档已改动,未执行”:排在别的编辑后面的编辑因此被拒(它对着的是前一个编辑落地之前的页面),再按一次即可。LOK 的修订(R2):worker 给每份文档记一个修订,会改文档的请求(键、鼠标按下与松开、拖动、文字、命令、粘贴、剪切、函数补全;不按键的鼠标移动不算)在执行前加一;按编号指向工作表、幻灯片、对象、参考线、制表位、行列的命令(参数里有 `Index`、`nTabNumber`、`TabNo`、`Part`、`Row`、`Col`、`Column` 或区域的四角,值不是 -1;-1 是新建)先发 `EXPECT` 带读到时的修订,不符即以 `stale:` 拒绝,内核不动;同一次读取得出的一组命令(对象列表的多选)只核对一次,在一次 worker 调用里依次执行(`Worker::run_all`、`LokView::run_all_at`、`Commands::run_all_in`)。`LokView::run` 拒绝带编号的命令,须经 `run_at`;外壳的 `run_in` 对带编号的命令用 `Host::read_at`(ElyTable、ElyShow 为其读取时同一次 worker 调用里取的 `Worker::now`,ElyDoc 为视图所见:推送的事件批次带修订,每个输入落地后视图另取一次修订);`Tabs::read_in` 也给读取闭包这个句柄(ElyPDF 为视图当前的句柄)。ElyTable、ElyShow 在视图所见修订新于其读取时重读,所以键入之后按编号的命令不会一直过期;记着编号的对话框(删除与重命名工作表、删除重复项、幻灯片备注栏键入期间)在读取时记下修订,确认按它执行(`Commands::run_on`),其间文档结构变了即拒绝;ElyDoc 属性栏按视图所见的修订发(删除所示的制表位)。输入失败时视图照样取回修订(请求在执行前已计数),之后的编号命令不会一直过期。过期的拒绝提示“文档在此间已改动,请重试”(`office.error.changed`)。MuPDF 打开中途(加锁检查、日志、修复)失败即释放已打开的文档与修复副本(原先每次失败留下整个文档;MuPDF 的存储另有 256 MB 上限的缓存,满后不再增长)。FFI:MuPDF 每个调用在垫片的 `fz_try`/`fz_catch` 里,返回码加错误信息;LibreOffice 只经 LOK 的 C 接口;worker 崩溃时客户端报出退出状态。工作进程:LOK 随应用窗口启动(启动即开空白文档),MuPDF 在打开第一个 PDF 时启动;不建进程池。后台:Git、OCR、缩略图、文件夹搜索、导出与系统打开都在后台任务里。不建设 §17.4 所列设施(无 RPC 服务发现、无常驻服务、Git 只有一个内核、Markdown 只有 markdown-rs)。
- 2026-10-08:应用之间的连接(C16.1,§16;首版只做六个应用之间的三项,邮件与 Agent 的三项随第二批)。启动别的 Ely 应用(`ely_platform::apps`):先找与本应用同一安装或构建的同级(`.app` 旁、Windows 的 `Ely Office\名称`、开发构建同一目录),再找 macOS 登记过的(按包标识 `AppId::bundle_id`,`NSWorkspace`);`.app` 一律经 `open -a` 交给 Launch Services,正在运行的应用经其打开请求收下文件(不另起一个),其余直接启动。应用切换:每个应用的应用菜单有“打开 Ely 应用”子菜单,只列已安装的其他 Ely 应用(`commands::switch_to`,八个无参数动作),启动失败即提示原因。导出后查看:导出、提取、拆分、删除标记内容、清理、保护、签名与附件另存成功后,提示带“打开”:PDF 在已安装的 ElyPDF 打开(作为新标签,不当作原编辑会话),否则与其他文件、文件夹一样交系统默认程序(gpui `open_with_system`);ElyPDF 打开失败即提示原因,不改交系统。Markdown 查看版本:ElyMark 状态栏的 Git 项把文件本身交给 ElyTree,ElyTree 收到文件即打开其所在仓库并显示该文件的历史(`Tree::open_path` 按文件所在文件夹开仓库,再以仓库内路径 `file_log`);不自动提交,不建仓库;未安装 ElyTree 时提示从发行包安装。真窗口(macOS):`release-macos.sh` 从 ElyDoc 的应用菜单启动 ElyMark;ElyMark 的 Git 项打开 ElyTree 的真窗口测试随 C14.16 重写。AppKit 自己的窗口恢复关掉(`startup::no_window_restore`,`ApplePersistenceIgnoreState`):Ely 自己恢复会话;原先应用被强退后再启动,AppKit 先弹模态“是否重新打开窗口”,访达交来的文件被挡在后面(打包测试实测)。窗口测试的 `seen` 不看系统菜单(苹果菜单的“最近使用的项目”会列出别处打开过的同名文件)。未验证:Windows 与 Linux 的同级查找与系统打开;系统默认程序打开(会在本机弹出别的应用,未自动测)。
- 2026-10-08:配置(C18.1,§18.1)。通用偏好在 `<本机数据目录>/ElyOffice/settings.json`,各应用的在其数据目录的 `settings.json`;都经 `ely_platform::settings::Store`:带 `version`(本构建为 1;更新的版本或读不通即报错,不覆盖),写入在同目录的 `.settings.lock`(`ely_platform::lock::FileLock`,flock 或 LockFileEx,由 ely-files 移来共用)下先重读、只改所写的键、未知字段原样留下,临时文件 fsync 后原子替换;多个应用同时写不丢字段(单测八路并发)。通用字段(`settings::Shared`,类型不对即报错):外观(跟随系统、浅色、深色)、语言(跟随系统、English、简体中文;下次启动生效,设置里写明)、动效(跟随系统、减少、完整)、跟随 Omarchy(只在有 Omarchy 时显示)、作者名(空即账户名;`user::name` 先取它,ElyPDF 的批注、LOK 的批注与修订都用它)。环境变量 `ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION`、`ELY_LOCALE` 仍优先。`ely_ui::settings` 启动时读入(读不出即用默认值、记下原因),以 `notify` 监视文件夹,别的应用写入后即刻重读:外观、Omarchy、动效当场换,作者名经 `observe_global` 在后台交给 LOK worker(`set_author`)。设置窗口(应用菜单“设置…”,Cmd+,,每个应用一个)用组件库 `SettingsLayout`、`SettingsSection`、`SettingsRow`、`ThemeSelector`(组件 489ef31 起三项文字进内建中英目录)、`SegmentedControl`、`Switch`、`forms::Input`:通用;Doc、Table、Show、PDF 另有“文档”(作者名,失焦或回车写入);ElyTree 另有“Git”(Git 程序,空即随带的;写入前在后台核对 2.40 或更新,不是即在框下以 Banner 说明、不写;`ely_platform::git::locate` 次序为 `ELY_GIT`、此设置、随带的,版本核对移入 `ely_platform::git::system_version`,下次启动 ElyTree 生效)。文件读不出时窗口上方以 Banner 写明原因,改动不保存。作者名与 Git 程序每次提交都编号,写入经 `Store::set_if` 在文件锁下核对仍是最新的提交才落盘(等锁期间改了主意,旧写入作废);写入失败另以 Banner 写明,键入的值留着可重试;作者框在未编辑时跟随别的应用写入的名字。版本核对先看 `--version` 的退出状态(`git::version_of`)。各应用主窗口的位置、大小与最大化、全屏存在应用的设置里(`window`,停止移动 0.5 秒后写),启动时所记之处的中心仍在某块显示器上才用。另:ElyMark(CEF)直接启动的应用继承了 Chromium 忽略或屏蔽的 SIGTERM,测试的 pkill 杀不掉、留下进程(实测数个钟头的残留);`apps::open` 在 exec 前把 SIGTERM、SIGINT、SIGHUP、SIGQUIT 复原并清空信号屏蔽(单测:启动者忽略并屏蔽 SIGTERM 时子进程照样被终止)。未验证:Windows 与 Linux 的锁与窗口位置。
- 2026-10-08:进程与状态核对(C17.1,§17.2、§17.4)。帧(`ely-ipc`)由长度、标签、请求号与内容组成:客户端每个请求一个递增的请求号,worker 的回复带回同一个,不符即报错(`check_reply`;原先只靠一问一答的次序配对,丢一个回复即整串错位而不自知);LOK 主动推送的事件请求号为 0(`UNASKED`);PDF worker 的版本戳包含 `ely-ipc` 的源码。帧超过上限(64 MB)发送前即报错,什么也不写;worker 的结果超限时回一个错误,worker 不退出。LOK 的输出读不下去(坏帧、提前结束)时,原因经回复通道交给等待的请求,客户端停下并回收 worker 再报出(原先请求一直等进程结束)。会话:LOK 每次载入一个会话号(递增,原先用内核文档指针,释放后的地址被下一份文档复用,旧句柄读到新文档、事件被当作已关的丢掉);PDF 的 `Doc` 带打开它的 worker 会话,客户端发送前核对,别的 worker 的旧句柄被拒;worker 的文档号带进程前缀(进程号与启动时刻),别的进程的号在此不存在。修订(PDF):`Doc` 记着读到时的历史标记(标记相同即文档相同,撤销回到原标记),指向页、对象、位置的请求带上它,worker 在动内核之前核对,不符即以 `stale:` 拒绝;整份文档的请求(页数、保存、撤销、元数据、附件列表与添加、保护等)带“任意”;按键读取或移除附件带标记。ElyPDF 的编辑在排队时记下当时的句柄,落地后按新标记换句柄,过期的读取照旧按版本丢弃(画不出的块不因过期记为失败),过期的编辑提示“文档已改动,未执行”:排在别的编辑后面的编辑因此被拒(它对着的是前一个编辑落地之前的页面),再按一次即可。LOK 的修订(R2):worker 给每份文档记一个修订,会改文档的请求(键、鼠标按下与松开、拖动、文字、命令、粘贴、剪切、函数补全;不按键的鼠标移动不算)在执行前加一;按编号指向工作表、幻灯片、对象、参考线、制表位、行列的命令(参数里有 `Index`、`nTabNumber`、`TabNo`、`Part`、`Row`、`Col`、`Column` 或区域的四角,值不是 -1;-1 是新建)先发 `EXPECT` 带读到时的修订,不符即以 `stale:` 拒绝,内核不动;同一次读取得出的一组命令(对象列表的多选)只核对一次,在一次 worker 调用里依次执行(`Worker::run_all`、`LokView::run_all_at`、`Commands::run_all_in`)。`LokView::run` 拒绝带编号的命令,须经 `run_at`;外壳的 `run_in` 对带编号的命令用 `Host::read_at`(ElyTable、ElyShow 为其读取时同一次 worker 调用里取的 `Worker::now`,ElyDoc 为视图所见:推送的事件批次带修订,每个输入落地后视图另取一次修订);`Tabs::read_in` 也给读取闭包这个句柄(ElyPDF 为视图当前的句柄)。ElyTable、ElyShow 在视图所见修订新于其读取时重读,所以键入之后按编号的命令不会一直过期;记着编号的对话框(删除与重命名工作表、删除重复项、幻灯片备注栏键入期间)在读取时记下修订,确认按它执行(`Commands::run_on`),其间文档结构变了即拒绝;ElyDoc 属性栏按视图所见的修订发(删除所示的制表位)。输入失败时视图照样取回修订(请求在执行前已计数),之后的编号命令不会一直过期。过期的拒绝提示“文档在此间已改动,请重试”(`office.error.changed`)。MuPDF 打开中途(加锁检查、日志、修复)失败即释放已打开的文档与修复副本(原先每次失败留下整个文档;MuPDF 的存储另有 256 MB 上限的缓存,满后不再增长)。FFI:MuPDF 每个调用在垫片的 `fz_try`/`fz_catch` 里,返回码加错误信息;LibreOffice 只经 LOK 的 C 接口;worker 崩溃时客户端报出退出状态。工作进程:LOK 随应用窗口启动(启动即开空白文档),MuPDF 在打开第一个 PDF 时启动;不建进程池。后台:Git、OCR、缩略图、文件夹搜索、导出与系统打开都在后台任务里。不建设 §17.4 所列设施(无 RPC 服务发现、无常驻服务、Git 只有一个内核、Markdown 只有 Milkdown 与 CodeMirror)。
- 2026-10-08:应用之间的连接(C16.1,§16;首版只做六个应用之间的三项,邮件与 Agent 的三项随第二批)。启动别的 Ely 应用(`ely_platform::apps`):先找与本应用同一安装或构建的同级(`.app` 旁、Windows 的 `Ely Office\名称`、开发构建同一目录),再找 macOS 登记过的(按包标识 `AppId::bundle_id`,`NSWorkspace`);`.app` 一律经 `open -a` 交给 Launch Services,正在运行的应用经其打开请求收下文件(不另起一个),其余直接启动。应用切换:每个应用的应用菜单有“打开 Ely 应用”子菜单,只列已安装的其他 Ely 应用(`commands::switch_to`,八个无参数动作),启动失败即提示原因。导出后查看:导出、提取、拆分、删除标记内容、清理、保护、签名与附件另存成功后,提示带“打开”:PDF 在已安装的 ElyPDF 打开(作为新标签,不当作原编辑会话),否则与其他文件、文件夹一样交系统默认程序(gpui `open_with_system`);ElyPDF 打开失败即提示原因,不改交系统。ElyMark 导出缺图、图或公式时的警告同样带“打开”。Markdown 查看版本:ElyMark 状态栏的 Git 项把文件本身交给 ElyTree,ElyTree 收到文件即打开其所在仓库并显示该文件的历史(`Tree::open_path` 按文件所在文件夹开仓库,再以仓库内路径 `file_log`);不自动提交,不建仓库;未安装 ElyTree 时提示从发行包安装。真窗口(macOS):`mark-output.sh` 核对 ElyTree 打开该文件的历史、导出警告的“打开”在 ElyPDF 打开 PDF;`release-macos.sh` 从 ElyDoc 的应用菜单启动 ElyMark。AppKit 自己的窗口恢复关掉(`startup::no_window_restore`,`ApplePersistenceIgnoreState`):Ely 自己恢复会话;原先应用被强退后再启动,AppKit 先弹模态“是否重新打开窗口”,访达交来的文件被挡在后面(打包测试实测)。窗口测试的 `seen` 不看系统菜单(苹果菜单的“最近使用的项目”会列出别处打开过的同名文件)。未验证:Windows 与 Linux 的同级查找与系统打开;系统默认程序打开(会在本机弹出别的应用,未自动测)。
- 2026-10-08:ElyMark 的单行 `$$…$$`(用户以 Pandoc 示例发现):remark-math 把它读成行内公式,序列化只写一个 `$`,与原文不同,整段退成原文块。现按原文记下每个行内公式的 `$` 数(`math.js`),两个及以上以显示模式排出(同 GitHub),写回照原数;导出同样以显示模式。Pandoc 专有的表格、定义列表、行块不是 CommonMark/GFM,照 §14.3 以原文块保留(`---- ----` 在 CommonMark 里是分隔线)。
- 2026-10-08:ElyMark 书写视图、输出、Git 配合、大文件(C14.7,MARK-09、11、13、14,PERF-07)。书写视图:“视图”菜单的专注模式(⇧⌘F,收起标签条、左栏与状态栏;可视模式里光标所在的顶层块之外淡到 0.35,源码里当前行之外同),打字机滚动(插入点所在行保持在视口中部;上下各留半屏,首尾行也能居中),正文宽度窄、中、宽(36、48、64rem),字体无衬线、衬线、等宽,文字大小(⌘= ⌘- ⌘0,70% 至 200%,每档 10%)。全部在页面里以 CSS 实现(`look.js`:根元素的 data 属性与 `--ely-text-scale`),Markdown 不变;设置存在数据目录的 `look.json`(读不出即报错并用默认值),菜单随之打勾,启动时恢复;淡入淡出遵守系统的减少动效。组件库的 `ZenMode` 不用(见缺口)。输出(“文件›导出…”⌘E 一个对话框选 HTML、HTML 与资源文件夹、PDF、纯文本;“打印…”⌘P):页面以 unified(remark-parse、remark-gfm、remark-math、remark-rehype、rehype-katex、rehype-stringify,版本写死)把编辑器此刻的全文(与懒挂载、大文件无关,不截断)渲染成 HTML,Mermaid 用浅色主题画成 SVG(画不出的照原代码块并在导出后提示);front matter 不进输出。HTML 内嵌导出样式表(构建时单独产出 `export.css`:KaTeX 样式与 woff2 字体内嵌、浅色排版)与图片(文档相对路径、百分号转义、file URL 读成 data URL;网址照原样;读不到的图片照原链接并提示);HTML 与资源文件夹写 `名称.html` 与 `名称_files/`(样式表与图片副本,同名图片按序号区分;文件夹已在即拒绝,写坏即删去);纯文本以 hast-util-to-text 按块换行,公式写 TeX;PDF 先把同一份内嵌 HTML 暂存到工作目录,在不显示的 CEF 视图里由 Chromium 打印(`WebView::print_pdf`,结束经 `Event::Printed` 报回),再交到所选位置;打印同一份 PDF 交系统打印面板(`ely_office::print_later`)。导出都经 `ely_files::export`(不覆盖打开着的文件)。富文本复制用 Chromium 自己的复制(剪贴板带 HTML)。Git(MARK-13,不做第二套客户端):Git 的查找与运行环境移入 `ely_platform::git`(`locate`:`ELY_GIT`,应用旁,ElyTree 包内的 Git,本构建的 `engines/git`;`command`:只读不抢锁等同一套环境变量),ElyTree 照用;ElyMark 在后台对当前文件问 `rev-parse --show-toplevel` 与 `status --porcelain=v2 --ignored`,状态栏显示未改动、有改动、未跟踪、已忽略、有冲突(问不出即“未知”,提示里是原因;不在仓库里不显示),按一下以仓库根打开 ElyTree(`ely_platform::apps::open`);切换标签、每次保存(指纹变化)、外部改动与窗口回到前台时重问。大文件(MARK-14):超过 2 MiB 的文本以源码打开,上方提示条写明大小与“可视与预览已关闭,保存保留全部内容”,切到可视或预览即提示不切;保存与导出都是全文。PERF-07 实测(macOS,release):1 MB 文档自启动到可视可编辑 1.4 秒(首次冷启动 2.0 秒);4 MB 在可视下约 5 秒,故按源码打开。真窗口(macOS,`tests/scripts/mark-output.sh`):Git 状态未改动→键入自动保存后有改动→按下打开 ElyTree 于该仓库;专注模式收起标签、存入 `look.json`、重启仍在,文件不变;四种导出的内容(KaTeX、SVG、内嵌图片、全文;资源文件夹的样式与图片;PDF 以 pdftotext 读出文字;纯文本含 TeX);打印生成 PDF;3.3 MB 文件以源码打开、⌘1 不切、末尾编辑后全文保存、纯文本导出含最后一行;`ELY_CLIPBOARD_TEST=1` 时可视全选复制带 HTML。`mark-content.sh` 的 look 步核对淡出、窄列、衬线与插入点居中。去掉专注收起、图片内嵌、大文件阈值、Git 状态或截断文本导出时各自失败。窗口测试须在英文输入法下运行(中文输入法会把模拟键入收进组字)。导出时每张图定为一个 `src`(去掉 `srcset`、`sizes` 与 `picture` 的 `source`,无 `src` 时取 `srcset` 的首个地址);公式的 KaTeX 错误随导出提示(行号与 KaTeX 的说明),不当作完整导出。引用正文的提示走外壳的 `toast_unlogged`,日志只记计数与行号;组件库 28a6bba 起 `Toaster` 只记提示的编号与级别,不记标题。未验证:Windows 与 Linux;Windows 的 Git 路径在 ElyTree 包内的位置。
- 2026-10-07:ElyMark 外部变更、自动保存与会话恢复(C14.6,MARK-08、12,§6.2、§6.3、§14.4,QA-22)。监视:一个 `notify` 监视器盖住各标签文件所在的文件夹(不递归),200 毫秒内的一串改动合成一次查看;按会话指纹(长度与修改时间)比对,自己的保存不触发。文件改了:读到稳定的字节(读前读后指纹相同,最多五次),无未保存内容(含页面还没送回的编辑)即原地换成磁盘的文字(`ely.refresh`:插入点所在的文件行与滚动尽量保持,时间线从这份文字重新开始),会话重载指纹并以新文件身份登记;有未保存内容即不动磁盘与编辑器,状态为“外部已改”,自动保存停止,上方提示条“比较…”。比较:组件库 `git::regions` 对“上次载入或保存时”的文字、你的与磁盘上的三方合并,`ThreeWayMerge` 逐个冲突选边(编辑器的文字先落定;比较期间磁盘再改即重读重比,选定前核对所读内容的指纹,不符即提示再比较一次、不写入),可“用磁盘上的”“保留你的”(立即覆盖写入)或冲突都选完后“用合并的”(磁盘的版本成为新的基底,合并结果作为一步可撤销的编辑,随后自动保存)。路径不见了:等 300 毫秒(原子替换的空档)仍不在,即在原文件夹里按文件身份(设备与 inode,不认符号链接)找改名后的文件(`Sessions::renamed_in`),找到即标签跟过去(与 F2 改名同一条路);找不到即为已删:内容留着,提示条“另存为…”,不再自动保存、不重建文件。读不出(权限等)或监视器出错:提示条写明原因与“再查一次”;“视图›检查磁盘上的文件”随时手动查看(网络目录等不保证有事件)。自动保存(§6.2):有路径的文件停手 1 秒后保存(`ely_files::PolicyEngine`),外部改动或删除后停止,选定处理后重新开始;失败照常留为未保存。会话(MARK-12):数据目录的 `session.json` 记各标签的路径(未命名为空)、名称、模式、插入点所在的文件行与滚动;每 10 秒有未保存内容且有变化的标签写一个恢复点(`ely_files::Recovery`,每标签一个文件夹,20 点 7 天);退出(Cmd+Q 或关窗)开始时先记下当时的标签;`session.lock` 写进程号,正常退出删去它与全部恢复点;下次启动锁还在且进程已不在即为崩溃(`ely_files::alive`)。启动时打开记下的文件,标签显示后换成记下的模式与位置;崩溃后的未保存内容从最新恢复点放回,一律开成未命名副本(有路径的文件照磁盘打开,从不先覆盖原件,§6.3;原件已不在也照样开副本并提示哪个文件没能打开)、标为未保存,并提示“恢复了某时未保存的内容;撤销历史未恢复”(§6.3 区分内容与历史)。显式“不保存”后正常退出,下次不当作崩溃,未命名的内容不回来。真窗口(macOS,`tests/scripts/mark-outside.sh`):原子替换后干净标签换成新文字、键入后自动保存;未保存时外部写入不被覆盖,比较三栏、保留你的写回;互不冲突的两处改动合并后都在;外部改名后标签名跟随、保存写到新名、旧名不重现;改成无权限读即提示、恢复权限后“再查一次”重载;删除后不再自动保存、不重建;退出后重开回到源码模式与原来的行;未命名文档写过恢复点后 `kill -9`,重开内容回来且关闭时问是否保存,选不保存后再开不回来。去掉干净重载、改名跟随、删除时停自动保存、读错提示、崩溃内容、退出时记下标签或恢复的位置时各自失败。启动时先显示可视再换到记下的模式(一次多余的绘制,已知)。启动参数给了文件时只开它们与崩溃恢复的副本,不连带上次的标签。“保留你的”以所比较的指纹为基线(`Session::rebase`)走普通保存,之后的外部改动照样冲突;自动保存撞上外部改动时交给比较(`Host::conflicted`),不弹外壳的冲突框;外壳在询问取消、保存面板取消、保存失败、冲突询问取消时都调 `Host::close_cancelled`,关闭标签时调 `Host::tab_closed`(ElyMark 即刻删其恢复点并更新清单);每个标签只在首次显示时恢复一次;缺列的位置读作 0。组件库 f756043:脏标签与非当前标签的关闭按钮透明而非隐藏,辅助技术可按。外壳冲突询问的“保留我的”以此刻磁盘的指纹为基线走普通保存(导出期间再改照样冲突;文件已不在时直接写),“从磁盘重新载入”先结束挂起的关闭;退出途中首次保存的标签以新路径记入清单。取消退出(未保存询问点取消)后清单照常跟随(`Host::clLine truncated
- 2026-10-07:ElyMark 编辑工具与附件(C14.5,MARK-06、07)。查找替换(Cmd+F):组件库 `FindWidget` 盖在页面右上,查询与替换是两个 `TextInput`,带区分大小写、全词、正则三个开关;可视用 `prosemirror-search`,源码用 `@codemirror/search`;总数与当前序号由页面按不重叠的命中报回;正则写错时框内显示 JS 的原话(按两个编辑器所用的 `gmu` 标志编译);空的或无效的查询不执行查找命令(CodeMirror 对它会打开自带的面板);零宽命中按码点前进(落在代理对中间时 Unicode 正则会往回答,原先反复收集同一处直至页面崩溃);预览不替换;换模式或换标签后按同一查询重查。查找框整窗一个,各标签的页面各记其查询:下一处、替换每次都带上框里此刻的查询与替换文字,标签一换即按框里的重查。查询变了从当前命中的起点往后找,键入替换文字不移动命中。替换框回车替换一处。缩进:源码 Tab 与 Shift-Tab 为 `indentWithTab`;可视在列表里 Tab 下沉、Shift-Tab 上提,不在列表时吞掉;代码块同源码。WebView 在自己的键位上下文绑定 Tab 与 Shift-Tab 交给页面(组件库 K02 起更深的上下文先得),焦点不离开编辑器。列表续写:可视为 Milkdown 自带,源码为 lang-markdown 的回车续写。“表格”菜单:插入表格(3×3)、上下加行、左右加列、删行、删列(prosemirror-tables);插入后插入点落在新表第一格(Milkdown 在段末插入时把插入点留在段里,之后的行列操作落空);插入点不在表格里、或在源码与预览里即提示。代码高亮见 C14.3。附件:拖放图片文件或粘贴剪贴板里的 PNG,复制到文档旁的 `assets/`:拖放保留原名、重名加序号,粘贴名为 `pasted-毫秒.png`;都以 `create_new` 占名,从不覆盖已有文件,写坏即删去半成品(`ely_files::import_attachment` 原先先查后拷,其间出现的文件会被覆盖);在插入点写入相对链接,名称按百分号转义,源码里替代文字的方括号与反斜杠加转义;复制期间文档另存到别的文件夹即不写链接,提示图片留在原文件夹的 assets;未命名文档、预览与非图片文件拒绝并说明。粘贴经 `WebView::intercept_paste`:宿主先看剪贴板,图片自己处理,其余照常交给页面。macOS:CEF 离屏输入不带 AppKit 编辑命令,而 Blink 在 macOS 上只凭这些命令处理 Cmd+方向键与 Cmd+Backspace(Option 组合 Blink 自己处理),可视编辑器里这些键原来不动;`ely-web-content` 的键盘处理器(`edit.rs`)在页面没处理这些键时照 AppKit 标准键位以 `Selection.modify` 移动或扩选到行首行尾、文首文末,Cmd+Backspace 与 Cmd+Delete 删到行首、行尾;CodeMirror 自己处理的键不经此。组件库 8f4729c 与 aa0c99d:开关按钮(`ToggleButton`、`ToggleGroup` 的各项)在 AX 树里是按钮,名为其文字或提示,带按下状态。真窗口(macOS,`tests/scripts/mark-tools.sh`):可视全部替换,源码正则替换,Tab 与 Shift-Tab,列表续写,Cmd+Down 后插入表格、加行加列、删列,拖放图片(自带的拖动源)复制到 assets 并写入转义的链接;`ELY_CLIPBOARD_TEST=1` 时粘贴图片同样落地;复制被拖住(FIFO)时经菜单另存到别处,不写链接;两个标签各设查询后回到前一个全部替换按框里的;键入替换文字后替换一处落在原命中;源码拖入 `a]b.png` 写成 `![a\]b](…)`。去掉 AppKit 键、正则、Tab 绑定、插入点修正、文件夹核对、起点查找、标签跟随或转义时各自失败。`tests/scripts/comments.py` 另查 Swift 测试助手与页面的 JS 源码。本机有全局快捷键占用 Cmd+Shift+S 与 Cmd+Shift+Z(空白 AppKit 窗口也收不到),所以测试经菜单另存。未验证:Windows 与 Linux。
- 2026-10-07:ElyMark 导航与搜索(C14.4,MARK-05、MARK-10,MARK-01 的打开文件夹)。左栏三页(分段控件):文件、大纲、搜索。打开(面板可选文件与文件夹,启动参数同)文件夹即在后台按其真实路径列出其下的 Markdown 文件(`folder.rs`:`.md`、`.markdown`,不进点开头的文件与文件夹,不跟随链接,只在所开的文件夹里,不扫整盘),以组件库 `lists::FileTree` 显示(只画可见行,可过滤),选中按回车或双击打开;窗口回到前台、改名之后即重列,列表变了即重搜文件夹。没开文件夹时文件页是最近列表(`ely_files::recent`:数据目录的 `recent.json`,新的在前,留 20 个,读不出即报错且不覆盖;ElyTree 的同一套移入此处共用),打开成功的文件与列出的文件夹记入,按真实路径(同一文件只一条);标签的路径变了(改名、另存为)即记入新路径,旧路径已不存在即移出。大纲:页面在显示后与每次送出全文时报出当前编辑器的标题(可视与预览取文档里的标题节点,源码取 CodeMirror 的语法树,ATX 与 Setext),以 `lists::Outline` 显示,原文块里的标题也在内(以 remark 读其源码,引用、列表中的标题同样列出,跳转落在标题文字上),选一项即把插入点放到该标题并把键盘焦点交给文档(预览滚到该处)。搜索:本文件或文件夹(打开了文件夹才有),按字面、不分大小写逐行找,打开着的文件按其当前文字(含未保存的),其余读盘,读不出或不是 UTF-8 的文件以“某文件未能搜索”的行列在结果最前(带原因),最多 500 条;本文件的结果随当前标签:切换标签即重搜,较早的搜索迟到即弃;结果列出行文字与“文件:行”,选中回车即打开(已开即转到,按真实路径认标签)并把插入点放到该行的那一处:源码定位到该行的命中,可视按写出时每个块在全文里的位置(`preserve.js` 的写出同时给出各块的区间)找到该行所在的块,再按块内此前命中的个数落到块中对应的命中(此前的行先经 Milkdown 解析,按编辑器所见的文字计数,链接地址等不算,与块内查找同按不重叠计;原文块照源码计数;该行所见文字没有查询词时——命中只在链接地址等处——落在该行开头);行号自文件开头算,可视与预览先减去 front matter 的行数,落在 front matter 里即滚到它。页面未显示完就要求的定位等显示后再做。多标签由外壳。真窗口(macOS,`tests/scripts/mark-places.sh`):文件树不列 .txt、点选回车打开,大纲跳到二级标题后键入落在标题里,文件夹搜索找到两个文件的行、不搜 .txt、选中结果打开并在该行键入,最近列表重启后还在、从中打开文件夹。组件库 dabc1eb 起树与树行有无障碍角色(树、树项,名称、层级、选中、展开);大纲项仍没有(测试按位置点选)。未验证:Windows 与 Linux。
- 2026-10-07:ElyMark 编辑模式、文件与语法(C14.3,MARK-01 至 04,§14.3)。模式:可视、源码、预览,“视图”菜单与 Cmd+1/2/3,状态栏右侧分段控件;每个标签各记其模式,页面回落到源码时标签随之。切换在页面里进行(`ely.mode`):先取当前编辑器的全文并立即送出待送的全文,再以同一文字显示新模式;每次切换都新建目标模式的编辑器(不再保留隐藏的编辑器)。本文件只有一条时间线:带着编辑离开一个模式时,把离开时的全文记入时间线;编辑器自己的撤销用尽(各看其撤销深度,Milkdown 的历史键位排在我们的之后)时再按撤销,回到时间线上一条全文、以当前模式重建(另一段的编辑作为一步撤销),重做用尽时向后走;只有真正的编辑会截掉时间线上其后的部分,撤销、重做的跳转不算编辑;离开时编辑器还有可重做的步骤,就把全部重做后的文字记在时间线前方,回来后重做即到那里。模式请求按序号只认最新的:编辑器构建期间再要的模式记下,构建完且仍是最新请求时显示它,此后的新请求清掉它;较旧的显示回报回来时序号不符即退出,不碰排队的请求。预览每次新建、只读(正文与代码块)。切换本身不改文件。文件:新建(空的未命名 `untitled.md`,保存走另存为)、打开、保存、另存为(外壳)、重命名(F2,“文件”菜单):`commands::RENAME` 与 `commands::RENAMING`(含重命名的文件命令表),`document_root`/`set_document_menus` 改收文件命令表;ElyMark 的 `rename.rs` 在原文件夹里改名(只有它用,不放外壳),`ely_files::rename_new` 不覆盖已有文件(macOS `renamex_np` 加 `RENAME_EXCL`,Linux `renameat2` 加 `RENAME_NOREPLACE`,Windows `MoveFileExW` 不带替换;后两者未构建验证),名称须不含斜杠且扩展名为可保存的格式,未命名或正在保存的标签不改名,改名完成时标签须仍开着且仍是原路径,否则只记日志、不动登记;同文件登记记下打开时的文件身份,原路径已不存在而身份相同即为同一文件(改名间隙里打开新名字会转到原标签),改名后以 `Sessions::track` 迁移登记(不关再开);外壳每次保存成功后也以它更新登记的路径与身份(原子保存换了文件);标签名与 `Session::renamed` 的路径随之,内容与指纹不变。文件 URL:Windows 的驱动器路径写成 `file:///C:/…`、UNC 写成 `file://主机/…`,去掉 `\\?\` 前缀,围栏反向换回 Windows 路径再取真实路径(字符串转换有单测,Windows 上未运行)。CEF 的请求处理移入 `fence.rs`(`handlers.rs` 原超过 500 行)。文档旁的图片:页面 `<base>` 设为文件所在文件夹,`WebView::allow_images` 让该文件夹只放行图片请求(资源类型为图片;导航、脚本仍只限内容文件夹),另存为或改名后随文件移动(已显示的相对图片不重载,已知限制);Mermaid 的包按脚本自身位置载入,不受 `<base>` 影响。Milkdown 7.22.2 的图片解析把无标题图片的 `null` 标题交给要求字符串的属性,ProseMirror 拒绝、Milkdown 记错误后丢掉整张图;`image.js` 改为空字符串。实测(macOS,`tests/scripts/mark-modes.sh`):文档旁的图片画出(截窗口量色),三种模式往返不改文件,预览不收键入,可视里键入后经预览回来撤销得回原样,源码里的改动回到可视并保存,改名到已有文件被拒、已有文件不动,改名到新名后文件移动、继续编辑保存到新文件,新建打开空的可视文档(读外壳写下的 `untitled.md` 须为空),可视键入、源码键入、回可视后两次撤销回到两者之前、重做回来(撤销跳转当作编辑、丢掉时间线时失败)(去掉预览只读、去掉保留编辑器、去掉旧编辑器的链接时测试失败);`mark-content.sh` 在同一段脚本里连要两种模式,最后显示后者(去掉记下的模式时失败);可视已显示、其回报未答时再要源码,最后为源码(不核对最新请求时失败)。“直接打开文件夹”随文件树在 C14.4。未验证:Windows 与 Linux。
- 2026-10-07:ElyMark 文件会话与原文保留(C14.2,§14.3,QA-21)。ElyMark 用 `ely-office` 的外壳(标签、打开、保存、另存为、关闭询问、冲突),引擎 `Mark`(`apps/ely-mark/src/engine.rs`):载入读字节,UTF-8 带不带 BOM 都可,UTF-16 与非 UTF-8 即报错并指出字节位置(`text.rs`);编辑器拿到去掉 BOM、CRLF 换成 LF 的文本;写出时文本与载入时相同即原样写回原字节,否则补回 BOM 并按载入时多数的换行(CRLF 多于单独 LF 即 CRLF;混用换行的文件编辑后统一成多数的那种,已知限制)。外壳的 `Engine::open` 改为收窗口、返回 `io::Result`(网页视图要窗口,页面开不了即为打开失败)。每个标签一个页面;页面每次改动立即报 `{"kind":"changed","version"}`,停手 300 毫秒后报该版本的全文 `{"kind":"text"}`;外壳的“已落定”即最新改动的全文已到,保存等它;历史标记为全文的长度与散列,撤销回原样即为未修改;外壳保存时先写出再读标记,两者之间页面可能送来新全文,所以写出时记下所写文字的标记,紧接的读标记交出它,之后的编辑仍算未保存。可视模式保留原文(`preserve.js`):照 Milkdown 自己的 remark 流程(`parse` 加 `runSync`)读出原文各顶层块的位置,与编辑器的顶层块一一对应(数目不同或有块无位置即不能对应;空文件 Milkdown 仍有一个空段落,照空处理)。每个顶层块单独序列化,与原文逐字相同的才是可视块,否则换成原文块(`ely_raw`:等宽、可编辑的纯文字,写出即其文字,不进撤销历史的一次替换);块间 Milkdown 不显示的原文(如链接定义:Milkdown 把引用链接内联、删去定义)也成原文块,所以没有藏起来的原文。序列化的标记照该文档自己多数的写法(`style.js`:无序与有序列表标记、强调、加粗、代码围栏、分隔线、Setext;以 remark-parse 统计后设 `remarkStringifyOptionsCtx`),写法一致的文档几乎全为可视块,与多数写法不同的块成原文块。写出时按块做 Myers 差异(`diff.js`;ProseMirror 未改的块是同一对象,否则按内容比较),未改的块与块间原文逐字照抄,只有改动的块用 Milkdown 序列化。YAML front matter 不进可视编辑器,以原文显示在上方,写出时原样接回。不能对应的文档直接以源码模式打开(可编辑,无损),并报 `fallback`。打开后插入点在文档开头。页面每次载入是一代(应用计数,随 `ely.show` 传入),回报带代数,别的代的回报丢弃;载入时版本计数归零,重载后显示最后收到的全文。故障视图按“重新载入”后焦点回到页面(原先留在消失的按钮上,键入无处可去)。普通 Markdown 读文件的权限错误不交外壳当作加密文件(外壳把 PermissionDenied 当作要密码),照系统原话报出;外壳打开失败另写日志。实测(macOS,`tests/scripts/mark-session.sh`):BOM、CRLF、front matter、Setext 标题、`1)` 列表、HTML 块、`~`删除线、硬换行、链接定义与 `[[...]]` 的文件,不改保存逐字节相同;在首段键入一字保存,除该段多一个字外逐字节相同(整篇序列化、去掉 BOM 时该测试失败);段落里的 `[[...]]`、引用里的 Setext 与 `[[...]]`、只有链接定义的文件都以原文块显示,键入一字只多一字;在链接定义前向前删除不移动、不修剪它;渲染进程被杀、按重新载入后再键入的字照样保存;无读权限的文件报“Permission denied”、不问密码(不用原文块、不按代丢弃回报时测试失败)。未验证:Windows 与 Linux;模式切换与撤销(C14.3)。
- 2026-10-07:ElyMark 内容包(C14.1,§14、§17.3)。网页源码在 `apps/ely-mark/web`:版本全部写死,`package-lock.json` 锁定;`scripts/build-mark-content.sh` 以 `npm ci --ignore-scripts` 安装、esbuild 打成经典脚本(file:// 下模块脚本被 Chromium 拦下)到 `engines/content/ely-mark`,附 `THIRD-PARTY-NOTICES.txt`(按打进包的每个包取其许可文件;没有许可文件的只许 MIT,以清单的作者与 MIT 原文写出,构建时列出)。包:Milkdown 7.22.2(commonmark、gfm 含脚注与任务、history、代码块组件)、CodeMirror 6(代码块与源码模式,语言取 language-data,全部打进包)、KaTeX 0.19.0(字体随包)、Mermaid 12.1.0(单独的 `diagram.js`,首次遇到图时才载入;`securityLevel: strict`,不在页面上留错误图)。数学按 Crepe 的做法(remark-math,块公式为 LaTeX 代码块)。只渲染文档里保存的源码;公式与图出错时显示错误(公式带位置,图带行号)与原文,不清空代码块。页面接口 `ely.show(markdown, mode, texts)`,mode 为 visual、source、preview;每次显示一个新容器与序号,较早的显示若后完成即销毁其编辑器、不报告,页面上始终只有最后一次的编辑器;预览时正文与代码块都只读;显示后经 `elyQuery` 报 `{"kind":"shown"}`。颜色全部取 `--ely-*`(CodeMirror 的高亮、Mermaid 的主题变量),随系统深浅色:Milkdown 把预览净化后拷进页面(不保留我们给的元素),且每次调用给的回调都是新函数,所以 Mermaid 预览先返回带编号的空占位,绘制结果只在该占位仍在页面上时经回调落下(改语言、改文字、卸载都换掉占位,旧结果即弃);绘制在全页一条队列里依次进行,每次绘制带代次,调用 Mermaid 前与落下前都核对属主、占位与代次,失效即跳过(编辑器销毁后排着的绘制不再执行);`:root` 的样式一变,凡占位仍在页面上的图(含正在绘制的)都以新颜色重画,此前的结果作废,首次落下的就是新颜色。记录归各编辑器所有,编辑器销毁即释放,占位已离开页面的记录在下一次绘制或换主题时清掉。Milkdown 把视口外的代码块换成占位(懒挂载),滚入视口时再建。界面文字由应用给(`mark.*`)。打包时 `bundle.sh` 把 `engines/content/<应用>` 放进 `Resources/content`,缺即失败;应用先找包内,再找本构建的 `engines/content`,都没有即报错。ElyMark 启动后显示一个空的可视文档(文件会话在 C14.2)。实测(macOS,`tests/scripts/mark-content.sh`):三种模式下标题、任务、表格、脚注、行内与块公式、代码块(逐块滚入视口,须可见、未被遮挡)、Mermaid 图(不小于 100×80)与两种错误,切换深浅色后图的颜色随之变,连续两次显示只留并只报后一次,截窗口背景色占比低于 0.97,KaTeX 字体已载入,无越界请求;`.app` 包内页面显示。未验证:Windows 与 Linux;树莓派上 2.5 MB 主脚本的载入时间。
- 2026-10-07:ElyTree 分支、标签、合并、变基、冲突(C13.4,TREE-07 至 09,§13.3)。Git 一律以 `GIT_EDITOR=true`、`GIT_SEQUENCE_EDITOR=true` 运行(没有终端可开编辑器,合并与撤销照 Git 自己拟的信息);失败时 stderr 为空即报 stdout(合并的冲突写在 stdout)。左栏第三页“分支”:本地分支(当前在前)、远程分支、标签(新的在前),`for-each-ref` 读出(附注标签取所指提交与标签自身的说明,`origin/HEAD` 不算分支,上游已删即不显示领先落后),上方“新建分支…”“新建标签…”;选中一个,中间列出提交、日期、上游与说明,以及适用的动作:切换(远程分支为 `switch --track refs/remotes/名`,不被同名标签劫走)、合并到当前分支、把当前分支变基到此、在此新建分支、重命名、删除(`branch -d`,Git 拒绝时报原话)、强制删除(`-D`)、删除标签。历史页提交详情多出拣选、撤销提交(合并提交以 `-m 1`)、在此新建分支、在此新建标签。新建分支可选“并切换过去”(`switch -c`);标签有说明即附注标签(`tag -a -m`,签名照用户的 `tag.gpgSign`)。变基、删除、强制删除、删除标签、中止、跳过每次都先问(每个确认只对这一次,§13.3),合并、拣选、撤销、继续不问。进行中的操作按 Git 目录里的 `rebase-merge`/`rebase-apply`、`MERGE_HEAD`、`CHERRY_PICK_HEAD`、`REVERT_HEAD` 判定(每次状态重读时读,只取最新一次),中间上方的提示写明正在把谁并到谁(变基时“当前侧”是新基底、“另一侧”是正在重放的提交),给继续(`<操作> --continue`)、跳过(合并无此项)、中止。命令失败时,只有合并、变基、拣选、撤销,且 Git 的输出有以 `CONFLICT (` 起头的行、失败后索引仍有未合并条目(`ls-files --unmerged`),才提示“因冲突停下”;其余(如钩子拒绝、冲突中 `checkout --ours` 的钩子失败)对话框显示 Git 的原话。失败的报错带上 stdout 与 stderr 两者(拣选的 CONFLICT 行在 stdout,钩子多写 stderr)。分支与标签的名称取自完整引用名(`refs/heads/`、`refs/remotes/`、`refs/tags/` 之后),同名的分支与标签各归各的;合并与变基用完整引用。分支行内的切换与删除接到同样的入口(删除照样先问)。冲突文件:中间上方是三侧与 `ThreeWayMerge` 的块级选择,各块照工作树文件里 Git 写下的标记分组(不另算一套合并;标记宽度取 `check-attr conflict-marker-size`,照 Git 的 `strtol_i` 读,不是正整数即 7;行须恰为该宽度,长短不同的同形行照正文;只按 LF 断行,同 Git,正文里的独立 CR 不算行界),基底取自 diff3 标记,没有时取 Git 自己 `merge-file --zdiff3 --marker-size` 对三个索引段的结果(块数相同时,只填没有基底的块,diff3 里空的基底照空);下方是可编辑的结果(`CodeEditor`,初为工作树里的带标记文件);选一侧即在结果里解决对应的那一块标记(其余手改照留),结果里的标记数与未选的冲突数对不上时(已手改过)不再按块选,提示在结果里改;“保存”只写工作树文件(经同一仓库的写队列,与 Git 的写操作不重叠;落地时按仓库与路径记下;路径已成符号链接即拒绝,不顺着链接写),“标为已解决”只做 `git add`(须先保存;结果里还有标记时先问),两者分开(§13.3);另有“用当前侧的文件”“用传入侧的文件”(`checkout --ours/--theirs`),一侧没有该文件时还有“以删除解决…”(`git rm`,先问)。符号链接、子模块、任一侧含 NUL(Git 视为二进制)、不是 UTF-8 或缺少的冲突整份解决:写明情形,给“用当前侧的文件”“用传入侧的文件”(只给有该文件的一侧)、“标为已解决”与“以删除解决…”。重读时结果有未保存的手改即保留并提示。真窗口(macOS):合并冲突的提示条、三侧与按块取另一侧后结果更新。未验证:Windows 与 Linux;交互式变基(不提供)。
- 2026-10-07:ElyTree 提交、历史、Blame(C13.3,TREE-05、06)。左栏分“改动”与“历史”两页(`navigation::Tabs`)。提交:改动页下方是 `git::CommitInput`(摘要与说明两个框,摘要满 50 变黄、72 变红),上面一行是 Git 设置里的身份(每次状态重读与窗口回到前台时重读,只取最新一次;`user.name <user.email>`,未设即写明,点它开身份对话框:姓名、邮箱(两个输入框以其标签为无障碍名),可勾“用于所有仓库”写 `--global`,否则写本仓库;空着即提示不写)与签名(`commit.gpgsign` 为真时写明以 `gpg.format` 签名,默认 openpgp),签名照用户自己的 Git 与工具,Ely 不另设。提交框按仓库各存一份(换标签时存下当前的、换上另一个的:摘要、说明、修改最近提交与其草稿),提交为 `commit --cleanup=whitespace -F -`(信息经标准输入,`#` 开头的行照留,用户的 `commit.cleanup` 不删它们,不经 shell),“修改最近一次提交”勾上即显示为勾选、读出上次的信息填入(读回前不能提交;先存下已键入的,取消勾选时放回;每次勾选或取消、换仓库都使在途的读取作废,读回时须仍是发起的仓库与那一次),提交成功后只在提交框仍是所提交的内容时清空(提交期间又键入的留着,别的仓库的提交框不动);钩子或签名失败时以对话框显示 Git 的全部原话,不只说失败。提交同其他写操作排队。历史:`log --topo-order --exclude=refs/stash --all`,每页 2000 个,滚到距末尾 20 行内读下一页;每次状态重读后比对 `show-ref --head`,引用变了(含外部提交、切分支、取回)即从头重读;读取期间来的比对记下,读完再比;引用读不出时历史栏显示 Git 的原话,之后读通即重读并清掉;提交图由 `git::lanes` 按已读的全部提交算出;时间按本机时区写到分钟(不用相对时间,长列表不必随时刷新)。搜索:在“提交信息”(`--grep`)、“作者”(`--author`)、“改动的文字”(`-S`)中按字面、不分大小写找,或按提交号(`rev-parse --verify`,前缀可);搜索时不画图。选中一个提交:中间上方是摘要、说明、作者与提交者(不同时才列)、完整提交号、父提交(可点,转到父提交)、签名状态(`%G?` 按字母译出,带签名者)、引用;下方左列是相对第一个父提交改动的文件(`diff --name-status -M` 与 `--numstat`,根提交对空树,空树由 `hash-object -t tree` 算,不写死 SHA-1),右边是所选文件的差异(与改动区同一读法:Git 自己的差异,图片、二进制、非 UTF-8 同样处理,无块的纯重命名显示为无改动)。改动区差异与历史页所选文件的标题栏都有“文件历史”与“逐行追溯”(改动区限已跟踪的文件):文件历史为 `log --follow --numstat -z`,跨重命名,每个提交带当时的路径与行数,选中即显示该提交里这个文件的差异(状态按两侧是否存在判定);逐行追溯为工作树文件的 `blame --porcelain`,未提交的行写“尚未提交”,每段首行可按,转到历史页的该提交;条色按提交时间新旧。读取都在后台,按发起时的仓库、提交与文件核对;文件历史与逐行追溯每次打开有窗口内唯一的序号,迟到的结果即弃。提交详情的作者、提交者、提交号、签名、引用、摘要与说明在 AX 树里有文字。真窗口(macOS)经 AX:历史页、提交图、引用、提交详情、文件列表与差异。未验证:Windows 与 Linux;GPG 与 SSH 实际签名(只读设置,签名由用户的 Git 完成)。
- 2026-10-07:ElyTree 差异与暂存(C13.2,TREE-03、04)。差异一律取 Git 自己的输出(`-c diff.suppressBlankEmpty=false diff --no-color --no-ext-diff --no-textconv -U3`,前缀写明 `a/`、`b/`,覆盖用户的 noprefix 等设置;空白的上下文行照样带前导空格):已暂存为 `--cached -M`(HEAD 对索引,即提交前的实际索引内容),改动为索引对工作树,未跟踪为 `--no-index` 对 `/dev/null`(退出码 1 即有差异);Git 的清理过滤(换行转换等)由它自己做,按块、按行暂存的补丁对得上索引。块之间未改动的行从旧侧的 blob 取(`cat-file blob`,已暂存为 `HEAD:旧路径`,改动为 `:路径`),可展开;行号按块头推算,旧文件比差异短即报错。改动的词按每对删去与加入的行标出(Unicode 词)。空白:精确、忽略空白变化(`-b`)、忽略全部空白(`-w`);行内与并排。按块或按行:只在精确比较、且是普通内容改动时(无新建、删除、重命名、复制、权限变化的头),已暂存区为取消暂存(`apply --cached --reverse`),改动区为暂存(`apply --cached`);未跟踪、重命名等整份暂存,界面写明。补丁由所选行切出:暂存时未选的删去行留作上下文、未选的加入行不写;取消暂存时反之;块头按“范围前的行数”换算,空范围照 Git 的写法;“无换行结尾”按切出后两侧的实际行尾:无换行的行在哪一侧后面还有行,那一侧就带上换行(所选的删去或加入行同样),两侧不同的上下文行拆成一删一加;`apply` 带 `--whitespace=nowarn`,用户的 `apply.whitespace` 不改所选的字节。写入前在后台重读同一条差异,与所选时读到的字节不同即拒绝(“文件在显示后改了,未暂存”),索引不动;同一仓库的写仍排队。所选行按住 Shift 取同一块内的范围;差异一变(任何重读得到不同的输出)所选即清空;每次读取有窗口内唯一的序号,换文件后旧文件迟到的读取即弃。图片(png、jpg、gif、webp、bmp、tiff、ico)以 `BeforeAfter` 左右拖动对比,一侧缺失时只显示另一侧;其他二进制文件,以及两侧任一侧不全是 UTF-8 的文件(差异以外的字节也算),显示两侧大小,整份暂存;文件与符号链接互换时 Git 为同一路径打出两段(删去与新增),两段都显示(不折叠:两段没有共同的旧文),整份暂存;工作树侧照 Git 读:符号链接读其自身的路径,不跟随;未跟踪的文件夹与冲突文件(冲突在 C13.4 处理)只说明。单击文件即在中间显示其差异(已忽略的文件以系统程序打开),标题栏另有“打开文件”。外部改动后状态重读,正显示的差异随之重读;文件不再列在原分区即收起。真窗口(macOS):改动区选两行暂存,索引恰为所选,已暂存区显示实际索引内容,并排视图;AX 树中各行、块按钮与分段可按。
- 2026-10-07(用户定):ElyTree 的 Git。随包自建钉住的 Git v2.56.0(a018953)加 git-lfs v3.8.0(各平台散列钉在 `scripts/fetch-git-lfs.sh`),`scripts/build-git.sh` 以 RUNTIME_PREFIX 构建到 `engines/git`,LFS 放在 `libexec/git-core`。查找次序:`ELY_GIT`(系统 Git,低于 2.40 即拒绝),否则应用旁或 `Resources/git`,否则本构建的 `engines/git`;没有即报错,不退回别的 Git。每次运行都是参数数组,带 `GIT_OPTIONAL_LOCKS=0`(读取不抢索引锁)、`GIT_TERMINAL_PROMPT=0`(从不在终端问凭据)、`GIT_LITERAL_PATHSPECS=1`(路径不当通配)。
- 2026-10-07:ElyTree 仓库与工作区(C13.1,TREE-01、02)。窗口经 `ely_ui::tool_root`:“文件”只有新建(选文件夹 `git init`)与打开(可多选文件夹),`commands::menus` 改收各应用自己的文件命令(文档应用照旧六项),保存、另存、导出、打印的动作在工具窗口里不挂;“仓库”菜单:克隆…、关闭仓库(Cmd+W)。每个仓库一个标签,同一工作树(按 `--show-toplevel` 的真实路径)只开一个,再开即切过去;裸仓库拒绝。最近列表在数据目录的 `recent.json`(新的在前,留 20 个),读不出即报错且不覆盖;最近项打不开即移出并说明。克隆:先问地址,再在最近仓库旁问新文件夹(名取地址末段去 `.git`),文件夹非空即拒绝;Git 的进度逐行显示在正中(回车与换行都断行,读到即显示),可停止(SIGTERM,Git 自己删去半成品;已结束的进程不再发信号,免误伤重用的进程号);关窗或退出时停下进行中的克隆。状态读 `status --porcelain=v2 -z --branch --untracked-files=normal --ignored`,未知字母即报错;行数取 `diff --numstat -z`(已暂存与未暂存各一次,二进制不计),未跟踪、冲突、忽略的不计行数。左栏 `git::ChangesList` 分冲突、已暂存、改动、未跟踪、已忽略;单击文件以系统程序打开;暂存(`add --all`)、取消暂存(有提交时 `restore --staged`,首个提交前 `rm --cached -f`:索引与文件不同也照取消,文件不动)、全部暂存(`add --update`)、全部取消(`reset -q`);取消暂存重命名连旧路径,暂存只用新路径(已暂存的重命名再改时旧路径已不在);放弃改动每次都问(已跟踪的 `restore --worktree`,未跟踪的 `clean -f -d` 并写明会删除)。同一仓库的写操作排队、一次一条,失败即清掉其后的排队并报出 Git 原话,关闭标签要等排队写完。外部改动:`notify` 监视工作树、Git 目录与共用目录(`--git-common-dir`;链接的 worktree 的分支与引用在主仓库里),250 毫秒内的一串改动合成一次重读,读取中再来的合成一次;空闲不读。状态栏:分支(游离显示短提交号,无提交写明)、上游与领先落后(上游引用已删即写明不存在,不显示为 0)、真实路径(所用 Git 的版本与路径只记日志,2026-10-11 用户定从状态栏去掉)。中间区在 C13.2 前只显示改动数或“没有要提交的改动”。组件库 4f8f3bd 与 37c5187:`Changed::lines` 可缺。真窗口(macOS):五个分区、行数、状态栏。未验证:Windows 与 Linux(停止克隆在非 Unix 上报未构建);LFS 仓库的克隆。
- 2026-10-07:ElyTree 仓库与工作区(C13.1,TREE-01、02)。窗口经 `ely_ui::tool_root`:“文件”只有新建(选文件夹 `git init`)与打开(可多选文件夹),`commands::menus` 改收各应用自己的文件命令(文档应用照旧六项),保存、另存、导出、打印的动作在工具窗口里不挂;“仓库”菜单:克隆…、关闭仓库(Cmd+W)。每个仓库一个标签,同一工作树(按 `--show-toplevel` 的真实路径)只开一个,再开即切过去;裸仓库拒绝。最近列表在数据目录的 `recent.json`(新的在前,留 20 个),读不出即报错且不覆盖;最近项打不开即移出并说明。克隆:先问地址,再在最近仓库旁问新文件夹(名取地址末段去 `.git`),文件夹非空即拒绝;Git 的进度逐行显示在正中(回车与换行都断行,读到即显示),可停止(SIGTERM,Git 自己删去半成品;已结束的进程不再发信号,免误伤重用的进程号);关窗或退出时停下进行中的克隆。状态读 `status --porcelain=v2 -z --branch --untracked-files=normal --ignored`,未知字母即报错;行数取 `diff --numstat -z`(已暂存与未暂存各一次,二进制不计),未跟踪、冲突、忽略的不计行数。左栏 `git::ChangesList` 分冲突、已暂存、改动、未跟踪、已忽略;单击文件以系统程序打开;暂存(`add --all`)、取消暂存(有提交时 `restore --staged`,首个提交前 `rm --cached -f`:索引与文件不同也照取消,文件不动)、全部暂存(`add --update`)、全部取消(`reset -q`);取消暂存重命名连旧路径,暂存只用新路径(已暂存的重命名再改时旧路径已不在);放弃改动每次都问(已跟踪的 `restore --worktree`,未跟踪的 `clean -f -d` 并写明会删除)。同一仓库的写操作排队、一次一条,失败即清掉其后的排队并报出 Git 原话,关闭标签要等排队写完。外部改动:`notify` 监视工作树、Git 目录与共用目录(`--git-common-dir`;链接的 worktree 的分支与引用在主仓库里),250 毫秒内的一串改动合成一次重读,读取中再来的合成一次;空闲不读。状态栏:分支(游离显示短提交号,无提交写明)、上游与领先落后(上游引用已删即写明不存在,不显示为 0)、真实路径、Git 版本、随附或系统与所用 Git 的路径。中间区在 C13.2 前只显示改动数或“没有要提交的改动”。组件库 4f8f3bd 与 37c5187:`Changed::lines` 可缺。真窗口(macOS):五个分区、行数、状态栏。未验证:Windows 与 Linux(停止克隆在非 Unix 上报未构建);LFS 仓库的克隆。
- 2026-10-07:ElyPDF 输出与文件恢复(C11.8,PDF-13、14,QA-13)。“文件›导出…”(Cmd+E)一个对话框三种:页面图片(PNG,72、150、300 dpi,全部或所选页,总是浅色,选文件夹,`名称-序号.png` 按页数补零,先以 `ely_files::claim_all` 占下全部目标,任一失败即删去本次全部);文字(UTF-8,各页以换页符分开,MuPDF 的 stext);优化的 PDF(可看可改的设置:压缩数据流、字体与图片;图片高于某 dpi 降到某 dpi,双三次重采样到确切目标,重压缩:无损的仍无损、有损的保持原格式,默认 300 降到 150;降到的 dpi 须低于阈值,否则不可导出;完成提示写明原文件与新文件大小)。导出图片与文字要“复制”许可,优化副本要“修改”许可。“打印…”(Cmd+P)把文档此刻写成副本(要“打印”许可,worker 的 `PRINT_COPY`)再交系统打印面板(`ely_office::print_later`)。文件恢复:MuPDF 打开时修复了文件结构(`pdf_was_repaired`),worker 即把修复后的文档整份写成临时副本、改开这份副本(日志与快照从此正常;原先修复过的文件不能保存),关闭文档时删去;打开时 MuPDF 的警告记下作修复报告(`ely_open` 期间接管警告回调,照样转给原回调)。外壳的 `Engine::signed` 改为 `Engine::risk`(宏由外壳判,签名、修复由引擎答),加 `Risk::Repaired`:提示条说明打开的是修复的副本、“另存为”写新文件、原件不动,带“报告…”按钮显示 MuPDF 的原话;保存一律另存,另存到原件被拒;签名或修复的文件另存成功后标签记下“待重开”,到内存与文件一致(已保存、无待落地编辑、不在导出)时才按新文件重开;另存期间又有编辑则留着编辑,下一次保存后再重开。MuPDF 有时读到坏对象才修复(尾部完好、某对象偏移坏):打开与解锁后先把每个对象读一遍,并解码图片以外的每个数据流(`ely_scan`,读不出的对象记入报告);MuPDF 修复了结构或读的过程中有任何警告(如内容流解压失败),都当作损坏文件,改开整份写出的副本、报告列出警告、保存一律另存。图片(用户定)在画出时检查:画图块或缩略图时 MuPDF 有警告即为损坏,报告补上警告,标签转为修复风险(保存一律另存);没画过的图片在原位保存(覆盖文件)之前统一解码检查(外壳的 `Engine::unsound`,有问题即转为另存为)。全部解码放在打开时的代价实测:100 张 2000×2000 的 JPEG(281 MB)打开到首块 2.6 秒(release,本机)。导出图片与文字期间 MuPDF 有警告即报错不写(读不全的内容不当作导出)。打印副本不带加密(已核过打印许可;否则系统打印面板打不开加了打开密码的副本),`print_later` 在面板关闭后删去它。导出对话框的降采样数值随键入生效(不等回车或失焦),直接点导出用的是所见的数。补丁 `0005` 见上(保存打不开输出时的保存中标志)。QA-13:worker 改字、加并填文本字段、移页后保存,以 PDFKit(另一阅读器)读回:页序、新字取代旧字、字段值都对(`tests/independent.rs`,只在 macOS);图片编辑在 PDFKit 里未单独核对(PDFKit 无图片清单接口)。组件库 a6b1bb0 的 `init` 改为返回错误(缺资源时),启动时出错即以 `startup::fail` 报出。真窗口(macOS):损坏文件的提示条与“修复的副本”状态、导出对话框。未验证:Windows 与 Linux 的打印(打印面板只在 macOS 实现);真窗口里实际打印。
- 2026-10-06:ElyPDF 密码与签名(C11.7,PDF-11、12,QA-15,§11.4)。权限:worker 记下每个文档的访问(MuPDF 的认证结果,4 为所有者;未加密即全权),`PERMISSIONS` 按 Ely 的位报出(打印、复制、修改、批注、填表、页面与书签、所有者),每个改动请求先按表核对(`secure::needs`:页面增删移转与书签要“页面与书签”或“修改”,内容、链接、属性、附件、OCR 层、删除与清理要“修改”,批注要“批注”,建改字段要修改加批注,填表要填表或批注,提取要复制,改保护要所有者),不许即以 `restricted:名` 报错,客户端转成 `PermissionDenied`,界面按名说明并提示输入所有者密码;复制文字在应用里按位拦(读文字本身不拦)。“文档”菜单“输入所有者密码…”:对时解除限制;错时 MuPDF 已丢了原密钥(RC4 与 AES-128 实测文字读不出),worker 以原密码重新认证。“密码与权限…”只在所有者权限下:写新文件(AES-256,`ely_protect` 经 `ely_save_copy` 带写出选项),打开密码可空,所有者密码必填且须与打开密码不同,六项许可勾选;或不保护(去掉加密)。只凭用户密码打开的文件改保护即拒绝。签名:MuPDF 以 `HAVE_LIBCRYPTO` 构建,worker 另链 `libmupdf-pkcs7.a` 与 OpenSSL 的静态 `libcrypto.a`(pkg-config 给目录)。`SIGNATURES` 按表单字段树列出全部签名字段(不画在页面上的也列,页与框可无;控件无 /P 时从各页 /Annots 找页):已签的给出摘要核对(`pdf_check_widget_digest`:未改、已改、读不出)、其后有无更新与更新是否越出签名所许(`pdf_signature_incremental_change_since_signing`、`pdf_validate_signature`)、签名者(MuPDF 的识别名与通用名)、签名者自称的时间、原因、地点,以及签名里的证书(签名者在前,DER);单个签名读不出只报该项的原因。证书信任由应用交系统判定(`ely_platform::trust`:macOS 用 `SecTrust`、`SecPolicy::create_x509`、禁网,故从不核验吊销;Windows 与 Linux 未构建,报“此平台未构建”)。界面“文档›签名…”对话框逐个列出完整性、证书、吊销(一律“未核验吊销状态”)、时间(带时区)、原因、地点与所在页,不给笼统的“有效”。新签:“以证书签名…”工具,拖出框或点空的签名字段,对话框选 .p12/.pfx、填证书密码、原因、地点,另存:快照副本上建字段(名 `SignatureN` 避开表单已有名)并签,以增量更新写出(旧签名覆盖的字节不动),写完重开核对新签名的摘要,不过即报错删文件;原文档不动;发起时绑定视图版本与所选位置。补丁 `patches/mupdf/0004-signature-reason-and-location.patch`:原因与地点写进签名字典(MuPDF 原只画进外观;签后再改会落到第二个更新段,字节范围算成负数)。外观只画标签、名字与时间(不画空的识别名各栏,不画 MuPDF 标志)。已签文件(`ely_signed` 数表单里有值的签名字段)打开为 `Risk::Signed`:上方提示条说明修改会使签名失效、“另存为”写出未签名副本,带“签名…”按钮;保存不覆盖原文件。外壳的 `risk` 由布尔改为 `Option<Risk>`(宏或签名),`Engine::signed` 由引擎回答。Ely 一切整份写出(保存、另存、提取、删除、清理、保护)在 `ely_save_copy` 里去掉签名值与其外观(字段留下、空着,去掉 AppendOnly 标志):整份重写本就使签名失效,不留假签名。签名核对的是文件,不是编辑中的文档:`SIGNATURES` 带路径,worker 以文档的密码另开该文件读出再关闭;签名列表按标签当前的文件路径读、按路径缓存,保存后重读。已签文件另存为成功后,标签按新文件重新打开(外壳的 `reload`,同冲突时;撤销历史随之重新开始),打开着的文档、签名列表与以后的签名都对应这份未签名的副本;另存失败时标签与签名都仍是原文件。签名对话框每次打开都重读文件,磁盘上被改动即显示已改。去掉签名值时一并去掉该签名字段自身的只读位(签名时由它的锁设下),以便再签;它锁住的其他字段的只读位留着(分不出原本是否只读,已知限制)。补丁 `patches/mupdf/0005-failed-save-clears-save-in-progress.patch`:MuPDF 的 `prepare_for_save` 置起保存中标志、只有写出过程清除它;打不开输出文件时标志留着,此后一切改动绕过日志,编辑没有撤销步(实测:存到不存在的目录后旋转,历史标记仍为 0);补丁让打开输出或预排签名失败时清除它。可视签名(用户定三种来源):“添加可视签名…”对话框,手写(组件库 `SiLine truncated
- 2026-10-06:ElyPDF 内容删除与文档清理(C11.6,PDF-09、10,§11.4,QA-14)。标记只在视图里(“标记文字以删除”按所选文字每行一框,“标记区域以删除”拖框,单击已有标记取消;预览为页面上的深色框;任何编辑落地即清空,页码可能已变),文档本身不动。“删除标记的内容…”与“清理文档…”都写新文件:先把文档快照写出、重开副本(`ely_save_copy`,提取与保存同用),在副本上改,再以 `garbage=2` 整份写出(不增量,旧修订不留),写到工作目录、核对通过后经 `ely_files::export` 交到所选位置,工作副本普通删除(对话框写明:原件、旧备份、同步历史与已发出的副本仍可能含有被删内容;不是磁盘级擦除)。删除:先删去碰到区域的批注与字段(MuPDF 的删除只管链接与文本框批注,其他批注的内容、字段的值会留下),字段失去最后一个控件即连同空了的上级一起从表单去掉(否则值留在字段树里);全部区域的删除做完后才建 Redact 批注(否则后一区域会删掉前一区域的标记)并 `pdf_redact_page`(黑框;图片在区域内的像素由 MuPDF 改成白色;碰到区域的线条整条删去,只删被盖满的会留下可复原的路径;碰到的文字删去,含隐藏文字)。有区域的页再过一遍内容(含表单):标记内容的 /ActualText、/Alt、/E 一律去掉,结构树里属于该页的元素(自身或继承的 `Pg`,或子项 MCR、OBJR 的 `Pg` 指向该页,连同其上级)去掉这三项与 /T;整页去掉而不只区域内的,宁多勿漏。页面缩略图 /Thumb 删去(它可引用原图),核对时区域所在页仍有缩略图即失败。写出后重开核对:区域内的字(与删除同一判法:字框各边缩一成后相交)为零;区域内画出来不是黑色的像素为零;逐页以设备重放,落进区域的路径、渐变、图像蒙版除我们的黑框外为零,图片单独画出在区域内只剩白或黑;文件只有一个版本;任一不过即报错并删去输出。确认时绑定标记与文档版本,选完路径后标记或版本变了即不写。清理:先扫出数量(属性与元数据条数、批注、附件含批注里的文件、有隐藏文字的页、脚本入口),勾选后写出:删 Info;其余按一遍遍历全部对象及其中的直接字典与数组:/Metadata 与 /PieceInfo,文件说明的 /EF 与 /RF 及关联文件 /AF,脚本;删批注(链接与字段不算批注,保留),删附件(名称树与文件批注;附件按带 /EF 的文件说明计数,每个一次),隐藏内容为:内容过滤(包住 MuPDF 的 sanitize 过滤器,用途设为 View 才认关着的图层)删去关着的图层里的一切、3 与 7 渲染模式及零透明度的文字,脚本(名称树 JavaScript;任何对象的 /A、/OpenAction、/Next 与 /AA 各项里 S 为 JavaScript 或带 /JS 的动作,从链中摘除,其后的 /Next 链接回原处,非脚本动作保留),带隐藏或不可见标志的批注(含链接与字段控件:链接不在 MuPDF 的批注表里,按页面 /Annots 删;控件删去时字段连同值从表单去掉);有隐藏文字被删的页,标记内容的替代文字照删除的做法去掉。扫描与写出后的核对都在快照副本上跑同一遍清理来计数,所以计数与执行一致;所选类别核对不为零即报错。扫描回来时须仍是当前标签且版本未变,对话框写明文件名。未验证:Windows 与 Linux。
@@ -266,63 +267,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-09(用户报:ElyPDF 改字报“这种字体画不出 V”):文档自带的子集字体常无可用的 Unicode 表(LibreOffice 导出的子集表为占位,0x00 起逐一对应字形号),按 Unicode 找字形一律失败。现以“文档自带字体”写字时直接用页面上的那个字体对象(`shim/ely_pen.c`;`pdf_load_font` 按对象缓存,与文字层的字体同一对象),以它自己的编码写:按文字提取的读法(ToUnicode,缺项或坏值退回 cid_to_ucs,同 pdf-op-run.c)倒出 Unicode 到编码的表,排版取它的宽度(`pdf_lookup_hmtx`),原有的 ToUnicode 与宽度不动,两个字共用一个字形时各写各的编码(原先按字形号写、重建 ToUnicode,共用字形的字被改写,且 CMap 漏写条目数,PDFKit 读不出字)。删去原文字块后页面资源里可能已无该字体,故按对象号以新名字重新引用。只支持编码即 CID 的字体(简单字体与 Identity-H),其余拒绝并说明;页面上没有的字仍报缺字。排版按同一套宽度量出的框,比较时留 0.01 点的余量(原块宽度即这些宽度之和,浮点误差曾使末字折行)。
- 2026-10-09:ElyTable 的自动筛选(R2.9)。内核会重新推算未命名的自动筛选区:每次打开筛选列表时向下扩到紧邻的数据行(`ExtendDataArea`、`ExtendBackColorArea`,为接上后来追加的行),命令从单格找到它时按连续数据区重算(`ScDocShell::GetDBData`:应用条件、关闭筛选及其撤销);选区下方紧邻的合计行因此进了列表、随数据一起被隐藏,存后重开的筛选区同样会扩。补丁 `0040-lok-calc-autofilter-keeps-its-rows.patch`:LOK 下已有的自动筛选区保持设定时的行(同 Excel),不再隐式扩展(光标在区内时;光标在同表另一块数据里即按那一块);单格开筛选时仍取整个数据区。代价:紧贴在筛选区下方追加的行不自动并入,需重新选定后再开筛选。
- 2026-10-09:ElyTree 的差异按文件类型着色(R2.12)。组件库 7f2ac3e 至 b0dbfd4:`DiffViewer`(建立时按路径定下读法) 按路径的扩展名判定散文与纯文本(md、markdown、txt、rst、adoc、org、csv、tsv、log 等,无扩展名同,不着色),代码按语言着色(组件库 b0dbfd4 的 `forms::syntax`:Rust、JavaScript/TypeScript、类 C、Python、Ruby、Shell、YAML、INI、SQL、Lua、Haskell、Lisp、Erlang、TeX 与其他 `#` 语言各有行注释记号、关键字与常量;Shell 与 YAML 的 `#` 只在词首起注释;Makefile、Dockerfile、CMakeLists.txt 先按文件名认),不是完整的语法分析器(字符串插值、块注释、多行字符串照通用词法)(原先一律套 `//` 的通用词法,Markdown 的 `#`、Python 的注释都被当作代码上色)。同批带入上游的块编辑器跨块选择、浮层滚轮、输入法候选框位置、插入点按设备像素对齐与格式修正。
- 2026-10-09:ElyMark 的表格(R2.13)。表格的空格与分隔行写法有许多种(`|a|b|`、`|---|`、对齐空格),逐字重现做不到,原先凡写不回原样的表格都成了原文块。现在一块的序列化与原文在把其中每张表格化成规范形式(各列对齐方式加各格的原文,`tables.js` 的 `canon`)后相同,即为可视块(`preserve.js` 的 `kept`),引用与列表里的表格同样;格内有 Milkdown 会改写的写法(`[[…]]`、引用式链接等)即不同,整块仍为原文块,与段落的规则一致。Milkdown 的列表项必须以段落开头,表格作列表项的第一块时无法放进编辑器,照原文块保留。未改的表格照旧逐字写回,改过的表格按文档多数表格的写法重写(`style.js` 按表格的行节点投票:单元格两侧有无空格、是否按列对齐,经 remark-gfm 的 `tableCellPadding` 与 `tablePipeAlign`;原先投票的解析器不认 GFM,表格从未计票),分隔行随之重写(如 `|---|` 成 `| - |`)。
- 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,不报进程号);原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。2026-10-14 起(用户报:已开着时再打开文件弹“已在运行”而不是新标签,各应用同此)第二份把文件交给运行中的那份后退出:持锁的那份在 127.0.0.1 开一个门(`instance::Door`,随机端口),端口、进程号与 32 字节随机令牌写在数据目录的 `instance.door`(先写临时文件再改名,Unix 为 0600);第二份先按当前目录把启动参数变成绝对路径,连上后先发令牌,门答“开”或“拒”之后才发各路径(`ely-ipc` 的帧,Unix 为原字节,Windows 为 UTF-16),收到回执即以 0 退出、不弹框;门未开、连不上、令牌被拒(旧的门文件)或答“开”之前断开时 10 秒内重试,路径发出后不重试(不会开两次),仍不成即照旧报“已在运行……未收下文件”及原因。门为每个来者各开一个线程,一个来者从连上到答完共 5 秒(零星补字不续期),令牌前的帧至多 64 字节、路径帧至多 128 KiB(`ely_ipc::read_capped`,超出即在分配前拒绝)。Windows 上第二份先 `AllowSetForegroundWindow` 让持锁的那份能到前台。每个应用的启动(`ely_ui::run`,`blank` 的 ElyMail、ElyAgent 同)在主窗口开出后装上交接的接收者(`opening::home`):先把主窗口带到前台(最小化的先复原),再把文件放进 `ely_ui::on_open` 的同一队列(各应用不改),然后才回执;主窗口已不在即不回执,第二份照报未收下。不带文件的再次启动只带到前台。主窗口关闭即退出应用(只剩设置窗口时进程原会一直持锁,却开不了文件)。macOS 经访达打开文件本就交给运行中的那份。`instance` 的单测以两个进程核对交接与伪造令牌被拒;`tests/scripts/one-instance.sh`(macOS)核对第二份 ElyDoc 交出文件、第一份开出它且工作目录不动,本次未在 macOS 上跑;Windows 上以真窗口的 ElyMark 核对(第二份 132 毫秒退出,第一份多一个标签)。
- 2026-10-10:ElyMark 的链接(M-05,我定,同常见编辑器):可视模式单击链接是编辑,Cmd 单击(Windows、Linux 为 Ctrl)交应用;预览单击即交应用;应用照 ElyPDF:http、https、mailto 交系统打开,其余提示拒绝。
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
- 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里借用开发克隆 `~/Documents/GitHub/ElyOfficeSuite` 的引擎(`ELY_SHARE`;2026-10-11 起:从零构建 LibreOffice 超过 act_runner 默认的 3 小时,v0.1.3 的 Mac 任务被杀、无 dmg),Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage、deb、rpm 与 Arch 包(引擎构建与打包都经 `packaging/linux/container.sh` 进 Ubuntu 26.04 容器,WSL 里须有 docker;`ELY_SHARE` 的目录一并挂进容器),都由 `packaging/stage.sh` 分装引擎;本提交的源码包只由 Linux 上传(`packaging/source.sh`;macOS 照建供 `release-macos.sh` 核对,不上传,两端并行时不重复;Windows 的检出把符号链接写成文件,不出);重跑时所传文件有任一同名的已在发布里即失败、什么也不传,写明须先删去哪些(重建的字节不同,更新清单的散列会对不上已在的 dmg);更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。
- 2026-10-11:gpui 的 Windows 窗口关闭(W-03 冒烟查出)。`gpui-pre-windows` 0.3.8 在窗口关闭时报三条错:系统已销毁窗口(ElyTree 的关窗交给系统)后 Drop 仍对旧句柄 `RevokeDragDrop` 与 `DestroyWindow`(拖放处理器因此不释放,连带窗口状态);销毁时的失活回报排在异步任务里,执行时窗口已移除(“window not found”);退出后后台任务向已销毁的平台窗口投递(`Invalid window handle`)。修复在 `vendor/gpui-pre-windows`:`WM_DESTROY` 时记下已销毁、在句柄仍有效时撤销拖放、去掉失活回调,Drop 见已销毁即不再动句柄;平台窗口已不在时投递失败只记 debug(应用已退出),其余投递失败照报。三个应用关窗与退出的日志无错。
- 2026-10-11(用户报:Windows 启动带黑色命令行窗口、“打开方式”里无图标、点 X 不退出、表格全黑无网格线):四处根因。命令行窗口:应用是控制台子系统;发行构建改为 `windows_subsystem = "windows"`(开发构建照旧有控制台看日志),子进程(两个 worker、Git、`cmd start`)一律经 `ely_ipc::windowless` 以 `CREATE_NO_WINDOW` 启动。图标:exe 里只有 gpui 的清单资源;各应用共用构建脚本 `packaging/windows/icon.rs`,把 `assets/icons/<应用>.ico` 编成资源 1(gpui 的窗口图标也取资源 1)。点 X 不退出:窗口其实弹了“保存更改”询问。`ely.xcd` 深色方案的 `CalcNotesBackground` 与浅色不同,Calc 在配色变化时改写批注样式并记一步撤销(`ScDetectiveFunc::UpdateAllComments`),未动的表格切到深色即算已修改;两套方案的批注底色改为同一值(LibreOffice 默认的浅黄),切换主题不再改文档,三端同此(2026-10-18 起只剩浅色方案,`sheet.rs` 的 `the_scheme_is_no_edit`)。全黑:Win32 的虚拟设备没有 `SetSizeUsingBuffer`(基类默认返回假,断言在发行构建里不在),给客户缓冲区的设备停在 1×1 像素,Calc 图块只画了一个像素,放映图层同空;补丁 `0044-lok-win32-paints-then-copies.patch` 让 Windows 走 macOS 的路:按全尺寸画进设备,再拷进客户缓冲区(`doc_paintTile` 与 `SlideshowLayerRenderer`)。Writer 自设设备尺寸,原本不受影响。`tests/scripts/lo-patches.sh` 的临时库也设 `core.autocrlf false`(原先在 Windows 上恒失败)。
- 2026-10-11(用户报:Windows 上标题栏的最小化、最大化、关闭三个按钮都按不动):gpui 回答系统的命中测试(`WM_NCHITTEST`)时按绘制次序取第一个含指针的窗口控制区,标题栏的拖动区先于其中的按钮画出,按钮上一律答“标题栏”,系统把按下当作拖动窗口的开始,点击到不了按钮。修复在 `vendor/gpui-pre`(原样一个提交,其后是修复):按命中测试的次序取最上层的控制区,且只看未被遮挡的部分(同 `is_hovered`,盖在标题栏上的对话框挡住其下的按钮)。实测(发行构建,DPI 感知的探针移动真实指针):自右向左依次答关闭、最大化、最小化、拖动;按下最小化即最小化,最大化即最大化,关闭即退出、worker 随之结束;v0.1.3 的安装版同一探针处处答拖动。该回调只有 Windows 平台调用。
- 2026-10-11:Linux 标题栏按钮(核查 Windows 按钮问题时查出)。macOS 的红绿灯由系统画,不受影响;Linux 为客户端装饰,按钮由组件库画在标题栏的拖动区里,拖动区对任何按下都准备移动窗口,按住按钮时指针动一像素即开始移动窗口,合成器接管指针,松开到不了按钮,点击落空。组件库 3eb4d34:`drag_region` 只在按下未被里面的按钮认领(按钮以 `prevent_default` 认领)时才准备移动;`shell/title_tests.rs` 核对晃动的按下点得动 Linux 的关闭、标题栏空处照样移动窗口(去掉修复时前者失败)。未在真 Linux 桌面上点过。
- 2026-10-11(用户报:ElyTree 改动列表里文件名长时盖住行尾的打开按钮):组件库 bd01470:`ChangesList` 行里的文件名原为不缩的盒子,长名越过行宽压在按钮下;现可缩、以省略号结尾,先于按钮让位;`git/tests/narrow.rs` 核对 260 宽时文件名止于按钮之前(原布局时失败)。
- 2026-10-11(用户定:ElyTree 状态栏去掉 Git 的版本与路径那一项):所用 Git 的版本与路径只记日志。同时查出 Windows 上 `std::fs::canonicalize` 给出 `\\?\C:\…` 形式,仓库根、Git 目录、工作树与文件的真实路径一律改用 `dunce::canonicalize`(能写成普通路径即普通路径,同一文件仍是同一真实路径),状态栏的仓库路径随之为普通形式;ElyTree 的单测在 Windows 上由 44 个失败降到 8 个,余下 8 个改前同样失败(如写入 Windows 不许的文件名),未处理。
- 2026-10-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 地址取。
- 2026-10-11(用户报:Omarchy 上六个 AppImage 都打不开):`gpui-pre-platform` 对 `gpui-pre-linux` 关了默认特性,Wayland 与 X11 只经它自己的 `wayland`、`x11` 特性打开;工作区原只开 `font-kit`,Linux 上 gpui 退成无头平台,日志照写“window open”,合成器里从无窗口。工作区的 `gpui_platform` 加开两者;`tests/scripts/linux-backends.sh` 核对特性,在 Hyprland 上核对 Wayland 与 X11 两路都映射出窗口、截图里有均匀的深色页面,tesseract 读出起始页的 “Open a repository”(不交缓冲的窗口 Hyprland 不映射;不画字形时读不出)。
- 2026-10-11:Linux 的 inotify 把打开文件报成事件(notify 的 `Access`),macOS 的 FSEvents 不报;四处文件监视原把任何事件当作改动,重读本身又触发下一次:Omarchy 主题与通用设置在第一次改动后、ElyTree 打开仓库起(`git status` 读仓库),空闲时每 250 毫秒重读。四处都经 `ely_ui::watching::only_read` 略去读取(打开、读、只读关闭),写后关闭照算改动(经内存映射的写入只报 CLOSE_WRITE,不报 MODIFY);`tests/scripts/linux-idle.sh` 以 inotifywait 从外面数空闲时的读取与 git status 次数。
- 2026-10-11(用户定):Linux 的引擎、应用与包都在 `ubuntu:26.04` 容器里构建(`packaging/linux/Dockerfile`,钉摘要;glibc 2.43),不在本机(Arch 的 glibc 更新,建出的程序在 Ubuntu 上起不来)。`packaging/linux/container.sh` 以本人身份运行,Cargo 的目标与家目录另放 `target/ubuntu*`;`stage.sh` 取 `CARGO_TARGET_DIR`;Git 2.56 用自己的 `target/` 链接其 Rust 部分,`build-git.sh` 为它去掉该变量。`tests/scripts/linux-glibc.sh` 核对每个动态链接的 ELF(静态的 git-lfs 不算)。
- 2026-10-11(用户要求:Linux 不只 AppImage):`packaging/linux/package.sh` 由同一个 AppDir 出四种包:AppImage,以及经 nfpm v2.47.0(钉散列)的 deb、rpm、Arch 包。包把 AppDir 的 `usr` 装到 `/opt/ely-office/<应用>`,`/usr/bin/<应用>` 为链接,桌面项与 512 像素图标在 `/usr/share`。依赖由 AppDir 里每个 ELF 所需而未随包的库,加 gpui 按名载入的 Vulkan 与 Wayland 客户端库得出:deb 与 Arch 按 `packaging/linux/depends.tsv` 换成包名(表里没有即打包失败),rpm 直接依赖库名(`libxcb.so.1()(64bit)`)。同级应用多找一处 `/opt/ely-office/<应用>/bin`(ElyMark 打开 ElyTree)。LibreOffice 的 cairo 由 meson 构建,内核直接拷其构建目录的产物,RUNPATH 仍指向构建树里的 pixman 与 fontconfig(别的机器上找不到);`build-libreoffice.sh` 在 Linux 上把指向源码树的 RUNPATH 改为 `$ORIGIN`(patchelf)。
- 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。
- 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。
- 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格(2026-10-15 起屏上读作换行,见下),嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、Line truncated
- 2026-10-12(用户报:Windows 上 ElyMark 的最近列表显示 `\\?\C:\…`):凡取真实路径一律 `dunce::canonicalize`(ely-files 的会话登记、ely-lok 的内核目录与配置目录、ElyMark 的文件夹、最近列表、会话与标签比对;ElyTree 原已如此):Windows 上能写成普通路径即普通路径,他处同 `std::fs::canonicalize`。只供读写的路径照旧保留 `\\?\`(`ely_files::save` 的落盘与同文件判定:在 250 字的文件夹里,普通写法的暂存文件名越过 260 而写不进),保存后交给会话与 `Saved` 的路径以 `dunce::simplified` 整体判定后为普通写法;ely-office 另存的目标先以完整路径求出,再以 `dunce::simplified` 整体判定能否写成普通路径(先简化文件夹再接文件名时,结尾带空格的名字会被 Windows 削成另一个已打开的文件)。`ely_files::recent` 读入时以 `dunce::simplified` 去掉旧版写下的 `\\?\` 并去重,下次写入即为普通路径。
- 2026-10-12(用户报:ElyTable 的网格上一直是文字指针):`LokView` 原把指针写死为文字指针。现不按键的鼠标移动也交内核(悬停,不算编辑、不加修订),内核以 `LOK_CALLBACK_MOUSE_POINTER` 回报 CSS 指针名(`vcl` 的 `gaLOKPointerMap`),随事件批次到视图,换成 gpui 的指针:单元格上为箭头,编辑单元格或文字时为文字指针,链接为手;未知的名字记警告并用箭头。
- 2026-10-13(用户定):ElyMark 以 Typora 1.14.10 为对照(R4;安装包与解包放在 `TyporaRef`,不入库):可视模式仍是按下的块以原文编辑;语法照 Typora 默认(`:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块、`[toc]`、front matter 框;`==`、`^`、单个 `~` 照 GFM);快捷键照 Typora(Ctrl+1–6 标题、Ctrl+0 段落、Ctrl+/ 源码、Ctrl+Shift+=/-/0 字号、F8 专注、F9 打字机);右键菜单照 Typora 的布局,剪贴板与格式为图标行(组件库 `Menu` 新增)。Typora 与 GitHub 读同一份文件不同时随 GitHub(GFM,§14.3):表情名取 GitHub 现行的 gemoji(`emojis` 库,`:cricket:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `<code>` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`</br>` 照 HTML5 也是换行,GFM 标签过滤的九种(`script`、`style` 等)照原文显示,提示块的标记读解析后的纯文字;Typora 只决定哪些扩展显示。导出、纯文本与富文本复制经组件 `MarkdownReader` 照可视读每个段落、标题、单元格与 HTML 块(各自一套新栈,块尾关闭未闭合的标签):脚注在原处、写作 `[号]`,链接不带标题(可视不显示),取代 C14.14 的页末脚注与 HTML 一律转义。审查改用 gpt-6-astra,effort max,fast。
- 2026-10-14(用户定):Typora 是参照,不求一一相同;ElyMark 的验收与评审只看每项功能是否可用,缺 Typora 的某行、次序或措辞不同不算缺陷。
- 2026-10-14(用户要求照 Word):ElyDoc 的右键与表格(R5)。右键菜单由 Ely 按目标自建(`apps/ely-doc/src/menu.rs`),开在所点之处(`LokView` 记下右键点,`take_menu` 一并交出):目标、各项可否与拼写建议都取自内核这次给的菜单(`ely_lok::menu`:有 `WrapMenu` 为图片,有 `TableInsertMenu` 为表格,否则正文;缺 `enabled` 即报错),命令只经 `COMMANDS`。剪切、复制、粘贴为图标行(组件库 `Menu::buttons`);正文为链接…、新建批注;表格另有插入、删除、合并、拆分、平均分布;图片为文字环绕(嵌入、四周、上下、浮于文字上方;内核无紧密、穿越、衬于文字下方的对应项,不列)、置于顶层、置于底层、插入题注…,新建批注灰显(内核的图片菜单无批注)。链接…随内核的 `.uno:InsertHyperlink`:须选中可编辑的文字,未选中时灰显(Word 未选中也可用;照做须改内核,未做)。拖动表格边线:worker 把 `LOK_CALLBACK_TABLE_SELECTED` 读成 `ely_lok::table::Table`(列与行边线、上下限),视图在边线上显示调整指针(命中范围不超过与邻线距离的四分之一,矮行的中间仍落在文档),按住时记下读到它的批次的修订与按住处的偏移,拖动时画参考线(出视图照跟),松开以 `.uno:TableChangeCurrentBorderPosition` 发一条命令(一步撤销),原地松开不发;其间文档变了即以过期拒绝(`view/borders.rs`)。内核在 LOK 下不处理边线的指针与拖动(依赖 LOK 没有的标尺)。属性栏在光标进表格时把表格组排在最前(组件库 9ff403d 起按标题记折叠)。Windows 真窗口逐项核对过(右键三种目标、插入行列、拖动列宽行高、撤销、保存后重开);拼写建议在 Windows 上内核不给(P3-01),未核对。
- 2026-10-14(用户要求核对 ElyTable 与 ElyShow):ElyTable 的右键(R5.5)。菜单机制移入 `ely_office::menu`(取内核这次给的菜单、开在所点之处、剪贴板图标行、子菜单可否随其中各项),ElyDoc 与 ElyTable 共用;`ely_lok::menu` 的目标另有单元格(有 `.uno:InsertCell`)与 Writer 之外的绘图对象(有 `.uno:ArrangeMenu`)。网格照 Excel:插入 ▸、删除 ▸、清除内容(`.uno:ClearContents`,同 Delete 键,不弹内核的删除内容对话框)、合并、拆分、设置单元格格式(打开格式面板);对象为置于顶层、置于底层;编辑单元格时只有剪贴板行。行列表头由 Ely 自画,内核不给菜单:右键先选中该行或列(已选的整行整列含它即不动,同 Excel)。选中对象或正编辑图形里的文字时,绘图外壳取代了单元格外壳,`.uno:SelectRow`/`.uno:SelectColumn` 原无处执行而被丢弃;补丁 `0046-lok-calc-headers-leave-drawings.patch` 让 LOK 下视图外壳接下这两条:先结束图形编辑(键入的文字留下)并取消选中,再选行列,同 Calc 自己的表头(`DrawDeselectAll`),菜单为插入、删除、清除内容、行高或列宽、隐藏、显示。ElyTable 每一项的可否都取自内核对它所发的那条命令此刻的状态(内核的菜单只定目标):补丁 `0045-lok-command-states.patch` 加 `getCommandValues(".uno:CommandStates?commands=…")`,先冲出调度器、再逐条问调度器(同菜单栏),未知的命令即报错(`ely_lok::menu::runs`);LOK 推送的状态只有固定的一组,且在空闲时才更新。因此受保护而许插入行列的工作表照样可插;行高、列宽只在文档只读时禁用(内核的规则),受保护时由内核拒绝并报出其错误。ElyTable 无批注界面,不列批注。
- 2026-10-14:ElyShow 的右键(R5.6)。画布右键照 PowerPoint 按目标出 Ely 的菜单(`apps/ely-show/src/menu.rs`,共用 `ely_office::menu`);`ely_lok::menu` 的目标另有幻灯片(有 `.uno:SlideLayoutMenu`)与 Writer 之外的图片(有 `.uno:ChangePicture`)。幻灯片为版式 ▸、新建批注;编辑文字为项目符号、编号、新建批注;图形与文本框为组合 ▸、置于顶层 ▸、置于底层 ▸、新建批注;图片另有替换…、裁剪;表格为插入 ▸、删除 ▸、合并单元格、新建批注;剪贴板同为图标行。每项可否取自内核对其命令此刻的状态(补丁 0045):整表选中时可插入行列,删除与合并要光标进单元格(内核的规则)。Impress 的 `LOK_CALLBACK_TABLE_SELECTED` 由 boost 属性树写出,数值与真假都是文字,空列表是空串,`table::read` 照收;拖动表格边线走 R5.3 的同一路径(`.uno:TableChangeCurrentBorderPosition`,偏移为 twips,内核换算)。补丁 `0047-lok-impress-table-edges.patch`:Impress 原只给内侧边线的上下限,行的下限是布局器写死的 600(上游待办),参考线与落点不符;补丁让两轴都带首尾边线的上下限(`leftMin`、`leftMax`、`rightMin`、`rightMax`,列只限从左到右),内侧行线取行自身的最小高度,`table::Lines::ends` 收下,有则按它夹住(内侧线也直接用内核的上限),Writer 不给时照旧留 40 twips;已在界外的线从不逆着拖动方向移动,原地按下松开不发命令;最左列边线原什么也不做却记一步撤销(上游待办),LOK 下改为改首列宽并随边线移动表格,同一步撤销,桌面的拖动手柄不变。正编辑的单元格按编辑器量最小高度,略大于内核真正允许的(约 2 像素),新表的行因此不能再缩,参考线与落点仍一致。正编辑表格单元格时在幻灯片空白处右键,内核只结束编辑、不给菜单,再按一次才有(BUGS P3-11)。
- 2026-10-14(Gitea #1,Linux Wayland 报):ElyShow 左侧缩略图条滚轮不滚。根因:列表包在 `ContextMenu` 宿主里,宿主无尺寸,列表随内容长高、从不溢出,滚轮无处可滚(右栏正常);宿主改为 `size_full`(同 ElyPDF 的三处),`tests/strip.rs` 以 24 张幻灯片核对滚轮滚到最后一张。各应用其余 `ContextMenu` 宿主已核:ElyPDF 三处本有尺寸,ElyTable 的工作表标签条与下拉选项不是滚动列表。
- 2026-10-14:ElyMark 的菜单与快捷键(R4.9,MARK-15)。项与键取自 Typora 1.14.10 主进程字节码的字符串表(`.work/typora-main-menus.txt`)与其 `zh-Hans.lproj/Menu.json`;实际大小在字节码里有 Ctrl+Shift+9 与 Ctrl+Shift+0 两串,照用户所定用 Ctrl+Shift+0;R4.11 真窗口查出 Windows 的中文输入法把 Ctrl+Shift+0 留作热键(注册表 `HKCU\Control Panel\Input Method\Hot Keys\00000104`),应用收不到,Windows 另绑 Ctrl+Shift+9(菜单显示它)。表格是“段落”的子菜单(照 Typora),顶层不再有“表格”。原 Ctrl+1/2/3 切模式、Ctrl+=/-/0 字号、Ctrl+Shift+F 专注让给 Typora 的键;可视、源码、预览留在“视图”菜单,无键。Ctrl+/ 另绑在 `ElyEditor` 上(gpui 同深度后绑者胜),盖过代码编辑器的 `//` 注释切换(Markdown 没有这种注释);`MarkdownDocument` 的键上下文只在可视模式有。带 Shift 的标点与数字一律写所出的字符(`secondary-{`、`secondary-+`、`alt-%`):三端的 gpui 都把 Shift 加该键报成字符并去掉 Shift(Windows 见 `gpui-pre-windows` 的 `get_keystroke_key`);R4.9 原在 Windows 写键名,真键盘按不出(R4.11 真窗口查出)。Ctrl+Enter 在打开的块里原是输入框的 Submit(文档不处理),改绑为下方插入行。拒绝一律成信息提示:当场的由 `Desk::edit` 说,后台读出的经 `DocumentEvent::EditRefused`、`view::Refused` 到宿主。菜单打开时,打开的块把焦点借给菜单,块与选区都留着,菜单命令照样作用于所选(组件 6d90351)。复制为 Markdown 与粘贴为纯文本只作用于文档:焦点在文档外的输入框(查找框、对话框)时交给该框自己的复制、粘贴。表格子菜单末项为删除表格(组件 `TableEdit::Delete`,一步撤销,插入点不在表格里即拒绝;在打开的块里删除后焦点回到文档,撤销键照用,组件 6c2d28c)。Typora 有而组件库暂缺的不进菜单:行内公式、上下标、注释、提示块子菜单、任务状态、列表缩进 Ctrl+[/]、代码工具。
- 2026-10-14:ElyMark 的右键菜单(R4.10)。项、次序与显隐规则取自 Typora 1.14.10 的 `resources/window.html`(`#context-menu` 与其子菜单)与 `frame.js` 的显隐函数,记在 `.work/R4.10/typora-context-menu.txt`。菜单在 `view/menu.rs`,`MarkView` 自持(`ely_office::menu::Offer` 与 `ContextMenu::manual`):可视模式由组件的 `DocumentEvent::Pointed` 开在指针处,源码与预览由视图自己的右键开。剪切、复制、粘贴、删除所选范围为图标行,按下即把其键交回菜单打开前的焦点(`dispatch_keystroke`),与键盘同一路径;无选区时剪切、复制、删除灰显。其余项经各自的动作,显示所绑的键。组件 013f142、1b5fb07:文本框与代码编辑器右键落在选区里时保留选区,否则把插入点移到所点处;可视模式打开的块右键不写回、不关闭,目标在后台按全文读出(块外的引用定义照样解析);`MarkdownDocument::delete_image`(删除图片,一步撤销,无图片即 `EditRefusal::NoImage`);可视无块时 Ctrl+V 粘贴文字(原只收图片,文字被丢弃)。复制为 HTML 代码的图片照原地址(`Pictures::Written`,同 Typora),复制为富文本照旧内嵌。“编辑”菜单的复制项照 Typora 次序:纯文本、Markdown、HTML 代码、富文本。Typora 有而不做的:缩进与减少缩进行(可视只在打开的块里有)、复制表格、代码自动缩进、图片上传、缩放、移动与语法切换、拼写、表情复制、检查元素。gpui 的测试只发按下,菜单在 Enter 松开时选中,测试另发松开(`blocks/context.rs` 的 `choose`)。第 1 轮后:右键的目标按所点的字节读(组件 79e94f7),保留的选区只供剪贴板,删除图片删所点的那张;三种“复制为”在后台转换,界面线程只写剪贴板,新的复制取代未完成的旧复制(`Desk::copying`);`ely_office::menu::Offer` 只在首次绘制时把请求交给宿主(宿主按元素存的“已开”记录随标签切走而丢,切回会重开),ElyDoc、ElyTable、ElyShow 同受其益。第 2 轮后:转换完成时剪贴板已不是开始时的内容(此后有任何复制,含编辑器自己的 Ctrl+C)即丢弃这次结果,同内容的再次写入分不出;打开的块里只认最后一次右键(组件 2b8bd9f),所点字节对回所画字节的读法逐段只对齐一次(原逐字重读,8000 字的段落要近 2 秒)。第 3 轮后:剪贴板的比对另含其 HTML(gpui 读剪贴板不含 HTML,纯文字相同而富文本不同的后来复制原被盖掉;`ely_platform::clipboard::html`);组件的计时测试改用 `web_time::Instant`(组件 e07fd34)。
- 2026-10-14(用户报:整个软件中文显示问题很多):中文显示(R4.13)。Windows 上 gpui 以用户区域(本机为 en-US)排版全部文字,DirectWrite 按日文为汉字选回退字体,日文字体缺的简体字再落到另一字体,同一个词里字形与粗细不一(ElyTree 的“打开仓库”:“打开”粗、“仓库”细)。`vendor/gpui-pre-windows` 的 `text_locale`:用户区域不是中日韩时,改用 Windows 语言列表(WinRT `GlobalizationPreferences::Languages`,Win32 的界面语言只给 en-US)里第一个中日韩语言排版(日志 `text: laid out as …`);在 Windows 上打中文须把中文加进该列表,所以它就是用户读的汉字。只换排版的区域,字体族名与字体列表仍按用户区域。读不出语言列表即记警告、照用户区域。Ely 的界面语言是中文时(设置或 `ELY_LOCALE`),`ely_ui::i18n::install_as` 经 `gpui_windows::set_text_language` 让排版随它,不论 Windows 区域(日文区域下“打开仓库”原为 Yu Gothic UI 600 与 Microsoft JhengHei UI 700 两种字);语言下次启动生效,故启动时设一次。组件库 6df9ab0:Ely 用到的组件里写死的英文(下拉的“Choose…”、清除、关闭、移除、取消固定的提示,公式补全的“No suggestions”,设置里字号的重置提示与示例句)改走内建中英目录。a4bf696:查找框(计数、三个选项与上下一处的提示、替换与全部替换)、排序与筛选(添加规则、满足全部或任一、条件、升降序、添加排序)、密码框的显示与隐藏提示同此;筛选条件以目录键为值(`Test::key`);ElyMark 的删除图片按右键所点的字节(`delete_image(at)`)。仍为英文的是内核的文字(LibreOffice 界面语言固定英文:幻灯片占位“Click to add Title”、母版“Default”、配色名、数字格式代码“General”、函数名),以及启动失败对话框(出现在读入语言之前,中英并列)。
- 2026-10-14(用户定:dmg 不再显示未公证):macOS 发布以 Developer ID Application 证书签名、经 `notarytool` 公证并钉票。证书与公证凭据(`xcrun notarytool store-credentials`,App 专用密码只在钥匙串里)装在构建用的 Mac 上;发布流程的 macOS 任务从仓库变量 `ELY_SIGN_IDENTITY`(证书全名)与 `ELY_NOTARY_PROFILE`(凭据名)取得,`ci.sh` 在 macOS 上缺任一即失败,不出未公证的包。本机手动出包同样设这两个变量再跑 `packaging/macos/release.sh`。2026-10-19 起:macOS 任务在 `macos-signing` 运行器上跑(Mac mini 上以 LaunchAgent 在登录会话里运行);`macos-arm64` 运行器是以 `UserName` 运行的 LaunchDaemon,其会话的钥匙串搜索列表里没有登录钥匙串,codesign 报“no identity found”(v0.1.8 实测)。两个变量此前未设,0.1.6 至 0.1.8 的 macOS 任务因此在 `ci.sh` 的检查处即失败,已在仓库变量里设好。强化运行时下 LibreOffice 的 UNO 桥以 `MAP_JIT` 映射虚表,`ely-lok-worker` 须带 `com.apple.security.cs.allow-jit`(`packaging/macos/lok-worker.entitlements`,同 LibreOffice 自己的 `hardened_runtime.xcent`;其余几项不需要,实测);0.1.5 首发的三个 LOK 应用缺它,worker 在首个请求时中止(R6.5)。
- 2026-10-15(用户报:ElyMark 点表格整个表格变成源码,应在格内编辑;用户定:格内显示该格原文):表格单元格编辑(R6.1)。组件库 2594b3f:可视模式按下表格只打开所按的那一格,在格内原位以单行输入框显示该格的 Markdown 原文(`**粗体**` 带记号),表格其余照常画出;按在格内文字上即落在所按的字,按在格内空白落在格末;以表格边缘、键盘或上下键从邻块进入的表格块打开其首格或末格;Tab、Shift+Tab 到下一格、上一格(跨行),上下键到上下一格,越出表格即到邻块;回车与 Esc 写回,写回为一步撤销;格内键入的 `|` 写回时转义为 `\|`(表格编辑读到的也是转义后的文字);格开着时做表格编辑(插删行列、整理等)先写回该格再执行,格随之关闭。列表、引用里的表格同此。表格绘制移入组件 `render/table.rs`,按格的空白处也带出所按的格(`Place::Cell`)。第 1 轮后(组件库 6a3f348):开着的格里的按下只给其输入框,插入点落在所按处、仍是一步撤销;格末单数的反斜杠写回时补成一对,不吞下一格的竖线;查找在可视模式里打开匹配所在的格并选中,跨格的匹配转到其行;格开着时右键所按的字节随格内键入的竖线换算;在未开的格的空白处右键,目标为该格末尾。第 2 轮后(组件库 2b6bd1d):格末的反斜杠只在紧接竖线时补成一对(`| a\ |` 的反斜杠后是空格,原样写回,只打开不键入不改一个字节);改首格后仍认得所在表格(无外侧竖线的表格首格始于块首,原被当作写掉了块),Tab 与按邻格照常;开着的格的空白处左键把插入点移到格末、右键以格末为目标,都不写回,仍是一步撤销。第 3 轮后(组件库 f1771b3,最后一轮,修完不再审查):无外侧竖线的行里清空首格或末格时写回一个竖线(`| | beta`、`alpha | |`),列数不变;插入点照写回的转义文字报出(键入竖线后不再落在多字节字中间);改过一格后右键邻格照常给出目标;长文(后台解析)里改格后 Tab 等到解析完成再打开下一格。
- 2026-10-15(用户报:二级菜单选不到,往子菜单移动时经过下面的项会弹出它们的子菜单,七个应用都如此;用户定照桌面系统的悬停意图做:安全三角加打开与关闭的延时):级联菜单(R6.2,组件库 d4fdb69,`ContextMenu`、`DropdownMenu`、`MenuBar` 共用的面板)。菜单面板挡住其下的元素(原先子菜单被挤回、盖在父菜单上时,被盖住的父菜单行照收悬停);子菜单右侧放不下且左侧放得下即开在所在行的左侧,从右滑入,不盖父菜单的行(两侧都放不下时贴窗口边,仍被面板挡住);指针停在有子菜单的行上 150 毫秒才打开;子菜单开着时,指针落在“上一位置与子菜单近侧上下两角”构成的三角形里的另一行,等 300 毫秒,指针仍在该行才切换,其间到达子菜单即作废;三角形外的行即刻标记。键盘与按下照旧即刻打开。实现在组件 `menus::aim`。第 1 轮后(组件库 5a53c08):指针离开一行(到边距、分隔线、禁用的行或菜单外)即作废其延时,回到该行重新计时;菜单在别处重开即忘掉旧的悬停;图标按钮行同样受三角形保护。
- 2026-10-15(用户报:ElyMark 的最近列表清不掉):侧栏“清除最近文件”(R6.3,98692cb)写空 `recent.json`(`ely_files::recent::clear`),失败即提示。会话恢复的标签不把文件加回列表(`Desk::remembered` 预先记下),清空在重启后照旧;显式打开照样加入,已开着的文件(按文件身份,含硬链接,`Sessions::is_open`)只切到该标签,也加入列表。
- 2026-10-15(用户报:ElyMark 代码块后的“你好↵测试”离开输入框后成了“你好 测试”):单个换行照 Typora 的默认(编辑时保留,导出与打印不保留):组件库 93efa7f 起 `MarkdownReader::new`(屏上:可视、预览、右键与命令的读法)把软换行读作换行,`MarkdownReader::joined`(输出)照 GFM 读作空格;标题的大纲与目录文字照旧一行。ElyMark 的 HTML、PDF、富文本复制与纯文本导出用 `joined`(R6.7)。组件库 c31d092 与 26af4be:画出成一个换行的 CRLF 对回其起点(原先按在 CRLF 行末键入落在 CR 与 LF 之间),行内代码照留两字则逐字对回。
- 2026-10-15(用户报:ElyMark 源码模式“测”后的插入点画在“测”的中间):组件库 `CodeEditor` 原按“列 × m 的宽”放插入点、选区、按下处与输入法框,中日韩字与表情约宽两格。组件库 9fb607b 起按 gpui 排好的这一行定位(`editor::measure`,含着色带来的字重),块状与下划线插入点取所在字的宽;标尺与缩进参考线仍按列(R6.8)。上下移动仍按字数记目标列。cd61653:按下处取最近的字首或行末(gpui 的 `closest_index_for_x` 越过末字字首即给行末);上次绘制得出的括号与配对位置只属当时的文字(`Buffer` 每次改动换一个戳),输入法改字后、下次绘制前的测量不用它们(原先把新字切进不同字体段,DirectWrite 崩溃)。
- 2026-10-15(用户报:ElyMark 三、四级标题不显眼):组件库 5954132 起标题字号随正文按 Typora 的倍数(一至六级 2.25、1.75、1.5、1.25、1、1),一律加粗,六级为次要色,行高为字号的 1.3 倍(原为固定的 24、20、16、14 像素,正文 13,三、四级只是中等字重)(R6.9)。90bd37b:粗体与标题同一字重(原粗体为半粗,标题里的粗体反而更细,正文粗体随之由半粗改为粗,同 Typora);标题行高按自身字号取 1.3 倍。Windows 真窗口核对(`.work/r69`):可视模式各级标题分明、“你好↵测试”两行,源码模式“测”后的插入点落在两字之间。
- 2026-10-15(用户报:macOS 打开一个文档,程序坞里有两个同样的图标):LOK worker 在应用包的 `Contents/MacOS` 里启动 AppKit,系统按包的 Info.plist 把它也登记为常规应用(`lsappinfo` 里应用与 worker 都是 Foreground),程序坞多一个图标;内核自己从不设激活策略。worker 在内核启动 AppKit 之前(`Lok::hook` 打开内核之后、调钩子之前)把本进程主包信息里的 `LSUIElement` 设为真,AppKit 登记时即为辅助应用(无程序坞图标、无菜单栏,仍可建屏外窗口);钩子之后核对激活策略是辅助,不是即报错、不启动(`ffi/accessory.rs`);包外的 worker(`cargo run`、单测)无包可登记,策略为禁止,此时改设辅助(R6.12 第 2 轮)。原先在登记之后才改策略,审查实测 15 次启动有 11 次先登记为 Foreground 约 30 毫秒(R6.12 第 1 轮)。`tests/scripts/dock-macos.sh` 连续启动三个应用与包外的开发构建,从 worker 启动起采样其类型到首块图块,见 Foreground 即失败(R6.12)。
- 2026-10-15(用户报:ElyShow 编辑区滚轮与上下键不换页,三端):内核在未选中对象时把上下键当作滚动窗口(`fupoor.cxx`),Ely 的画布整页适合、无处可滚,滚轮同样。`LokView::turn_parts`(ElyShow 首次适合时调用)之后:整页可见时滚轮换页(`view/turn.rs`):滚轮的行按本平台每格的行数(`ely_lok::notch`:macOS 1,Windows 取系统设置,X11 与 Wayland 为 3)折成格,整格的行每格一页(快速连滚与一次多格都照格数);一串行里出现不足一格的(X11、Windows 的触控板)即为平滑滚动,此后到停顿前不再换页,像素(macOS、Wayland 的触控板)同按手势:一次手势换一页(满一格或 50 像素),惯性不算;有起止的手势(macOS)只到结束才算完,没有起止的停 200 毫秒即为下一次。X11 的滚轮与触控板同为行、gpui 不分,以整格开头的触控板滑动在出现小数之前会按格换页(已知限制)。未选中对象、未编辑文字时上下键换页。换页一律经有序输入队列交 worker(`TURN`):worker 按之前各输入回报后的状态判定,有选区、选中对象或插入点即把方向键照常交内核,否则当场切页(到首末页即停,同缩略图条的 `setPart`),视图在落地时换上 worker 所示的页,其后排队的键与点按都落在新页上(Tab 后紧接的 Down 移动所选对象;Down、Tab、Down 是换页后在新页上选中并移动);ElyShow 后台读回的当前页只在视图已无待落地输入时采用(R6.13)。
- 2026-10-16(用户定):合集安装包(R7.1)。macOS 一个公证的 `ElyOffice-<版本>-<架构>.dmg`,装首批六个已签名的 .app 与“应用程序”替身,用户拖入所要的(`release.sh` 的 `dmg` 与单个应用共用);无 Developer ID Installer 证书,不出 .pkg。Windows 一个 `ElyOffice-<版本>-windows-x86_64-setup.exe`(`packaging/windows/bundle.nsi`),组件页勾选应用,所选的各自的安装程序以 `/S` 运行,所以装法、位置、升级、卸载项与单装完全相同;某个退出码非零即停下并写明其名与退出码(2 为文件被占用或位置被占),之前装上的照旧。Linux 不出合集。单个应用的包、更新清单照旧。
- 2026-10-16(Gitea #6):设置窗口多出一行应用菜单(R7.2)。设置窗口沿用主窗口的外壳 `Shell`,Windows 与 Linux 上随之画出菜单栏(macOS 菜单在系统菜单栏,不受影响)与更新横幅。菜单栏与更新横幅只属主窗口:`Shell::secondary` 去掉两者,设置窗口用它;`settings/tests.rs` 量内容起点距标题栏底:只装菜单时主窗口大于零(macOS 为零),再有待下载的新版时更大,设置窗口为零。
- 2026-10-16(Gitea #4):装了宋体仍报缺少(R7.3)。内核按名找字体时比较 `GetEnglishSearchFontName`(小写 ASCII、去空格、中日韩本地名译为英文名,`宋体` 即 `simsun`),所以文档的 `宋体` 找到并用上 SimSun;缺字检查却比较原名与所用字体名,报为缺失。Linux 上内核以界面语言(英文)为 fontconfig 字体取名,文档里的本地名一律报缺。补丁 `0048-lok-fonts-missing-by-search-name.patch`:缺字检查同样比较检索名。实测(2026-10-16):Arch 容器装 ElyDoc 0.1.5 的 Arch 包与 simsun.ttc,40 行宋体五号、文档网格的 docx 报缺宋体,正文实为 SimSun(无此字体时为方框),与 Windows 同为一页;换上打过 0048 的内核后同一容器不再报缺、仍为一页。用户所说一页变两页未能复现,需其文件:十种宋体版式(44 与 45 行的文档网格、无网格、折行两端对齐、字符网格、粗体、小四、固定行距、1.5 倍行距)在 Windows 与 Linux 内核上页数、断行、分页逐行相同(`.work/r7/pages`),网格下每页 44 行、每行约 39 字,同 Word 的 A4 默认;差别若在,是该文件里 LibreOffice 与 Word 的排版不同,与平台无关。缺字检查测试:`navigate/tests.rs` 以随包的 DejaVu Sans 写作 `DEJAVU SANS`,只报真缺的字体。另见:Linux 上属性栏的字体框为空(字体列表只有 SimSun,文档写宋体),未改。
- 2026-10-16(Gitea #5):每个 PDF 打开即报已损坏(R7.4)。C11.8 把画图块与导出时 MuPDF 的任何警告都算作损坏;读页面时载入未嵌入的字体也发警告(中文文件里写名而不嵌入的宋体等:`non-embedded font using identity encoding`),合法的文件因此转为修复副本、保存一律另存。现在警告监视(`keep_warning`,打开时的扫描、画图块、导出与 Markdown 的 PDF 共用)只略去这一句与紧随其后的 MuPDF 重复计数(`... repeated N times...`);其余照算损坏,含内容的语法错误、无字体写字、坏的嵌入字体与图片流。读文字与搜索也受监视(坏图片读过即被缓存,之后画图块、导出与检查都不再报警告):其间遇到的损坏记入报告与该文档,下一次画图块或检查图片报出,报出之前导出图片与文字一律拒绝。监视开始时,MuPDF 尚未报出的重复计数(来自其余未受监视的读取)按它所重复的那句归类(`ctx->warn.message`):字体提示的重复略去,其余照算。`tests/output.rs` 核对:两个同名未嵌入字体的文件先读文字再画图块、导出图片与文字都不算损坏;无字体写字与 `BT ] ET` 的图块为损坏;`[/FlateDecode /ASCIIHexDecode]` 下校验和错的图片使两种导出都被拒、图块为损坏。先读文字或搜索后,这样的两张图片、颜色键掩码无效的一张图片、以及它在两个未嵌入字体之前或之后画出,画图块、导出与检查图片仍报损坏。
- 2026-10-16(Gitea #3):Windows 与 Linux 打印(R7.6)。`print_pdf` 改为异步:对话框在应用线程上,读页与出作业在自己的线程上(`print::off`),应用不卡。Windows:系统打印对话框(`PrintDlgEx`,Windows 11 显示为新式对话框),打开前把默认打印机的设置改成文档的纸张与方向(按打印机自己的纸型匹配(纸型须装得下页面,至多大 2 毫米,Linux 的 PWG 名同此);没有即自定义尺寸),设置须连同打印机名(`DEVNAMES`)一起交给对话框,否则对话框改用打印机自己的偏好(实测:Letter 文档印成 A4);所选页码范围照办,份数交驱动;勾“打印到文件”时文档输出为 `FILE:`,由打印子系统问文件名。驱动不支持所设纸张时会换成别的(实测“Microsoft Print to PDF”没有自定义尺寸,1000×600 点的页面换成 A4),所以出作业之前(`StartDoc` 之前)按打印机实际的纸张核对每一页:超出纸张半点以上(纸张尺寸至多舍入到整点,`print::ROUNDING`)、转过来也超出即报错,什么也不印(实测:600×842 点的页面原在 A4 上印出、右缘被裁)。页面由 Windows 自带的 PDF 渲染(`Windows.Data.Pdf`)按打印机分辨率分条画出,按原大从纸张的角上放(扣去不可打印的偏移),只在转过来才放得下时左转 90 度;渲染器的尺寸按 DIP 算、乘显示缩放,每次作业先试画一次量出倍数。`ELY_PRINT_TO` 时不弹对话框,经“Microsoft Print to PDF”写成文件(测试用)。`GlobalFree` 成功时返回空,windows-rs 读成错误码 0 的错误,须当作成功(否则打印报“操作成功完成”)。Linux:经 xdg 桌面门户的 `org.freedesktop.portal.Print`(`PreparePrint` 后带其令牌 `Print`,交出 PDF 的文件描述符),页面设置为文档纸张(标准纸型给 PWG 名,否则自定义毫米尺寸),边距为零,只许输出 PDF(`supported_output_file_formats`,否则对话框可选 PostScript,写出的却是 PDF);门户照原样交出所给的字节,对话框的选择由应用排定(`print/linux/sheets.rs`):GTK 设置里的页码范围(从零起)、奇偶页、倒序、份数与逐份、缩放、每张页数与排列,排到对话框所选纸张与方向上,每页为一个表单对象(带可打印批注的外观;带 NoRotate 的批注在转过的页面上绕其左上角转回、保持正立,同 MuPDF;外观的资源名避开页面已有的;可打印却没有外观的批注即报错,链接与弹出说明本无外观),单页按原大乘缩放从纸张左上角放,超出半点以上而转过来放得下即左转,否则报错;方向照 GTK 的写法(`reverse-portrait`,门户文档的页面设置同此);奇偶页只在 GTK 的文件与 lpr 后端时由 Ely 排(设置里有 `output-uri` 或 `lpr-commandline`),CUPS 后端自己把它交给 CUPS(`cups-page-set`,GTK 3.24.52 实测),Ely 再排一次即丢页;份数、每张页数等 GTK 留给应用,Ely 照排;裁剪框超出媒体框的部分不印(取两者的交);页码范围的开放终点 GTK 写作 -1(`1--1`),倒着的范围即报错;多页按格居中,再交内存文件(`memfd`);直接用 zbus:ashpd 0.13.13 的 `PageSetup`、`Settings` 缺任一字段即解析失败,真门户的回复常不带 `PPDName`。页面尺寸由 lopdf 读(CropBox,否则 MediaBox,继承、`/Rotate` 与页面自己的 `/UserUnit`)。打印不再按平台禁用(原 `print::BUILT` 与“此平台尚未构建”删去)。替身门户的测试核对选页、缩放、倒序与份数后交出的页面与位置,与只许 PDF。ElyPDF 的打印副本(`ely_print_copy`)按打印的规则画:可打印(不隐藏)的批注与表单控件,外观画进页面内容(位置照 MuPDF 自己的 `pdf_annot_transform`,与屏上一致;无外观的先由 MuPDF 生成,MuPDF 原只在显示时放在本地表里、不存入文件),其余批注与表单去掉,只留链接;系统的渲染器按屏幕规则画(不打印的照画,“打印但不显示”的不画,实测 Windows),画进内容后各渲染器都只见要打印的;仍画不出的即报错;`tests/output.rs` 渲染副本核对。真窗口(Windows):ElyDoc 的 Ctrl+P、Ely 的打印对话框、系统对话框、“Microsoft Print to PDF”的另存为,印出的 PDF 为文档的 Letter;系统对话框取消时不打印、不报错(`.work/r76/print.ps1`)。未验证:Linux 真桌面上的门户(只对替身门户核对);实体打印机。
- 2026-10-16(用户定):Windows 资源管理器的右键“新建”可建 DOCX、XLSX、PPTX 与 Markdown 文件(R7.9)。空白文件在 `packaging/windows/new`(`templates.sh` 以本构建的内核把空文件转存而得,无作者与日期;Markdown 为空文件),随所属的应用装在安装目录(列在 `.ely-installed` 里),`HKCU\…\.<扩展名>\ShellNew` 的 `FileName` 指向它。资源管理器只在扩展名有默认 ProgID 时列出新建项(实测:只有 `ShellNew`、或只在 `OpenWithProgids` 下时都不列);所以只在该类型没有任何程序处理时(HKCR 下无默认值,或默认值所指的类不存在:本机 Windows 11 的 `.md` 指向不存在的 `Markdown Document`)把默认值设为本应用的 ProgID(写在 HKCU,用户自己的 `UserChoice` 不动),已有的从不改动(装着 Office、WPS 时新建菜单用它们自己的项);没有处理程序的这四种类型从此由 Ely 打开。卸载只删仍是本安装写下的 `ShellNew` 与默认值。ProgID 的名称为“<应用> Document”(新建菜单与类型列显示它)。`tests/scripts/types.sh` 核对四种都有新建项,默认值只在判空之后写。
- 2026-10-16(用户定):ElyMark 打开的块保持原样(R7.5)。组件库 bff7ebc 起,可视模式里按下的块以原文显示在它合上时的框里(同样的边距与透明边框),字号、行高与颜色同合上时:段落、列表、引用等为正文,标题按其级数的字号、1.3 倍行高、加粗(六级为次要色),照原文写出的块(链接定义等)为等宽次要色;不再加深色底板与另一套边距,按下前后不挪位置。代码块、块公式与 front matter 仍有自己的底板(代码与 front matter 为等宽小号,front matter 为次要色,同合上时)。输入框的着色(源码模式同用)给标题与粗体同一字重(原为半粗,压过所设的加粗),六级标题里的字照为次要色(行内代码照为正文色,2e65b05),front matter 照 front matter 读(原读成分隔线加标题,键名变亮变粗)(1ff993d、962964e)。表格单元格照旧。打字机模式的居中另按两端余量判定(原只按块高与视图高,打开前后同高时首块不再居中)。同批带入组件库 cdc5ce8(标题栏拖动区里的控件按下归控件,Windows)。Windows 真窗口核对(`.work/r75`):段落、三级标题、代码块打开前后位置相同。
- 2026-10-18:Word 公式(R8.2,Gitea #9)。附件 math.docx 的六类公式逐类核对(本机无 Word,以文件所写为准):键盘输入、OMML(内核转为公式对象)、MathType(OLE,显示随带的 WMF 预览)、上下标、图片原本就对;差别在两处根因。其一,运行的 `w:position`(升降)原先只给文字,图片与 OLE 公式所在的运行失去它(25 张图片与一个 ½ 浮在行上):补丁 `0049-writer-inline-objects-move-with-their-run.patch` 把它作为固定升降写到对象的锚字符上(按锚字符自己的字号换算百分比,不扩展到其后的文字),版面让“作为字符”的对象随锚字符的固定升降移动(vertAlign 的自动上下标不动对象,同原样);各导出本就写出锚字符的升降,DOCX 往返仍为 `w:position`。其二,EQ 域原先只认 `\o`、`\s\do` 与注音,其余(如分数 `\f`)整个消失:补丁 `0050-writer-eq-field-parser.patch`(读法)与 `0051-writer-eq-fields-as-formulas.patch`(导入)把 EQ 域转为公式对象:`\f` 分数、`\r` 根式、`\s` 上下标、`\b` 括号、`\l` 列表、`\a` 阵列、`\i` 积分与求和求积;`\x` 与 `\o` 只留内容(无框、无叠印),`\d` 只前移,`\ai`、`\di` 只是行距(不升降),开关不分大小写;每个命令字符保留其运行的上下标(Word 照此画),同一段上下标里字样变了仍是一个上下标;斜体字母写成标识符(StarMath 与 OMML 都画成斜体,DOCX 往返不丢),斜体的数字与符号写成 `ital` 文字,存为 DOCX 后成正体(OMML 无此写法,已知限制);没有底的上下标以空文字为底(空的底在 OMML 里是占位符,重开成 `<?>`);公式取域的字号;读不通的命令以其域代码显示为文字,不消失。只有整个域是 `\s\do N(文字)` 时照原样成为下移的文字,其余都经读法(原先小写的 `\s\do` 读到第一个括号即止,其后丢失)。`client/tests/word.rs` 以 `tests/fixtures/office/word.docx`(由 `.work/r8/gen/eq.py` 生成,不入库)核对公式文本(含开头的空格、大写开关、读不通的域、字样变化的下标、`\ai`、`\di`、小写 `\s\do` 的尾部与嵌套)、DOCX 往返后的斜体、下标与无占位符、锚字符的升降与图块里图片低于文字(去掉版面的升降即失败)。审查三轮:第 3 轮的四处已修并以上述测试核对,未再审查(每条至多三轮)。仍与 Word 不同:编辑区里公式与图片四周有内核的辅助框线(`ShowBoundaries`,视图辅助,不印出);DOCX 另存后重开行距变宽,因内核导出改写文档默认字体与段距,与公式无关。
- 2026-10-18:工作表过多时标签条(R8.3,Gitea #8)。组件库 `navigation::Tabs` 横排时可横向滚动(滚轮同样),标签可窄于自然宽度;所选标签在所选或标签条宽度变了时滚入视口,在绘制时以刚排好的宽度判定(原先只在首帧滚一次,其后右栏打开、标签条变窄,所选标签被挤出视口);手动滚动保留到二者之一再变;横排的标签至多与标签条同宽,名字过长以省略号结尾(组件库 e56a343、3a68d1b、7d704d9,同批带入上游列外壳拖动的四个提交)。ElyTable 的标签条随之收缩,添加按钮留在其旁;标签条的状态按工作簿各记一份(元素 id 带标签号),切到另一工作簿时其所选标签照样滚入。`navigation/tests/tabs.rs` 以 30 个标签核对:首帧滚到所选、滚轮回到第一个、新选的滚入、变窄后所选仍在末端(去掉按宽度判定即失败)、长名的所选标签整个在标签条内(去掉宽度上限即失败);ElyTable 的 `tests/strip.rs` 以 `sheets::strip` 画两个各有 30 张表、都选第 30 张的工作簿,滚回开头后来回切换,所选标签每次都在标签条内(两个工作簿共用一个 id 即失败)。Windows 真窗口(`.work/r8/strip3.png`):30 张表、第 30 张为当前,1100 宽时标签条末端为它。
- 2026-10-18:ElyMark 公式(R8.4,用户定两项都做)。行内公式照块公式由 RaTeX 排版(原只显示着色的 TeX 原文):gpui 的文本里放不进图片,组件库 `render/math_text.rs` 把每个行内公式换成等宽的不断行空格(U+00A0,按字体量出个数),排好的公式居中画在其上,行高随最高的公式撑开;空格串与词一样整个折到下一行,画在其所在行(折行处的序号 gpui 读作上一行末);按下与右键所在的字节经换算读回公式的原文;排不出的公式照旧显示 TeX 原文,其后以危险色写出 RaTeX 给的原因与位置,记警告(不含公式)。`\(…\)` 读作行内公式,`\[…\]` 读作块公式(ChatGPT 等常写的分隔符;GitHub 不认,Typora 默认不认):解析前逐字节把成对的分隔符换成 `$`(长度不变,位置照旧对得上)。成对只在同一段落、标题或单元格里(跨列表项、CR 空行不成对);不跨代码、公式、HTML 标签、链接地址与引用标签、图片、脚注引用,`<code>`、`<kbd>` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 TeX 原文。独占一段的 `\[…\]` 为块公式(段首可有一至三个空格)。组件库公开 `documents::markdown_tree`,ElyMark 的 HTML、PDF、纯文本导出都经它解析(`markdown_options` 不再公开),纯文本里独占一段的公式照块公式写其 TeX。同时查出(组件库 e095f06):RaTeX 的 SVG 以 pt 定尺寸,usvg 按 px 量墨迹,两者差 4/3,每个公式的画面右侧与下方多出约四分之一的空白(块公式因此偏左)、导出的公式比正文大 4/3;现生成时即把根改为 px。`markdown/tex/tests.rs` 与 `tree/tests.rs` 核对分隔符的各种情形(相邻、`\$`、链接地址、`<code>`、跨块),`math_text.rs` 的窗口测试核对公式排进并读回原文、折到第二行的公式画在第二行首(改回按起点定位即失败)、排不出的公式写出原因(组件库 a20e712、5fba7b3、de961eb),`math/tests.rs` 核对画面与公式同宽(改回 pt 即失败),ElyMark 的 `visual.rs` 核对导出与纯文本。Windows 真窗口(`.work/r8/mark/visual2.png`):`$…$`、`\(…\)` 排在行内,`\[…\]` 居中成块,行内代码与转义的 `\\(` 照原文。
- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点;正文有 8 位字节即带 `BODY=8BITMIME`,服务器不支持即拒绝。读信按 MIME 树与所在容器(C12.2),嵌套(多部分与内嵌邮件都算)深于 64 层即报错,超深的解析结果以迭代拆开再丢(直接丢弃会递归、栈溢出);mail-parser 开 `full_encoding`;SMTPUTF8 按每一层的头块判定。本地存储(C12.3):rusqlite 自带的 SQLite,`mail.db` 加内容目录(原文按 SHA-256),FTS5(trigram)可由索引与原文重建;`user_version` 记结构版本,升级前备份,失败即只读;原文文件在删去或替换邮件的事务提交前删去,删不掉即回滚。只有 HTML 的邮件经 html5ever 的树构建器取文字入索引,元素深过 512 层即停读(树构建器的开放元素栈扫描随深度平方增长)。凭据(C12.4):密码与刷新令牌只进系统凭据库(keyring),被拒即只留在内存到退出;OAuth 换令牌与刷新经 ureq,回跳在 127.0.0.1 随机端口;Google 授权带 `access_type=offline` 与 `prompt=consent`(每次同意都发刷新令牌),Microsoft 另要 `profile`(无 `email` 时以 `preferred_username` 为地址),换码没有刷新令牌即报错;客户端 ID 由 `crates/ely-mail-engine/build.rs` 取环境变量或 `oauth.env`,该文件不存在即未配置,读不出或写错即构建失败。ureq 在 trace 级逐字节记下请求与回复(令牌、客户端密钥),`ely_platform::logging` 在任何 `ELY_LOG` 下都不记 `ureq`、`ureq_proto` 低于 info 的日志。账号(C12.5):`ely_mail_engine::Account` 存在 accounts 表的 settings 列(JSON),凭据库名 `account:地址`;删账号先删凭据再删本地数据,本地删除失败即放回凭据,凭据库删不掉时内存里的凭据照留,不动服务端;地址在库里唯一,不分 ASCII 大小写(`Error::Taken`)。应用的库读写在后台、锁后进行,网络任务在应用自带的两线程 tokio 运行时上,结果交回 gpui。iCloud 收发都以完整地址为用户名。删去原文先改名为 `.散列.gone` 留在原处,事务提交后才删,失败即改回;打开库时按库里是否仍有邮件指向它放回或删去(C12.5 第 1 轮后)。对话框的请求(检查、登录、保存)在整个窗口里编号,回来时只作用于仍在等它的那个对话框;保存的结果照样更新账号列表,失败而对话框已换即以提示写明。端口记下上次自动填入的值,与之不同的即为键入,换安全方式不覆盖。IMAP 同步(C12.6):文件夹按 LIST 存为邮箱(角色按 RFC 6154,INBOX 按名、库里不分大小写;有 LIST-EXTENDED 与 SPECIAL-USE 即以 `RETURN (SPECIAL-USE)` 取角色),不再列出的连同邮件删去;EXAMINE 缺 UIDVALIDITY 或 EXISTS 即报错;UIDVALIDITY 变了即清空重建;同一 UID 的 FETCH 回复合并;邮件头全取,正文只取 90 天内(`sync::BODY_DAYS`):先读 BODYSTRUCTURE,无附件即取整封,有附件只取头与正文部分、按原结构重组,附件内容为空、部分号记在 `missing`,附件按需(C12.17),处置为 attachment 的多部分整个按需,related 里不看处置(RFC 2387);新列由结构 v2 升级加上,UIDVALIDITY 不同于所记时只删别的 ULine truncated
- 2026-10-19(用户定):代码与发布从 Gitea 搬到 GitHub 组织 ELY-Open-Source(`ELY-Open-Source/ElyOffice`;组件库 `ELY-Open-Source/Ely-GPUI-Components`,Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变;Kigi CLI 同在此组织)。历史、标签与发布已迁过,Gitea 停用,GitHub 是唯一的家。发布由 `.github/workflows/release.yml` 在推送 `v*` 标签时运行(只此一种触发,拉取请求到不了自托管机器):macOS 在 `[self-hosted, macos-signing]`(Mac mini 的图形会话,Developer ID 签名并公证),Windows 与 Linux(WSL)在 `[self-hosted, ely-windows]`;各机的常驻克隆 `$HOME/ely-ci/ElyOffice` 从 GitHub 拉取。`scripts/ci.sh` 经 GitHub API 按标签取发布、没有就建(`GITHUB_TOKEN`,`contents: write`),同名文件先删再传:重跑只换本平台的文件。更新检查读 `api.github.com/repos/ELY-Open-Source/ElyOffice/releases/latest` 的 `<slug>.json`,清单的下载地址为 `github.com/ELY-Open-Source/ElyOffice/releases/download/v<版本>/<文件>`;0.1.8 及以前装好的应用仍查 Gitea,需手动装一次新版。官网 worker 读 GitHub API(可选机密 `GITHUB_TOKEN` 避开共享出口地址的限流),下载与源码链接指向 GitHub(GitHub 标志);macOS 版已公证,去掉“未经公证”的提示。发布视频的源文件与成片里仍是 Gitea 地址,未改未重渲染。
- 2026-10-19(用户定):自有代码由 AGPL-3.0-or-later 改为 MIT(`LICENSE`,Copyright (c) 2026 Yang Zhang;工作区 `Cargo.toml`、Linux 包的许可字段、`docs/licenses.md`、官网)。第三方照旧:LibreOffice 与 `patches/libreoffice` 为 MPL-2.0 等,Git 为 GPL-2.0,MuPDF 为 AGPL-3.0;ElyPDF 链接 MuPDF,其发行包整体仍须按 AGPL 履行,Linux 包的许可字段据此写为 `MIT AND <引擎许可>`。
- 2026-10-19(GitHub #2,R9.1):Impress 在母版视图里 `getParts` 数母版、`getPartName` 给母版名,`getPartInfo` 却读同一序号的幻灯片,母版多于幻灯片时信息里没有 `visible`,ElyShow 每次重读部件都报错;补丁 `0052-lok-impress-master-view-part-info.patch` 让母版视图里取母版页(`client/tests/show.rs` 核对)。`ELY_SHARE` 下发布流程不重建内核:新补丁须先在三台机器的共享引擎上各自重建。GitHub PR #1(R9.2,用户定合并):选区画在图块之上,不透明的选区色盖住文字,`LokView` 画选区时不透明度至多 0.3。
- 2026-10-20(GitHub #4,R9.3):整列看不出标题时 Calc 开自动筛选会弹异步询问,worker 当场关掉即在其回调里崩溃;补丁 `0053-lok-calc-autofilter-header-without-asking.patch`:LOK 下不问,首行作标题(同 Excel)。R9.4:arboard 在 Windows 把剪贴板上没有 HTML 读成错误,`ely_platform::clipboard` 先查格式;剪贴板错误的提示写出整条原因。补丁 0049 补上 `doc.hxx`(Linux 的 gcc 编不过;MSVC 与 clang 经别的头文件带入)。Gitea 上另发同名的过渡发布(用户定):0.1.8 及更早的应用在 Gitea 查更新,过渡发布的清单指向 GitHub 的下载,装上的 0.1.9 起从 GitHub 查;Gitea 只为此推上 main 与标签,不再是发布之处。
- 2026-10-20(GitHub PR #6,R9.5,用户定合并):ElyTable 的函数候选由 Tab、不带修饰的 Enter 或单击接受(原 Enter 提交半个公式并下移);接受后到对应输入与其修订的事件批次到达前不再提供候选,不能重复接受(`view/formula/completion.rs`);单击按所点的函数名接受。接受与否由 worker 在执行时按内核此刻的候选判定(`worker/complete.rs`,同换页的 `TURN`):界面上回报的候选可能已被其后排队的键入作废;所选的仍在即取它,按键时不在即取菜单首项(名最短者,同菜单的次序),没有候选即照常交按键,单击的已不在即什么也不做。有效性下拉按钮挡鼠标的框至少为按钮的主题高度(默认行比按钮矮)。候选、参数提示、有效性下拉按钮与输入说明挡住滚轮以外的鼠标,按下不落到其下的格;填充与行列拖动在其上松开即取消。PR 的 ElyTable 测试原设空的错误样式,补丁 0017 拒绝该规则,改为 `stop`;空的单格读不出选区文字,改读 E2:F2。
-4
View File
@@ -5,10 +5,6 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies]
ely-platform.workspace = true
-3
View File
@@ -1,6 +1,3 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use ely_platform::AppId;
fn main() {
-5
View File
@@ -5,10 +5,6 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies]
csv.workspace = true
@@ -26,7 +22,6 @@ ely-ui.workspace = true
[dev-dependencies]
ely-ui = { workspace = true, features = ["test-support"] }
ely-gpui-component = { workspace = true, features = ["test-support"] }
env_logger.workspace = true
image.workspace = true
tempfile.workspace = true
+2 -3
View File
@@ -48,7 +48,7 @@ impl Docs {
.size_full()
.child(tab.view.clone())
.children(self.find_box(cx))
.children(self.context_menu(cx))
.children(self.spelling_menu(cx))
.into_any_element();
let risk = self.stopped_banner(cx).or_else(|| self.risk_banner(cx));
let fonts = tab.view.read(cx).missing_fonts().join(", ");
@@ -80,8 +80,7 @@ impl Docs {
let shown = self
.active_tab()
.map(|tab| (tab.view.clone(), tab.doc.clone()));
// Hidden, it follows nothing: no reads on every move.
let tab = shown.as_ref().filter(|_| self.navigating).map(|(view, doc)| (view, doc));
let tab = shown.as_ref().map(|(view, doc)| (view, doc));
self.navigator
.update(cx, |navigator, cx| navigator.show(tab, cx));
shown.filter(|_| self.navigating)?;
+4 -30
View File
@@ -37,7 +37,7 @@ pub struct Docs {
pub review: Entity<Review>,
/// What the right column shows.
pub side: Side,
pub offer: Option<ely_office::menu::Offer<ely_lok::menu::Offered>>,
pub offer: Option<crate::spell::Offer>,
pub merge: Entity<crate::merge::Merge>,
pub navigator: Entity<crate::navigate::Navigator>,
/// The left column shows the navigator.
@@ -46,14 +46,8 @@ pub struct Docs {
pub back: Vec<(u64, ely_lok::Rect)>,
/// Last jump: tab, input, prior cursor moves, target.
pub going: Option<(u64, u64, u64, ely_lok::Rect)>,
/// Dialog reads asked; a dialog or tab change voids older.
pub dialog_asked: u64,
dialog_seen: Seen,
}
/// Open dialog kinds, Ely's and the shell's, and the tab.
type Seen = (Option<std::mem::Discriminant<Dialog>>, Option<std::mem::Discriminant<ely_office::Dialog>>, Option<u64>);
/// The right column's panels.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Side {
@@ -88,15 +82,7 @@ impl Host for Docs {
fn observed(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
self.count(id, view, cx);
self.found(id, view, cx);
self.offer_menu(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();
}
self.offer_spelling(id, view, cx);
}
fn search_leaves(&mut self, cx: &mut Context<Self>) {
@@ -140,7 +126,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.own_window(window, cx);
docs.core.window = Some(window.window_handle());
window.focus(&docs.core.focus, cx);
});
let launched = ely_ui::launch_files(cx);
@@ -177,7 +163,6 @@ impl Docs {
failed(docs, error, cx)
})
.detach();
cx.observe_self(|docs, _| docs.watch_dialogs()).detach();
Docs {
core: Core::new(worker, work, cx),
dialog: None,
@@ -190,18 +175,6 @@ impl Docs {
navigating: true,
back: Vec::new(),
going: None,
dialog_asked: 0,
dialog_seen: (None, None, None),
}
}
/// A dialog or tab change ends earlier dialog reads.
fn watch_dialogs(&mut self) {
let ely = self.dialog.as_ref().map(std::mem::discriminant);
let now = (ely, self.core.dialog.as_ref().map(std::mem::discriminant), self.core.active);
if self.dialog_seen != now {
self.dialog_seen = now;
self.dialog_asked += 1;
}
}
@@ -236,6 +209,7 @@ 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))
+23 -22
View File
@@ -128,28 +128,29 @@ impl Docs {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
let id = tab.id;
// Page style names are read off the window thread.
self.ask_in(
id,
|worker, doc| ely_lok::commands::styles(&worker.values(doc, ".uno:StyleApply")?, "8"),
move |docs, listed, _, cx| {
let names = match listed {
Ok(names) => names,
Err(error) => return docs.engine_failed(&error.to_string(), cx),
};
let name = (2..)
.map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string())
.find(|name| !names.contains(name))
.expect("a free section name");
log::info!("doc: a new section");
docs.run_in(Some(id), "style.new", &[&name, "8"], cx);
docs.run_in(Some(id), "page.section", &["3", &name], cx);
docs.props.update(cx, |props, _| props.refresh_lists());
cx.notify();
},
cx,
);
let listed = self
.core
.worker
.lock()
.expect("the worker lock")
.values(&tab.doc, ".uno:StyleApply")
.and_then(|json| ely_lok::commands::styles(&json, "8"));
let names = match listed {
Ok(names) => names,
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
let name = (2..)
.map(|n| Docs::text(cx, "doc.section.name", &[("n", &n.to_string())]).to_string())
.find(|name| !names.contains(name))
.expect("a free section name");
log::info!("doc: a new section");
self.run_active("style.new", &[&name, "8"], cx);
self.run_active("page.section", &["3", &name], cx);
self.props.update(cx, |props, _| props.refresh_lists());
cx.notify();
}
pub fn on_page_number(&mut self, _: &InsertPageNumber, _: &mut Window, cx: &mut Context<Self>) {
+1 -4
View File
@@ -1,19 +1,16 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod actions;
mod chrome;
mod compare;
mod docs;
mod files;
mod insert;
mod menu;
mod merge;
mod navigate;
mod output;
mod props;
mod refs;
mod review;
mod spell;
use ely_platform::AppId;
-238
View File
@@ -1,238 +0,0 @@
//! The right-click menu, laid out as Word's (DOC-02, DOC-07, DOC-12).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::{
LokView,
menu::{Offered, Target},
};
use ely_office::menu::{self, Entry, Offer};
use gpui::{App, Context, Entity, Window};
use crate::docs::Docs;
use crate::refs::{InsertCaption, InsertComment, InsertLink};
use ely_office::prelude::*;
/// One thing the menu does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pick {
Cut,
Copy,
Paste,
Link,
Comment,
ColumnLeft,
ColumnRight,
RowAbove,
RowBelow,
DeleteRows,
DeleteColumns,
DeleteTable,
Merge,
SplitColumns,
SplitRows,
EvenRows,
EvenColumns,
Inline,
Square,
TopBottom,
InFront,
Front,
Forward,
Back,
Backward,
Caption,
}
const TABLE: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub(
"doc.menu.insert",
&[Pick::ColumnLeft, Pick::ColumnRight, Pick::RowAbove, Pick::RowBelow],
),
Entry::Sub(
"doc.menu.delete",
&[Pick::DeleteRows, Pick::DeleteColumns, Pick::DeleteTable],
),
Entry::Row(Pick::Merge),
Entry::Sub("doc.menu.split", &[Pick::SplitColumns, Pick::SplitRows]),
Entry::Row(Pick::EvenRows),
Entry::Row(Pick::EvenColumns),
Entry::Line,
Entry::Row(Pick::Link),
Entry::Row(Pick::Comment),
];
const FRAME: &[Entry<Pick>] = &[
Entry::Line,
Entry::Sub(
"doc.menu.wrap",
&[Pick::Inline, Pick::Square, Pick::TopBottom, Pick::InFront],
),
Entry::Sub("office.menu.front", &[Pick::Front, Pick::Forward]),
Entry::Sub("office.menu.back", &[Pick::Back, Pick::Backward]),
Entry::Line,
Entry::Row(Pick::Caption),
Entry::Row(Pick::Comment),
];
const TEXT: &[Entry<Pick>] = &[Entry::Line, Entry::Row(Pick::Link), Entry::Row(Pick::Comment)];
impl menu::Pick for Pick {
fn row(self) -> (&'static str, &'static str) {
match self {
Pick::Cut => ("office.menu.cut", ".uno:Cut"),
Pick::Copy => ("office.menu.copy", ".uno:Copy"),
Pick::Paste => ("office.menu.paste", ".uno:Paste"),
Pick::Link => ("doc.menu.link", ".uno:InsertHyperlink"),
Pick::Comment => ("doc.menu.comment", ".uno:InsertAnnotation"),
Pick::ColumnLeft => ("doc.menu.column_left", ".uno:InsertColumnsBefore"),
Pick::ColumnRight => ("doc.menu.column_right", ".uno:InsertColumnsAfter"),
Pick::RowAbove => ("doc.menu.row_above", ".uno:InsertRowsBefore"),
Pick::RowBelow => ("doc.menu.row_below", ".uno:InsertRowsAfter"),
Pick::DeleteRows => ("doc.menu.delete_rows", ".uno:DeleteRows"),
Pick::DeleteColumns => ("doc.menu.delete_columns", ".uno:DeleteColumns"),
Pick::DeleteTable => ("doc.menu.delete_table", ".uno:DeleteTable"),
Pick::Merge => ("doc.menu.merge", ".uno:MergeCells"),
Pick::SplitColumns => ("doc.menu.split_columns", ".uno:SplitCell"),
Pick::SplitRows => ("doc.menu.split_rows", ".uno:SplitCell"),
Pick::EvenRows => ("doc.menu.even_rows", ".uno:DistributeRows"),
Pick::EvenColumns => ("doc.menu.even_columns", ".uno:DistributeColumns"),
Pick::Inline => ("doc.menu.inline", ".uno:SetAnchorToChar"),
Pick::Square => ("doc.menu.square", ".uno:WrapOn"),
Pick::TopBottom => ("doc.menu.top_bottom", ".uno:WrapOff"),
Pick::InFront => ("doc.menu.in_front", ".uno:WrapThrough"),
Pick::Front => ("office.menu.front", ".uno:BringToFront"),
Pick::Forward => ("office.menu.forward", ".uno:ObjectForwardOne"),
Pick::Back => ("office.menu.back", ".uno:SendToBack"),
Pick::Backward => ("office.menu.backward", ".uno:ObjectBackOne"),
Pick::Caption => ("doc.menu.caption", ".uno:InsertCaptionDialog"),
}
}
}
impl Pick {
/// The kernel offers it and it runs now.
pub(crate) fn on(self, offered: &Offered) -> bool {
offered.allows(menu::Pick::row(self).1)
}
}
impl Docs {
/// Turns the kernel's context menu into Ely's.
pub(crate) fn offer_menu(&mut self, id: u64, view: &Entity<LokView>, cx: &mut Context<Self>) {
let (offered, at) = match menu::take(view, cx) {
None => return,
Some(Ok(taken)) => taken,
Some(Err(error)) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
if matches!(offered.target, Target::Cell | Target::Object | Target::Picture | Target::Slide) {
let error = format!("a {:?} menu in a text document", offered.target);
let text = Docs::text(cx, "office.error.engine", &[("error", &error)]);
return self.toast(text, Severity::Danger, cx);
}
let suggestions = offered.spelling.as_ref().map_or(0, |spelling| spelling.suggestions.len());
log::info!("doc: {suggestions} suggestions");
self.offer = Some(Offer::new(self.offer.as_ref(), id, offered, at));
cx.notify();
}
/// The menu's host, for the active tab.
pub(crate) fn context_menu(&self, cx: &mut Context<Self>) -> Option<ContextMenu> {
let offer = self
.offer
.as_ref()
.filter(|offer| Some(offer.tab) == self.core.active)?;
Some(offer.host("doc-menu", menu_of(&cx.entity(), &offer.what, cx)))
}
/// Runs one menu row.
pub(crate) fn choose(&mut self, pick: Pick, window: &mut Window, cx: &mut Context<Self>) {
log::info!("doc: the menu picks {pick:?}");
let (id, values): (&str, &[&str]) = match pick {
Pick::Cut | Pick::Copy | Pick::Paste => return self.clipboard(pick, cx),
Pick::Link => return self.on_link(&InsertLink, window, cx),
Pick::Comment => return self.on_comment(&InsertComment, window, cx),
Pick::Caption => return self.on_caption(&InsertCaption, window, cx),
Pick::ColumnLeft => ("table.column_left", &[]),
Pick::ColumnRight => ("table.column_right", &[]),
Pick::RowAbove => ("table.row_above", &[]),
Pick::RowBelow => ("table.row_below", &[]),
Pick::DeleteRows => ("table.delete_rows", &[]),
Pick::DeleteColumns => ("table.delete_columns", &[]),
Pick::DeleteTable => ("table.delete", &[]),
Pick::Merge => ("table.merge", &[]),
Pick::SplitColumns => ("table.split", &["2", "false"]),
Pick::SplitRows => ("table.split", &["2", "true"]),
Pick::EvenRows => ("table.even_rows", &[]),
Pick::EvenColumns => ("table.even_columns", &[]),
Pick::Inline => ("image.anchor_as_char", &[]),
Pick::Square => ("image.wrap_on", &[]),
Pick::TopBottom => ("image.wrap_off", &[]),
Pick::InFront => ("image.wrap_through", &[]),
Pick::Front => ("image.front", &[]),
Pick::Forward => ("image.forward", &[]),
Pick::Back => ("image.back", &[]),
Pick::Backward => ("image.backward", &[]),
};
self.run_active(id, values, cx);
}
fn clipboard(&mut self, pick: Pick, cx: &mut Context<Self>) {
let Some(view) = self.active_tab().map(|tab| tab.view.clone()) else {
log::warn!("doc: {pick:?} with no document");
return;
};
view.update(cx, |view, _| match pick {
Pick::Paste => view.paste(),
_ => view.copy(pick == Pick::Cut),
});
}
}
/// Spelling first, then the clipboard row, then the target's rows.
pub(crate) fn menu_of(this: &Entity<Docs>, offered: &Offered, cx: &App) -> Menu {
let item = |pick: Pick| {
let this = this.clone();
MenuItem::new(Docs::text(cx, menu::Pick::row(pick).0, &[]))
.disabled(!pick.on(offered))
.on_click(move |window, cx| this.update(cx, |docs, cx| docs.choose(pick, window, cx)))
};
let mut menu = Menu::new();
if let Some(spelling) = &offered.spelling {
for (label, rule) in &spelling.suggestions {
let (this, rule) = (this.clone(), rule.clone());
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
}));
}
if spelling.suggestions.is_empty() {
menu = menu.item(MenuItem::new(Docs::text(cx, "doc.spell.none", &[])).disabled(true));
}
let this = this.clone();
let ignore = MenuItem::new(Docs::text(cx, "doc.spell.ignore_all", &[])).on_click(
move |_, cx| {
this.update(cx, |docs, cx| {
docs.run_active("spell.ignore_all", &["Spelling"], cx)
})
},
);
menu = menu.item(ignore).separator();
}
let menu = menu::clipboard(menu, [Pick::Cut, Pick::Copy, Pick::Paste], item);
let rows = match offered.target {
Target::Table => TABLE,
Target::Frame => FRAME,
Target::Text => TEXT,
Target::Cell | Target::Object | Target::Picture | Target::Slide => unreachable!("offer_menu refuses them"),
};
menu::entries(menu, rows, cx, |pick| pick.on(offered), item)
}
#[cfg(test)]
mod tests;
-117
View File
@@ -1,117 +0,0 @@
use ely_gpui_component::theme::Theme;
use ely_lok::{LokView, menu::Target};
use gpui::{Entity, Modifiers, MouseButton, Pixels, Point, TestAppContext, VisualTestContext};
use super::Pick;
use crate::docs::Docs;
use crate::tests::{blank, docs, settled, text_of, type_into, with_window, written};
use ely_office::prelude::*;
/// The view with the menu's host over it.
struct Host(Entity<Docs>, Entity<LokView>);
impl gpui::Render for Host {
fn render(&mut self, _: &mut gpui::Window, cx: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
let menu = self.0.update(cx, |docs, cx| docs.context_menu(cx));
gpui::div().relative().size_full().child(self.1.clone()).children(menu)
}
}
fn frames(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
/// The caret's middle, in window pixels.
fn caret(cx: &mut VisualTestContext, view: &Entity<LokView>) -> Point<Pixels> {
frames(cx);
cx.update(|_, app| view.read(app).cursor_bounds().expect("a caret").center())
}
/// Right-clicks through the view and waits for the target's menu.
fn right_click(cx: &mut VisualTestContext, docs: &Entity<Docs>, at: Point<Pixels>, target: Target) {
cx.update(|_, app| docs.update(app, |docs, _| docs.offer = None));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
let shown = |app: &gpui::App| {
docs.read(app)
.offer
.as_ref()
.is_some_and(|offer| offer.what.target == target)
};
for _ in 0..500 {
cx.run_until_parked();
if cx.update(|_, app| shown(app)) {
return frames(cx);
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("no {target:?} menu");
}
#[test]
fn a_right_click_opens_word_rows_where_it_lands() {
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;
};
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
let id = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Text");
let view = cx.update(|app| docs.read(app).active_tab().expect("a tab").view.clone());
let window = cx.add_window(|_, _| Host(docs.clone(), view.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
let text = caret(&mut shown, &view) - gpui::point(gpui::px(8.0), gpui::px(0.0));
right_click(&mut shown, &docs, text, Target::Text);
let cut = shown.debug_bounds("menu-button-0-0").expect("the clipboard row");
let off = cut.origin - text;
assert!(
off.x >= gpui::px(0.0) && off.x < gpui::px(40.0) && off.y < gpui::px(40.0),
"the menu opens at the click, not the caret: {off:?}"
);
let on = |shown: &mut VisualTestContext, pick: Pick| {
shown.update(|_, app| pick.on(&docs.read(app).offer.as_ref().expect("an offer").what))
};
assert!(on(&mut shown, Pick::Comment));
assert!(!on(&mut shown, Pick::Link), "the kernel links a selection");
assert!(!on(&mut shown, Pick::Cut), "nothing is selected");
with_window(&mut cx, &docs, |docs, _, cx| docs.insert_table(2.0, 2.0, cx));
settled(&cx, &docs);
let cell = caret(&mut shown, &view);
right_click(&mut shown, &docs, cell, Target::Table);
assert!(on(&mut shown, Pick::RowBelow) && on(&mut shown, Pick::SplitRows));
assert!(!on(&mut shown, Pick::Merge), "one cell does not merge");
for pick in [Pick::RowBelow, Pick::ColumnRight] {
with_window(&mut cx, &docs, |docs, window, cx| docs.choose(pick, window, cx));
settled(&cx, &docs);
}
let path = dir.path().join("grown.odt");
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, path.clone(), None, window, cx)
});
written(&cx, &path, None);
let flat = text_of(&mut cx, &docs, &path);
assert_eq!(flat.matches("<table:table-row").count(), 3, "a row below");
assert_eq!(flat.matches("<table:table-cell").count(), 9, "and a column right");
}
#[test]
fn link_follows_the_kernel_flag() {
let menu = |enabled: bool| {
let json = format!(
r#"{{"menu":[{{"text":"x","type":"command","command":".uno:InsertHyperlink","enabled":"{enabled}"}}]}}"#
);
ely_lok::menu::read(&json).expect("the menu parses")
};
assert!(Pick::Link.on(&menu(true)));
assert!(!Pick::Link.on(&menu(false)), "protected text gets no link");
}
+9 -29
View File
@@ -36,10 +36,6 @@ 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,
@@ -50,8 +46,6 @@ impl Merge {
Merge {
docs,
table: None,
source: None,
previews: std::collections::HashSet::new(),
record: 1,
pdf: false,
}
@@ -68,20 +62,11 @@ impl Docs {
}
pub(crate) fn choose_csv(&mut self, cx: &mut Context<Self>) {
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) {
self.pick(false, cx, |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();
});
@@ -99,20 +84,20 @@ impl Docs {
});
}
/// The template's tab, while it is open.
/// The tab a merge starts from: the active one.
fn source(&self, cx: &mut Context<Self>) -> Option<u64> {
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", &[]);
if self.core.active.is_none() {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
self.toast(text, Severity::Warning, cx);
}
source
self.core.active
}
/// Types a field's marker at the template's cursor.
/// Types a field's marker at the cursor.
pub(crate) fn insert_field(&mut self, header: &str, cx: &mut Context<Self>) {
let Some(tab) = self.source(cx).and_then(|source| self.tab(source)) else {
return;
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
tab.view.update(cx, |view, _| view.insert(&marker(header)));
}
@@ -209,12 +194,7 @@ 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(
+3 -6
View File
@@ -164,7 +164,6 @@ 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,
@@ -247,11 +246,9 @@ impl Docs {
let mut worker = worker.lock().expect("the worker lock");
let staged = template.with_file_name(format!("record.{extension}"));
let made = worker.load(&ely_lok::file_url(&template)).and_then(|doc| {
let saved = fill(&mut worker, &doc, &table.headers, row)
.and_then(|()| doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension));
// Closed even when this record failed.
let closed = worker.close_doc(doc);
saved.and(closed)?;
fill(&mut worker, &doc, &table.headers, row)?;
doc.save_as(&mut worker, &ely_lok::file_url(&staged), extension)?;
worker.close_doc(doc)?;
publish(&staged, target)
});
made.map_err(|error| (n + 1, error))?;
+4 -49
View File
@@ -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.field_here("Name", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
type_into(&mut cx, &docs, " of ");
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("City", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("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.field_here("Name", cx);
docs.insert_field("Name", cx);
docs.preview_record(source, &table, 0, cx);
docs.core.active = Some(other);
})
@@ -191,14 +191,6 @@ 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)
});
@@ -235,7 +227,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.field_here("Name", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
settled(&cx, &docs);
}
let csv = dir.path().join("one.csv");
@@ -273,40 +265,3 @@ 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:?}");
}
+6 -4
View File
@@ -272,13 +272,15 @@ impl Render for Navigator {
if let Some(at) = self.outline.iter().position(|heading| heading.current) {
outline = outline.current(at.to_string());
}
// The places shown: a reread may change the list.
let places: Vec<_> = self.outline.iter().map(|heading| heading.rect).collect();
let docs = self.docs.clone();
let this = this.clone();
outline
.on_select(move |key, _, cx| {
let at: usize = key.parse().expect("an outline index");
Navigator::jump(&docs, places[at], cx);
let (to, docs) = {
let navigator = this.read(cx);
(navigator.outline[at].rect, navigator.docs.clone())
};
Navigator::jump(&docs, to, cx);
})
.into_any_element()
}
+4 -120
View File
@@ -181,6 +181,7 @@ fn the_current_heading_follows_the_cursor_across_columns() {
settle(&cx, |app| current(app) == Some(0));
}
#[cfg(unix)]
#[test]
fn pdfs_export_a_range_and_locked_files_ask() {
let dir = tempfile::tempdir().expect("a temp dir");
@@ -204,17 +205,11 @@ fn pdfs_export_a_range_and_locked_files_ask() {
});
written(&cx, &pdf, None);
let source = dir.path().join("long.fodt");
let alias = dir.path().join("alias.pdf");
std::os::unix::fs::symlink(&source, &alias).expect("the alias");
let link = dir.path().join("link.pdf");
std::fs::hard_link(&source, &link).expect("the hard link");
#[cfg(unix)]
let aliases = {
let alias = dir.path().join("alias.pdf");
std::os::unix::fs::symlink(&source, &alias).expect("the alias");
vec![alias]
};
#[cfg(not(unix))]
let aliases: Vec<std::path::PathBuf> = Vec::new();
for target in [source.clone(), link].iter().chain(&aliases) {
for target in [&source, &alias, &link] {
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.export_to(id, target.clone(), ely_lok::Pdf::default(), cx)
@@ -257,114 +252,3 @@ fn pdfs_export_a_range_and_locked_files_ask() {
"the locked text opens"
);
}
#[test]
fn a_hidden_navigator_reads_nothing() {
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;
};
crate::tests::blank(&mut cx, &docs);
let follows = |cx: &mut TestAppContext| cx.update(|app| docs.read(app).navigator.read(app).shown.is_some());
cx.update(|app| docs.update(app, |docs, cx| docs.left_view(cx)));
assert!(follows(&mut cx), "shown: it follows the tab");
cx.update(|app| docs.update(app, |docs, cx| {
docs.navigating = false;
docs.left_view(cx)
}));
assert!(!follows(&mut cx), "hidden: no tab to reread");
cx.update(|app| docs.update(app, |docs, cx| {
docs.navigating = true;
docs.left_view(cx)
}));
assert!(follows(&mut cx), "shown again: it reads once more");
}
/// The navigator alone as a window's view.
struct Host(Entity<Navigator>);
impl gpui::Render for Host {
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
gpui::div().size_full().child(self.0.clone())
}
}
#[test]
fn an_outline_click_goes_where_its_row_was_drawn() {
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;
};
cx.update(ely_gpui_component::init_for_tests);
crate::tests::blank(&mut cx, &docs);
let navigator = cx.update(|app| docs.read(app).navigator.clone());
let heading = |n: i32| ely_lok::review::Heading { text: format!("Part {n}"), level: 0, rect: (0, n * 100, 10, 10), current: false };
cx.update(|app| navigator.update(app, |navigator, cx| {
navigator.outline = vec![heading(1), heading(2), heading(3)];
cx.notify();
}));
let (_, vcx) = cx.add_window_view(|_, _| Host(navigator.clone()));
let target = |vcx: &mut gpui::VisualTestContext| vcx.update(|_, app| docs.read(app).going.map(|going| going.3));
// Find where the drawn third row sits.
let third = (0..120)
.map(|step| gpui::point(gpui::px(40.), gpui::px(step as f32 * 4.)))
.find(|at| {
vcx.simulate_click(*at, gpui::Modifiers::none());
target(vcx) == Some((0, 300, 10, 10))
})
.expect("the third row is drawn");
vcx.update(|_, app| docs.update(app, |docs, _| docs.going = None));
// The list shrinks; the click lands on the old frame.
vcx.update(|window, app| {
navigator.update(app, |navigator, cx| {
navigator.outline.truncate(1);
cx.notify();
});
let (position, modifiers, button) = (third, gpui::Modifiers::none(), gpui::MouseButton::Left);
let down = gpui::MouseDownEvent { position, modifiers, button, click_count: 1, first_mouse: false };
window.dispatch_event(gpui::PlatformInput::MouseDown(down), app);
let up = gpui::MouseUpEvent { position, modifiers, button, click_count: 1 };
window.dispatch_event(gpui::PlatformInput::MouseUp(up), app);
});
assert_eq!(target(vcx), Some((0, 300, 10, 10)), "the click goes where its row was");
}
/// A bundled font spelled otherwise, and one nowhere.
const SPELLED: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.text">
<office:font-face-decls>
<style:font-face style:name="DEJAVU SANS" svg:font-family="&apos;DEJAVU SANS&apos;"/>
<style:font-face style:name="Nowhere Sans" svg:font-family="&apos;Nowhere Sans&apos;"/>
</office:font-face-decls>
<office:automatic-styles>
<style:style style:name="T1" style:family="text"><style:text-properties style:font-name="DEJAVU SANS"/></style:style>
<style:style style:name="T2" style:family="text"><style:text-properties style:font-name="Nowhere Sans"/></style:style>
</office:automatic-styles>
<office:body><office:text>
<text:p><text:span text:style-name="T1">found</text:span> <text:span text:style-name="T2">lost</text:span></text:p>
</office:text></office:body>
</office:document>"#;
#[test]
fn a_font_found_by_its_search_name_is_not_missing() {
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;
};
open_text(&mut cx, &docs, &dir.path().join("spelled.fodt"), SPELLED);
settle(&cx, |app| {
let tab = docs.read(app).active_tab().expect("a tab");
!tab.view.read(app).missing_fonts().is_empty()
});
let missing = cx.update(|app| {
let tab = docs.read(app).active_tab().expect("a tab");
tab.view.read(app).missing_fonts().to_vec()
});
assert_eq!(missing, ["Nowhere Sans"], "the lookup found DejaVu Sans");
}
+5 -3
View File
@@ -29,7 +29,7 @@ impl Docs {
self.ask_output(true, cx);
}
pub(crate) fn ask_output(&mut self, print: bool, cx: &mut Context<Self>) {
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);
@@ -81,8 +81,10 @@ impl Docs {
let path = source.work.join("print.pdf");
let printing = path.clone();
return self.write_pdf(tab, path, pdf, cx, move |docs, written, cx| match written {
Ok(()) => ely_office::print_later(printing, cx),
Err(error) => docs.print_failed(&error.to_string(), cx),
Ok(()) => ely_office::print_later(printing, cx, |docs, error, cx| {
docs.output_failed(&error, cx)
}),
Err(error) => docs.output_failed(&error.to_string(), cx),
});
}
let Some(dir) = ely_office::folder(source) else {
+33 -45
View File
@@ -113,55 +113,43 @@ impl Props {
self.read_styles(cx);
}
/// The installed fonts, read off the window thread.
fn read_fonts(&mut self, doc: &Doc, cx: &mut Context<Self>) {
ely_lok::read_off(
&self.worker,
doc,
|worker, doc| ely_lok::commands::fonts(&worker.values(doc, ".uno:CharFontName")?),
|props: &mut Props, read, cx| match read {
Ok(fonts) => {
props.fonts = fonts.into_iter().map(Into::into).collect();
cx.notify();
}
Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))),
},
cx,
);
let read = self
.worker
.lock()
.expect("the worker lock")
.values(doc, ".uno:CharFontName")
.and_then(|json| ely_lok::commands::fonts(&json));
match read {
Ok(fonts) => self.fonts = fonts.into_iter().map(Into::into).collect(),
Err(error) => cx.emit(LokError(format!("the font list is unreadable: {error}"))),
}
}
/// The shown document's style lists, read off the window thread.
fn read_styles(&mut self, cx: &mut Context<Self>) {
let Some(shown) = self.shown.as_ref() else {
let Some(shown) = self.shown.as_mut() else {
return;
};
let view = shown.view.entity_id();
ely_lok::read_off(
&self.worker,
&shown.doc,
|worker, doc| {
let json = worker.values(doc, ".uno:StyleApply")?;
let read = self
.worker
.lock()
.expect("the worker lock")
.values(&shown.doc, ".uno:StyleApply")
.and_then(|json| {
let list = |family| -> std::io::Result<Vec<SharedString>> {
let names = ely_lok::commands::styles(&json, family)?;
Ok(names.into_iter().map(Into::into).collect())
};
Ok((list("2")?, list("1")?, list("8")?))
},
move |props: &mut Props, read, cx| {
// Another tab shown since: its own read follows.
let Some(shown) = props.shown.as_mut().filter(|shown| shown.view.entity_id() == view) else {
return;
};
match read {
Ok((paragraph, character, pages)) => {
(shown.paragraph, shown.character, shown.pages) = (paragraph, character, pages);
cx.notify();
}
Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))),
}
},
cx,
);
});
match read {
Ok((paragraph, character, pages)) => {
shown.paragraph = paragraph;
shown.character = character;
shown.pages = pages;
}
Err(error) => cx.emit(LokError(format!("the style list is unreadable: {error}"))),
}
}
/// Re-reads the style lists once queued input lands.
@@ -250,15 +238,15 @@ impl Render for Props {
let grid = if graphic {
grid.group(self.object_group(cx))
} else {
// In a table, its group leads, as in Word.
let grid = match self.state(".uno:DeleteTable", cx).as_deref() {
Some("enabled") => grid.group(self.table_group(cx)),
_ => grid,
};
grid.group(self.text_group(window, cx))
let grid = grid
.group(self.text_group(window, cx))
.group(self.paragraph_group(cx))
.group(self.style_group(cx))
.group(self.page_group(cx))
.group(self.page_group(cx));
match self.state(".uno:DeleteTable", cx).as_deref() {
Some("enabled") => grid.group(self.table_group(cx)),
_ => grid,
}
};
div()
.size_full()
+2 -7
View File
@@ -2,7 +2,7 @@
use ely_gpui_component::{data_display::PropertyGroup, primitives::IconName};
use ely_office::panel::{Panel, color_state, font_box, palette, row, size_menu, toggle};
use gpui::{App, Context, InteractiveElement, ParentElement, SharedString, Window, div};
use gpui::{App, Context, SharedString, Window};
use super::Props;
use crate::docs::Docs;
@@ -77,12 +77,7 @@ impl Props {
PropertyGroup::new(tip("doc.props.text"))
.row(tip("doc.props.font"), font)
.row(tip("doc.props.size"), size_menu(self, "doc-props-size", cx))
.row(
tip("doc.props.effects"),
div()
.debug_selector(|| "doc-props-text".into())
.child(effects),
)
.row(tip("doc.props.effects"), effects)
.row(
tip("doc.props.color"),
palette(&this, "format.color", (color, AUTOMATIC), None, cx),
+6 -8
View File
@@ -2,7 +2,7 @@
use ely_gpui_component::{buttons::Button, data_display::PropertyGroup, theme::ControlSize};
use ely_office::panel::{button, color_state, palette, row, send, toggle};
use gpui::{AnyElement, Context, InteractiveElement, IntoElement, ParentElement, SharedString, div};
use gpui::{AnyElement, Context, IntoElement, SharedString};
use super::Props;
use crate::docs::Docs;
@@ -14,13 +14,11 @@ impl Props {
PropertyGroup::new(tip("doc.props.table"))
.row(
tip("doc.props.rows"),
div()
.debug_selector(|| "doc-props-table".into())
.child(row([
button(self, "table.row_above", "doc.props.above_row", why, cx),
button(self, "table.row_below", "doc.props.below_row", why, cx),
button(self, "table.delete_rows", "doc.props.delete", why, cx),
])),
row([
button(self, "table.row_above", "doc.props.above_row", why, cx),
button(self, "table.row_below", "doc.props.below_row", why, cx),
button(self, "table.delete_rows", "doc.props.delete", why, cx),
]),
)
.row(
tip("doc.props.columns"),
+2 -88
View File
@@ -1,6 +1,5 @@
use std::{cell::RefCell, rc::Rc};
use ely_gpui_component::theme::Theme;
use ely_lok::LokError;
use ely_office::panel::Panel as _;
use gpui::{Entity, TestAppContext};
@@ -12,19 +11,13 @@ use ely_office::prelude::*;
/// The panel, shown on the active tab.
pub(super) fn panel(cx: &mut TestAppContext, docs: &Entity<Docs>) -> Entity<Props> {
let props = cx.update(|app| {
cx.update(|app| {
let tab = docs.read(app).active_tab().expect("a tab");
let (view, doc) = (tab.view.clone(), tab.doc.clone());
let props = docs.read(app).props.clone();
props.update(app, |props, cx| props.show(Some((&view, &doc)), cx));
props
});
// The lists come from a background read.
settle(cx, |app| {
let props = props.read(app);
!props.fonts.is_empty() && props.shown.as_ref().is_some_and(|shown| !shown.paragraph.is_empty())
});
props
})
}
fn run(cx: &mut TestAppContext, props: &Entity<Props>, id: &str, values: &[&str]) {
@@ -297,82 +290,3 @@ fn quick_spacing_edits_keep_each_other() {
"both quick edits land"
);
}
/// A window root that shows the panel.
struct Shown(Entity<Props>);
impl gpui::Render for Shown {
fn render(
&mut self,
_: &mut gpui::Window,
_: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
use gpui::{ParentElement as _, Styled as _};
gpui::div().size_full().child(self.0.clone())
}
}
/// The top of the element marked `selector`.
fn top(cx: &mut gpui::VisualTestContext, selector: &'static str) -> Option<gpui::Pixels> {
// Past reduced motion's 1ms folds.
for _ in 0..3 {
std::thread::sleep(std::time::Duration::from_millis(2));
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
cx.debug_bounds(selector).map(|bounds| bounds.origin.y)
}
#[test]
fn in_a_table_the_table_group_comes_first_and_folds_stay_put() {
const DOWN: i32 = 1024;
const UP: i32 = 1025;
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;
};
cx.update(|cx| {
ely_gpui_component::init_for_tests(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
});
blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Table");
let props = panel(&mut cx, &docs);
let window = cx.add_window(|_, _| Shown(props.clone()));
let mut shown = gpui::VisualTestContext::from_window(window.into(), &cx);
assert_eq!(
top(&mut shown, "doc-props-table"),
None,
"no table, no group"
);
let header = gpui::point(gpui::px(10.0), gpui::px(12.0));
shown.simulate_click(header, gpui::Modifiers::none());
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds");
shown.simulate_click(header, gpui::Modifiers::none());
assert!(top(&mut shown, "doc-props-text").is_some(), "Text opens");
with_window(&mut cx, &docs, |docs, _, cx| {
docs.insert_table(3.0, 2.0, cx)
});
let in_table = |app: &gpui::App| props.read(app).state(".uno:DeleteTable", app);
settle(&cx, |app| in_table(app).as_deref() == Some("enabled"));
let table = top(&mut shown, "doc-props-table").expect("the table group");
let text = top(&mut shown, "doc-props-text").expect("the text group");
assert!(table < text, "the table group leads: {table:?} vs {text:?}");
keys(&mut cx, &docs, &[UP]);
settle(&cx, |app| in_table(app).as_deref() != Some("enabled"));
shown.simulate_click(header, gpui::Modifiers::none());
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text folds again");
keys(&mut cx, &docs, &[DOWN]);
settle(&cx, |app| in_table(app).as_deref() == Some("enabled"));
assert!(top(&mut shown, "doc-props-table").is_some(), "Table opens");
assert_eq!(top(&mut shown, "doc-props-text"), None, "Text stays folded");
keys(&mut cx, &docs, &[UP]);
settle(&cx, |app| in_table(app).as_deref() != Some("enabled"));
assert_eq!(
top(&mut shown, "doc-props-text"),
None,
"and after the table"
);
}
+53 -63
View File
@@ -71,51 +71,35 @@ pub fn items(cx: &App) -> Vec<MenuItem> {
}
impl Docs {
/// Reads a kernel list off the window thread, then `then`.
fn read_then(
&mut self,
command: &'static str,
then: impl FnOnce(&mut Self, String, &mut Window, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) {
/// A kernel list for the active document; failures toast.
fn read(&self, command: &str, cx: &mut Context<Self>) -> Option<String> {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
self.toast(text, Severity::Warning, cx);
return None;
};
let id = tab.id;
self.dialog_asked += 1;
let asked = self.dialog_asked;
self.ask_in(
id,
move |worker, doc| worker.values(doc, command),
move |docs, read, window, cx| {
// A dialog opened since, or a later ask, wins.
if docs.dialog_asked != asked || docs.dialog.is_some() || docs.core.dialog.is_some() {
return log::info!("doc: a dialog read for tab {id} lapsed");
}
match read {
Ok(json) => then(docs, json, window, cx),
Err(error) => docs.engine_failed(&error.to_string(), cx),
}
},
cx,
);
let read = self
.core
.worker
.lock()
.expect("the worker lock")
.values(&tab.doc, command);
read.map_err(|error| {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
})
.ok()
}
/// The bookmarks' names, then `then`.
fn with_bookmarks(
&mut self,
then: impl FnOnce(&mut Self, Vec<String>, &mut Window, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) {
self.read_then(
".uno:Bookmarks",
move |docs, json, window, cx| match ely_lok::review::bookmarks(&json) {
Ok(marks) => then(docs, marks.into_iter().map(|mark| mark.name).collect(), window, cx),
Err(error) => docs.engine_failed(&error.to_string(), cx),
},
cx,
);
fn bookmarks(&self, cx: &mut Context<Self>) -> Option<Vec<String>> {
let listed = ely_lok::review::bookmarks(&self.read(".uno:Bookmarks", cx)?);
listed
.map(|marks| marks.into_iter().map(|mark| mark.name).collect())
.map_err(|error| {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
})
.ok()
}
fn ask(&mut self, ask: Ask, text: &str, window: &mut Window, cx: &mut Context<Self>) {
@@ -142,8 +126,10 @@ impl Docs {
self.run_active("note.endnote", &[], cx);
}
pub fn on_bookmark(&mut self, _: &InsertBookmark, _: &mut Window, cx: &mut Context<Self>) {
self.with_bookmarks(|docs, taken, window, cx| docs.ask(Ask::Bookmark(taken), "", window, cx), cx);
pub fn on_bookmark(&mut self, _: &InsertBookmark, window: &mut Window, cx: &mut Context<Self>) {
if let Some(taken) = self.bookmarks(cx) {
self.ask(Ask::Bookmark(taken), "", window, cx);
}
}
pub fn on_link(&mut self, _: &InsertLink, window: &mut Window, cx: &mut Context<Self>) {
@@ -155,17 +141,19 @@ impl Docs {
}
pub fn on_reference(&mut self, _: &InsertReference, _: &mut Window, cx: &mut Context<Self>) {
self.with_bookmarks(
|docs, marks, _, cx| {
let Some(mark) = marks.first().cloned() else {
let text = Docs::text(cx, "doc.reference.none", &[]);
return docs.toast(text, Severity::Warning, cx);
};
docs.dialog = Some(Dialog::Reference { marks, mark, show: "2" });
cx.notify();
},
cx,
);
let Some(marks) = self.bookmarks(cx) else {
return;
};
let Some(mark) = marks.first().cloned() else {
let text = Docs::text(cx, "doc.reference.none", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.dialog = Some(Dialog::Reference {
marks,
mark,
show: "2",
});
cx.notify();
}
pub fn on_caption(&mut self, _: &InsertCaption, window: &mut Window, cx: &mut Context<Self>) {
@@ -185,16 +173,18 @@ impl Docs {
self.ask(Ask::Caption(category), "", window, cx);
}
pub fn on_formula(&mut self, _: &InsertFormula, _: &mut Window, cx: &mut Context<Self>) {
self.read_then(
".uno:Formula",
|docs, json, window, cx| match ely_lok::review::formula(&json) {
Ok(Some(formula)) => docs.ask(Ask::Formula(true), &formula, window, cx),
Ok(None) => docs.ask(Ask::Formula(false), "", window, cx),
Err(error) => docs.engine_failed(&error.to_string(), cx),
},
cx,
);
pub fn on_formula(&mut self, _: &InsertFormula, window: &mut Window, cx: &mut Context<Self>) {
let Some(json) = self.read(".uno:Formula", cx) else {
return;
};
match ely_lok::review::formula(&json) {
Ok(Some(formula)) => self.ask(Ask::Formula(true), &formula, window, cx),
Ok(None) => self.ask(Ask::Formula(false), "", window, cx),
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
}
}
}
pub fn on_comment(&mut self, _: &InsertComment, window: &mut Window, cx: &mut Context<Self>) {
-50
View File
@@ -5,8 +5,6 @@ use crate::tests::{blank, docs, settle, settled, text_of, type_into, with_window
/// The open prompt's request and field.
fn asked(cx: &TestAppContext, docs: &Entity<Docs>) -> (Ask, Entity<TextInput>) {
// Prompts open once their kernel read comes back.
cx.run_until_parked();
cx.update(|app| match &docs.read(app).dialog {
Some(Dialog::Ask { ask, field }) => (ask.clone(), field.clone()),
_ => panic!("a prompt is open"),
@@ -37,7 +35,6 @@ fn references_go_in_through_their_dialogs() {
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_reference(&InsertReference, window, cx)
});
cx.run_until_parked();
assert!(
cx.update(|app| docs.read(app).dialog.is_none()),
"no bookmark, no cross-reference dialog"
@@ -58,7 +55,6 @@ fn references_go_in_through_their_dialogs() {
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_reference(&InsertReference, window, cx)
});
cx.run_until_parked();
cx.update(|app| docs.update(app, |docs, cx| docs.insert_reference(cx)));
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_link(&InsertLink, window, cx)
@@ -192,49 +188,3 @@ fn a_long_formula_prefills_whole() {
let (_, field) = asked(&cx, &docs);
assert_eq!(cx.update(|app| field.read(app).text().to_string()), long);
}
#[test]
fn a_late_dialog_read_never_replaces_a_newer_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;
};
blank(&mut cx, &docs);
// The link dialog opens before the bookmarks come back.
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_bookmark(&InsertBookmark, window, cx);
docs.on_link(&InsertLink, window, cx);
});
cx.run_until_parked();
assert!(cx.update(|app| matches!(docs.read(app).dialog, Some(Dialog::Link { .. }))), "the link dialog stays");
// Opened and closed meanwhile: the old ask lapses too.
with_window(&mut cx, &docs, |docs, window, cx| {
docs.dialog = None;
cx.notify();
docs.on_bookmark(&InsertBookmark, window, cx);
docs.on_link(&InsertLink, window, cx);
});
cx.update(|app| docs.update(app, |docs, cx| {
docs.dialog = None;
cx.notify();
}));
cx.run_until_parked();
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "nothing pops up later");
// A close prompt shown and cancelled meanwhile counts too.
let id = cx.update(|app| docs.read(app).core.active.expect("a tab"));
type_into(&mut cx, &docs, "edited");
settled(&cx, &docs);
with_window(&mut cx, &docs, |docs, window, cx| {
docs.on_bookmark(&InsertBookmark, window, cx);
docs.request_close(id, ely_office::After::Tab, window, cx);
});
assert!(cx.update(|app| docs.read(app).core.dialog.is_some()), "the close asks");
cx.update(|app| docs.update(app, |docs, cx| {
docs.core.dialog = None;
cx.notify();
}));
cx.run_until_parked();
assert!(cx.update(|app| docs.read(app).dialog.is_none()), "no bookmark prompt after the cancel");
}
+26 -69
View File
@@ -8,11 +8,11 @@ use std::{
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
collab::{Comment, CommentSidebar, Peer, Thread},
feedback::{EmptyState, InlineMessage},
feedback::EmptyState,
forms::{Switch, TextInput},
layout::ScrollArea,
lists::{List, ListItem},
primitives::{IconName, Severity},
primitives::IconName,
theme::ControlSize,
};
use ely_lok::{
@@ -20,7 +20,7 @@ use ely_lok::{
review::{Change, Note},
};
use gpui::{
App, Context, Entity, EventEmitter, InteractiveElement as _, IntoElement, Menu, StatefulInteractiveElement as _, MenuItem, ParentElement as _, Render,
App, Context, Entity, EventEmitter, IntoElement, Menu, MenuItem, ParentElement as _, Render,
SharedString, Styled as _, Subscription, Window, actions, div,
};
@@ -86,8 +86,6 @@ pub struct Review {
shown: Option<Shown>,
notes: Vec<Note>,
threads: Vec<Thread>,
/// Notes whose time does not read: time and why.
unread: Vec<Unread>,
pub(crate) changes: Vec<Change>,
active: Option<SharedString>,
pane: Pane,
@@ -102,7 +100,6 @@ impl Review {
shown: None,
notes: Vec::new(),
threads: Vec::new(),
unread: Vec::new(),
changes: Vec::new(),
active: None,
pane: Pane::Comments,
@@ -120,7 +117,6 @@ impl Review {
self.active = None;
self.notes.clear();
self.threads.clear();
self.unread.clear();
self.changes.clear();
cx.notify();
let Some((view, doc)) = tab else {
@@ -165,8 +161,21 @@ impl Review {
return;
}
shown.reading = false;
let read = read.and_then(|(notes, changes)| {
let threads = threads(&notes).map_err(std::io::Error::other)?;
Ok((notes, threads, changes))
});
match read {
Ok((notes, changes)) => review.take_lists(notes, changes),
Ok((notes, threads, changes)) => {
log::info!(
"doc: review lists {} notes, {} changes",
notes.len(),
changes.len()
);
review.notes = notes;
review.threads = threads;
review.changes = changes;
}
Err(error) => cx.emit(LokError(format!(
"the review lists are unreadable: {error}"
))),
@@ -357,61 +366,12 @@ impl Render for Review {
.p_2()
.child(head)
.child(pane)
// Many bad notes scroll; the lists stay in reach.
.children(self.unread_alert(cx).map(|alert| {
ScrollArea::new("doc-review-unread-scroll").vertical().w_full().max_h(gpui::rems(6.)).flex_none().child(alert)
}))
.child(div().flex_1().min_h_0().child(body))
}
}
/// A note whose time does not read, and why.
#[derive(Clone, Debug, PartialEq)]
pub struct Unread {
pub author: String,
pub at: String,
pub error: String,
}
impl Review {
/// The kernel's lists; one bad time spares the rest.
fn take_lists(&mut self, notes: Vec<Note>, changes: Vec<Change>) {
let (threads, unread) = threads(&notes);
for bad in &unread {
log::error!("doc: a bad comment time {:?}: {}", bad.at, bad.error);
}
log::info!("doc: review lists {} notes, {} changes", notes.len(), changes.len());
(self.notes, self.threads, self.unread, self.changes) = (notes, threads, unread, changes);
}
/// Each unreadable note, by its author and the reason.
fn unread_lines(&self, cx: &App) -> Vec<SharedString> {
let line = |bad: &Unread| {
Docs::text(cx, "doc.review.unread", &[("author", &bad.author), ("at", &bad.at), ("error", &bad.error)])
};
self.unread.iter().map(line).collect()
}
/// The lines as one alert assistive tech reads whole.
fn unread_alert(&self, cx: &App) -> Option<gpui::AnyElement> {
let lines = self.unread_lines(cx);
let said: SharedString = lines.join("\n").into();
(!lines.is_empty()).then(|| {
div()
.id("doc-review-unread")
.role(gpui::Role::Alert)
.aria_label(said)
.flex()
.flex_col()
.gap_1()
.children(lines.into_iter().map(|line| InlineMessage::new(Severity::Danger, line)))
.into_any_element()
})
}
}
/// Notes threaded under their first comment; bad times apart.
fn threads(notes: &[Note]) -> (Vec<Thread>, Vec<Unread>) {
/// The notes as threads under their first comment.
fn threads(notes: &[Note]) -> Result<Vec<Thread>, String> {
let parents: HashMap<&str, &str> = notes
.iter()
.map(|note| (note.id.as_str(), note.parent.as_str()))
@@ -424,16 +384,13 @@ fn threads(notes: &[Note]) -> (Vec<Thread>, Vec<Unread>) {
id.to_string()
};
let authors: Vec<&str> = notes.iter().map(|note| note.author.as_str()).collect();
let (mut threads, mut unread): (Vec<Thread>, Vec<Unread>) = (Vec::new(), Vec::new());
let mut threads: Vec<Thread> = Vec::new();
for note in notes {
let read = note.at.parse::<jiff::civil::DateTime>().and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()));
let at = match read {
Ok(at) => at,
Err(error) => {
unread.push(Unread { author: note.author.clone(), at: note.at.clone(), error: error.to_string() });
continue;
}
};
let at = note
.at
.parse::<jiff::civil::DateTime>()
.and_then(|at| at.to_zoned(jiff::tz::TimeZone::system()))
.map_err(|error| format!("a bad comment time {}: {error}", note.at))?;
let hue = authors
.iter()
.position(|name| *name == note.author)
@@ -455,7 +412,7 @@ fn threads(notes: &[Note]) -> (Vec<Thread>, Vec<Unread>) {
}),
}
}
(threads, unread)
Ok(threads)
}
#[cfg(test)]
-37
View File
@@ -82,40 +82,3 @@ fn comments_thread_and_changes_accept_or_reject() {
}
assert!(!text.contains("gone"), "the rejected words go");
}
#[test]
fn one_unreadable_time_spares_the_other_notes() {
let note = |id: &str, parent: &str, at: &str| Note {
id: id.into(),
parent: parent.into(),
author: "A".into(),
text: "t".into(),
resolved: false,
at: at.into(),
anchor: (0, 0, 0, 0),
};
// The kernel writes an empty date as 0-00-00.
let mut undated = note("2", "0", "0-00-00");
undated.author = "Undated".into();
let notes = vec![note("1", "0", "2025-03-01T10:00:00"), undated, note("3", "1", "2025-03-02T09:00:00")];
let (threads, unread) = threads(&notes);
assert_eq!(threads.len(), 1);
assert_eq!(threads[0].comments.len(), 2, "the reply stays in its thread");
assert_eq!((unread.len(), unread[0].author.as_str(), unread[0].at.as_str()), (1, "Undated", "0-00-00"));
// Through the refresh path: changes stay, the line names both.
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = gpui::TestAppContext::single();
let Some(docs) = crate::tests::docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let review = cx.update(|app| docs.read(app).review.clone());
let change = Change { index: 7, author: "B".into(), kind: "Insert".into(), description: String::new(), at: "2025-03-01T10:00:00".into(), anchor: None };
let lines = cx.update(|cx| review.update(cx, |review, cx| {
review.take_lists(notes, vec![change]);
assert_eq!(review.changes.len(), 1, "the changes stay");
review.unread_lines(cx)
}));
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("Undated") && lines[0].contains(&unread[0].error), "{}", lines[0]);
}
+85
View File
@@ -0,0 +1,85 @@
//! Spelling suggestions from the kernel's context menu (DOC-12).
use ely_gpui_component::{
menus::{ContextMenu, Menu, MenuItem},
primitives::Severity,
};
use ely_lok::LokView;
use gpui::{Context, Entity, IntoElement, Pixels, Point};
use crate::docs::Docs;
use ely_office::prelude::*;
/// A misspelled word's menu, asked for at a point.
pub struct Offer {
tab: u64,
pub(crate) suggestions: Vec<(String, String)>,
/// Bumps for each menu, so each opens once.
request: (u64, Point<Pixels>),
}
impl Docs {
/// Turns the kernel's context menu into a spelling offer.
pub(crate) fn offer_spelling(
&mut self,
id: u64,
view: &Entity<LokView>,
cx: &mut Context<Self>,
) {
let Some(json) = view.update(cx, |view, _| view.take_menu()) else {
return;
};
let spelling = match ely_lok::review::spelling(&json) {
Ok(spelling) => spelling,
Err(error) => {
let text = Docs::text(cx, "office.error.engine", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
let Some(spelling) = spelling else {
log::info!("doc: the kernel menu has no spelling");
return;
};
let Some(caret) = view.read(cx).cursor_bounds() else {
log::warn!("doc: a spelling menu without a caret");
return;
};
let number = self.offer.as_ref().map_or(1, |offer| offer.request.0 + 1);
log::info!("doc: {} spelling suggestions", spelling.suggestions.len());
self.offer = Some(Offer {
tab: id,
suggestions: spelling.suggestions,
request: (number, caret.bottom_left()),
});
cx.notify();
}
/// The spelling menu's host, for the active tab.
pub(crate) fn spelling_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
let offer = self
.offer
.as_ref()
.filter(|offer| Some(offer.tab) == self.core.active)?;
let this = cx.entity();
let mut menu = Menu::new();
for (label, rule) in &offer.suggestions {
let (this, rule) = (this.clone(), rule.clone());
menu = menu.item(MenuItem::new(label.clone()).on_click(move |_, cx| {
this.update(cx, |docs, cx| docs.run_active("spell.apply", &[&rule], cx))
}));
}
if offer.suggestions.is_empty() {
let none = Docs::text(cx, "doc.spell.none", &[]);
menu = menu.item(MenuItem::new(none).disabled(true));
}
let ignore = Docs::text(cx, "doc.spell.ignore_all", &[]);
let menu = menu
.separator()
.item(MenuItem::new(ignore).on_click(move |_, cx| {
this.update(cx, |docs, cx| {
docs.run_active("spell.ignore_all", &["Spelling"], cx)
})
}));
Some(ContextMenu::new("doc-spelling", menu).manual(Some(offer.request)))
}
}
+1 -10
View File
@@ -365,7 +365,7 @@ fn a_macro_file_opens_in_risk_mode_and_saves_only_as_a_copy() {
);
cx.update(|app| {
assert!(
docs.read(app).active_tab().expect("a tab").risk.is_none(),
!docs.read(app).active_tab().expect("a tab").risk.is_some(),
"the DOCX copy holds no macros"
)
});
@@ -463,12 +463,3 @@ 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);
}
}
+1 -2
View File
@@ -126,8 +126,7 @@ fn a_misspelled_word_offers_suggestions() {
settle(&cx, |app| docs.read(app).offer.is_some());
let rule = cx.update(|app| {
let offer = docs.read(app).offer.as_ref().expect("an offer");
let spelling = offer.what.spelling.as_ref().expect("a spelling menu");
let (_, rule) = spelling
let (_, rule) = offer
.suggestions
.iter()
.find(|(label, _)| label == "The")
-86
View File
@@ -215,89 +215,3 @@ 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");
}
#[test]
fn a_tab_on_a_vanished_folder_takes_no_other_file() {
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);
let gone = dir.path().join("volume");
std::fs::create_dir(&gone).expect("a folder");
let first = gone.join("first.docx");
with_window(&mut cx, &docs, |docs, window, cx| docs.write_as(id, first.clone(), None, window, cx));
written(&cx, &first, None);
// Its volume goes away; another file still saves.
std::fs::remove_dir_all(&gone).expect("the folder goes");
let other = dir.path().join("other.docx");
let holder = cx.update(|app| docs.read(app).holder(&other).map_err(|error| error.to_string()));
assert_eq!(holder, Ok(None));
}
#[test]
fn an_edit_after_a_save_raced_it_counts_again() {
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);
type_into(&mut cx, &docs, "a");
settle(&cx, |app| docs.read(app).tab(id).is_some_and(|tab| tab.modified));
// A save's ticket landed after this edit: saved, still modified.
cx.update(|app| docs.update(app, |docs, _| docs.tab_mut(id).expect("a tab").session.reverted()));
type_into(&mut cx, &docs, "b");
settle(&cx, |app| docs.read(app).tab(id).is_some_and(|tab| tab.session.state() == ely_files::SaveState::Dirty));
}
+2 -2
View File
@@ -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.field_here("Name", cx));
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
let beta = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SourceBeta ");
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
settled(&cx, &docs);
let csv = dir.path().join("two.csv");
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
-16
View File
@@ -5,26 +5,10 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies]
ely-gpui-component.workspace = true
ely-mail-engine.workspace = true
ely-platform.workspace = true
ely-ui.workspace = true
gpui.workspace = true
jiff.workspace = true
log.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread"] }
[dev-dependencies]
ely-ui = { workspace = true, features = ["test-support"] }
ely-gpui-component = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
tempfile.workspace = true
[lints]
workspace = true
-285
View File
@@ -1,285 +0,0 @@
//! The add-account dialog (MAIL-01, 02).
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::DescriptionList,
feedback::{Banner, InlineMessage},
forms::{Input, PasswordInput, Switch},
overlays::Dialog,
primitives::Severity,
};
use ely_mail_engine::{Auth, Error, Security};
use gpui::{
AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Styled as _,
WeakEntity, Window, div,
};
use crate::{
adding::{Adding, Step},
kinds::{APPLE_ACCOUNT, KINDS, Kind},
mail::{Mail, text},
view::auth,
};
type Edit = fn(&mut Adding, &str, &mut App);
/// A segmented choice whose change edits the dialog.
fn choice(
this: &WeakEntity<Mail>,
id: &'static str,
picked: &str,
options: impl IntoIterator<Item = (&'static str, SharedString)>,
edit: Edit,
) -> SegmentedControl {
let this = this.clone();
options
.into_iter()
.fold(
SegmentedControl::new(id, picked.to_owned()),
|control, (key, label)| control.segment(key, label, None),
)
.on_change(move |key, _, cx| {
this.update(cx, |mail, cx| {
if let Some(adding) = &mut mail.adding {
edit(adding, key, cx);
cx.notify();
}
})
.ok();
})
}
fn security_key(security: Security) -> &'static str {
match security {
Security::Tls => "tls",
Security::StartTls => "starttls",
}
}
fn security_of(key: &str) -> Security {
match key {
"tls" => Security::Tls,
_ => Security::StartTls,
}
}
fn auth_key(auth: Auth) -> &'static str {
match auth {
Auth::Password => "password",
Auth::Google => "google",
Auth::Microsoft => "microsoft",
}
}
impl Mail {
pub fn add_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let adding = self.adding.as_ref()?;
let this = cx.weak_entity();
let dismiss = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |mail, cx| mail.close_adding(cx)).ok();
}
};
let kinds = KINDS.map(|kind| {
(
kind.key(),
text(cx, &format!("mail.kind.{}", kind.key()), &[]),
)
});
let kinds = choice(
&this,
"mail-add-kind",
adding.kind.key(),
kinds,
|adding, key, cx| {
if let Some(kind) = Kind::from_key(key) {
adding.set_kind(kind, cx);
}
},
);
let oauth = adding.auth() != Auth::Password;
let unavailable = match adding.auth().provider() {
Some(Err(Error::Unavailable(name))) => {
Some(text(cx, &format!("mail.add.unavailable.{name}"), &[]))
}
_ => None,
};
let cancel = text(cx, "mail.cancel", &[]);
let (go_key, go): (&str, fn(&mut Mail, &mut Context<Mail>)) = match &adding.step {
Step::Checked { checked, .. } if checked.passed() => ("mail.add.save", Mail::save),
_ if oauth => ("mail.add.sign_in", Mail::sign_in),
_ => ("mail.add.check", Mail::check),
};
let go_label = text(cx, go_key, &[("method", &auth(adding.auth(), cx))]);
let busy = adding.busy();
let blocked = busy || (unavailable.is_some() && oauth);
let dialog = Dialog::new("mail-add", text(cx, "mail.add.title", &[]), dismiss)
.child(kinds)
.children(
unavailable
.map(|words| Banner::new("mail-add-unavailable", Severity::Danger, words)),
)
.child(self.add_fields(adding, &this, cx))
.children(status(adding, cx))
.action(move |close| {
Button::new("mail-add-cancel", cancel)
.on_click(move |_, window, cx| close(window, cx))
})
.action(move |_| {
Button::new("mail-add-go", go_label)
.variant(ButtonVariant::Primary)
.loading(busy)
.disabled(blocked)
.on_click(move |_, _, cx| {
this.update(cx, go).ok();
})
});
Some(dialog.into_any_element())
}
fn add_fields(&self, adding: &Adding, this: &WeakEntity<Mail>, cx: &App) -> AnyElement {
let label = |key: &str| text(cx, key, &[]);
let field = |input| div().w_full().child(Input::new(input));
let fields = &adding.fields;
let password = div().w_full().child(PasswordInput::new(&fields.password));
match adding.kind {
Kind::Gmail | Kind::Microsoft => DescriptionList::new()
.item(label("mail.add.address_hint"), field(&fields.address))
.into_any_element(),
Kind::ICloud => {
let open = Button::new("mail-add-apple", label("mail.icloud.open"))
.on_click(|_, _, cx| cx.open_url(APPLE_ACCOUNT));
let rows = DescriptionList::new()
.item(label("mail.add.address"), field(&fields.address))
.item(label("mail.icloud.password"), password);
div()
.flex()
.flex_col()
.gap_3()
.child(label("mail.icloud.steps"))
.child(div().child(open))
.child(rows)
.into_any_element()
}
Kind::Imap | Kind::Pop => {
let auths = [Auth::Password, Auth::Google, Auth::Microsoft]
.map(|each| (auth_key(each), auth(each, cx)));
let methods = choice(
this,
"mail-add-auth",
auth_key(adding.auth),
auths,
|adding, key, _| {
adding.stop();
adding.step = Step::Editing;
adding.auth = match key {
"google" => Auth::Google,
"microsoft" => Auth::Microsoft,
_ => Auth::Password,
};
},
);
let securities = |outgoing: bool, picked: Security| {
let options = [Security::Tls, Security::StartTls].map(|each| {
(
security_key(each),
label(&format!("mail.security.{}", security_key(each))),
)
});
let id = if outgoing {
"mail-add-out-security"
} else {
"mail-add-in-security"
};
let edit: Edit = if outgoing {
|adding, key, cx| adding.set_security(true, security_of(key), cx)
} else {
|adding, key, cx| adding.set_security(false, security_of(key), cx)
};
choice(this, id, security_key(picked), options, edit)
};
let mut rows = DescriptionList::new()
.item(label("mail.add.address"), field(&fields.address))
.item(label("mail.auth.title"), methods);
if adding.auth == Auth::Password {
rows = rows
.item(label("mail.add.user"), field(&fields.user))
.item(label("mail.add.password"), password);
}
if adding.kind == Kind::Pop {
let this = this.clone();
let keep = Switch::new("mail-add-keep", adding.keep)
.name(label("mail.add.keep"))
.on_change(move |on, _, cx| {
this.update(cx, |mail, cx| {
if let Some(adding) = &mut mail.adding {
adding.set_keep(on);
cx.notify();
}
})
.ok();
});
rows = rows.item(label("mail.add.keep"), keep);
}
rows.item(label("mail.add.incoming"), field(&fields.incoming))
.item(label("mail.add.port"), field(&fields.incoming_port))
.item(
label("mail.add.security"),
securities(false, adding.incoming_security),
)
.item(label("mail.add.outgoing"), field(&fields.outgoing))
.item(label("mail.add.port"), field(&fields.outgoing_port))
.item(
label("mail.add.security"),
securities(true, adding.outgoing_security),
)
.into_any_element()
}
}
}
}
/// Each server's answer, or what is under way.
fn status(adding: &Adding, cx: &App) -> Option<AnyElement> {
let line =
|severity, words: SharedString| InlineMessage::new(severity, words).into_any_element();
let said = |key: &str, result: &ely_mail_engine::Result<()>| match result {
Ok(()) => line(
Severity::Success,
text(cx, key, &[("result", &text(cx, "mail.check.passed", &[]))]),
),
Err(error) => line(
Severity::Danger,
text(cx, key, &[("result", &failure(error, cx))]),
),
};
Some(match &adding.step {
Step::Editing => return None,
Step::Signing => line(Severity::Info, text(cx, "mail.add.signing", &[])),
Step::Checking => line(Severity::Info, text(cx, "mail.add.checking", &[])),
Step::Saving => line(Severity::Info, text(cx, "mail.add.saving", &[])),
Step::Failed(words) => line(Severity::Danger, words.clone()),
Step::Checked { checked, .. } => div()
.flex()
.flex_col()
.gap_1()
.child(said("mail.check.incoming", &checked.incoming))
.child(said("mail.check.outgoing", &checked.outgoing))
.into_any_element(),
})
}
/// A server's refusal in the reader's language, its words kept.
pub fn failure(error: &Error, cx: &App) -> SharedString {
let (key, detail) = match error {
Error::Connect(err) => ("mail.error.connect", err.to_string()),
Error::Tls(detail) => ("mail.error.tls_failed", detail.clone()),
Error::Rejected(detail) => ("mail.error.rejected", detail.clone()),
Error::Server(detail) => ("mail.error.server", detail.clone()),
Error::Protocol(detail) => ("mail.error.protocol", detail.clone()),
Error::Timeout(step) => ("mail.error.timeout", (*step).to_owned()),
other => ("mail.error.other", other.to_string()),
};
text(cx, key, &[("detail", &detail)])
}
-262
View File
@@ -1,262 +0,0 @@
//! The add-account dialog's fields and steps (MAIL-01, 02).
use ely_gpui_component::forms::{InputEvent, TextInput};
use ely_mail_engine::{Account, Auth, Checked, Security, Server};
use gpui::{App, AppContext as _, Context, Entity, SharedString, Subscription, Window};
use tokio::task::AbortHandle;
use crate::{
kinds::{self, Kind},
mail::{Mail, text},
};
pub struct Fields {
pub address: Entity<TextInput>,
pub user: Entity<TextInput>,
pub password: Entity<TextInput>,
pub incoming: Entity<TextInput>,
pub incoming_port: Entity<TextInput>,
pub outgoing: Entity<TextInput>,
pub outgoing_port: Entity<TextInput>,
}
pub enum Step {
Editing,
/// The browser holds the provider's page.
Signing,
Checking,
/// `kept` goes to the vault: a password or refresh token.
Checked {
account: Account,
kept: String,
checked: Checked,
},
Failed(SharedString),
Saving,
}
pub struct Adding {
pub kind: Kind,
pub fields: Fields,
pub incoming_security: Security,
pub outgoing_security: Security,
/// Typed-in accounts choose; providers fix it.
pub auth: Auth,
/// POP3 leaves a copy on the server (§12.3).
pub keep: bool,
pub step: Step,
/// The ports last filled in; any other was typed.
filled: [String; 2],
/// The request awaited; any other answer is dropped.
pub asked: Option<u64>,
pub task: Option<AbortHandle>,
_edits: Vec<Subscription>,
}
impl Drop for Adding {
fn drop(&mut self) {
self.stop();
}
}
impl Adding {
pub fn new(kind: Kind, window: &mut Window, cx: &mut Context<Mail>) -> Self {
let mut field = |key: &str, masked: bool, cx: &mut Context<Mail>| {
let words = text(cx, key, &[]);
cx.new(|cx| {
let input = TextInput::new(window, cx)
.placeholder(words.clone())
.label(words);
if masked { input.masked() } else { input }
})
};
let address = field("mail.add.address", false, cx);
let user = field("mail.add.user", false, cx);
let password = field("mail.add.password", true, cx);
let incoming = field("mail.add.incoming", false, cx);
let incoming_port = field("mail.add.port", false, cx);
let outgoing = field("mail.add.outgoing", false, cx);
let outgoing_port = field("mail.add.port", false, cx);
let fields = Fields {
address,
user,
password,
incoming,
incoming_port,
outgoing,
outgoing_port,
};
let edits = [
&fields.address,
&fields.user,
&fields.password,
&fields.incoming,
&fields.incoming_port,
&fields.outgoing,
&fields.outgoing_port,
]
.into_iter()
.map(|field| {
cx.subscribe(field, |mail: &mut Mail, _, event: &InputEvent, cx| {
if *event == InputEvent::Changed {
mail.edited(cx);
}
})
})
.collect();
let mut adding = Adding {
kind,
fields,
incoming_security: Security::Tls,
outgoing_security: Security::StartTls,
auth: Auth::Password,
keep: true,
step: Step::Editing,
filled: Default::default(),
asked: None,
task: None,
_edits: edits,
};
adding.set_ports(cx);
adding
}
pub fn stop(&mut self) {
if let Some(task) = self.task.take() {
task.abort();
}
self.asked = None;
}
/// A new kind keeps what was typed.
pub fn set_kind(&mut self, kind: Kind, cx: &mut App) {
self.stop();
self.kind = kind;
self.step = Step::Editing;
self.set_ports(cx);
}
pub fn set_security(&mut self, outgoing: bool, security: Security, cx: &mut App) {
self.stop();
self.step = Step::Editing;
if outgoing {
self.outgoing_security = security;
} else {
self.incoming_security = security;
}
self.set_ports(cx);
}
pub fn set_keep(&mut self, keep: bool) {
self.stop();
self.step = Step::Editing;
self.keep = keep;
}
/// The usual ports, unless one was typed.
fn set_ports(&mut self, cx: &mut App) {
let ports = [
kinds::port(Some(self.kind.protocol(self.keep)), self.incoming_security),
kinds::port(None, self.outgoing_security),
];
let fields = [&self.fields.incoming_port, &self.fields.outgoing_port];
for ((field, port), filled) in fields.into_iter().zip(ports).zip(&mut self.filled) {
let typed = field.read(cx).text().trim().to_owned();
if typed.is_empty() || typed == *filled {
*filled = port.to_string();
field.update(cx, |field, cx| field.set_text(filled.clone(), cx));
}
}
}
/// The sign-in this kind uses.
pub fn auth(&self) -> Auth {
match self.kind.known() {
Some((auth, ..)) => auth,
None => self.auth,
}
}
fn read(&self, field: &Entity<TextInput>, cx: &App) -> String {
field.read(cx).text().trim().to_owned()
}
/// The servers as known or typed.
pub fn servers(&self, cx: &App) -> Result<(Server, Server), SharedString> {
if let Some((_, incoming, outgoing)) = self.kind.known() {
return Ok((incoming, outgoing));
}
let server = |host: &Entity<TextInput>, port: &Entity<TextInput>, security| {
let host = self.read(host, cx);
if host.is_empty() {
return Err(text(cx, "mail.add.need_host", &[]));
}
let port = self.read(port, cx);
let port = port
.parse()
.ok()
.filter(|port| *port > 0)
.ok_or_else(|| text(cx, "mail.add.bad_port", &[("port", &port)]))?;
Ok(Server {
host,
port,
security,
})
};
Ok((
server(
&self.fields.incoming,
&self.fields.incoming_port,
self.incoming_security,
)?,
server(
&self.fields.outgoing,
&self.fields.outgoing_port,
self.outgoing_security,
)?,
))
}
/// The account the fields name; `signed` is an OAuth address.
pub fn account(&self, signed: Option<&str>, cx: &App) -> Result<Account, SharedString> {
let address = match signed {
Some(address) => address.to_owned(),
None => self.read(&self.fields.address, cx),
};
if address.is_empty() {
return Err(text(cx, "mail.add.need_address", &[]));
}
let user = match (signed, self.kind) {
(None, Kind::Imap | Kind::Pop) => self.read(&self.fields.user, cx),
_ => address.clone(),
};
if user.is_empty() {
return Err(text(cx, "mail.add.need_user", &[]));
}
let (incoming, outgoing) = self.servers(cx)?;
let account = Account {
address,
user,
auth: self.auth(),
protocol: self.kind.protocol(self.keep),
incoming,
outgoing,
};
account
.valid()
.map_err(|err| text(cx, "mail.add.invalid", &[("error", &err.to_string())]))?;
Ok(account)
}
pub fn password(&self, cx: &App) -> Result<String, SharedString> {
let password = self.fields.password.read(cx).text().to_owned();
if password.is_empty() {
return Err(text(cx, "mail.add.need_password", &[]));
}
Ok(password)
}
pub fn busy(&self) -> bool {
matches!(self.step, Step::Signing | Step::Checking | Step::Saving)
}
}
-204
View File
@@ -1,204 +0,0 @@
//! What the window has open, and its list.
use std::{collections::HashMap, rc::Rc};
use ely_gpui_component::mail::Mail as Row;
use ely_mail_engine::{AccountId, Filter, MailboxId, Role, Scope, Summary};
use gpui::{Context, SharedString};
use crate::mail::{Mail, text};
pub type Unread = HashMap<MailboxId, u32>;
/// What the middle of the window shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Open {
Inboxes,
Mailbox(AccountId, MailboxId),
/// The account's settings and sync.
Account(AccountId),
}
impl Open {
pub fn key(self) -> SharedString {
match self {
Open::Inboxes => "inboxes".into(),
Open::Mailbox(_, mailbox) => format!("box-{}", mailbox.0).into(),
Open::Account(id) => format!("account-{}", id.0).into(),
}
}
pub fn account(self) -> Option<AccountId> {
match self {
Open::Inboxes => None,
Open::Mailbox(id, _) | Open::Account(id) => Some(id),
}
}
fn scope(self) -> Option<Scope> {
match self {
Open::Inboxes => Some(Scope::Inboxes),
Open::Mailbox(_, mailbox) => Some(Scope::Mailbox(mailbox)),
Open::Account(_) => None,
}
}
}
/// Rows read, and where each key sits.
pub struct Indexed {
rows: Vec<Row>,
at: HashMap<SharedString, usize>,
}
/// Off the UI thread: a long list takes a while.
pub fn index(rows: Vec<Row>) -> Indexed {
let at = rows
.iter()
.enumerate()
.map(|(ix, row)| (row.key.clone(), ix))
.collect();
Indexed { rows, at }
}
/// The open list and the messages picked in it.
#[derive(Default)]
pub struct Listing {
/// Numbers reads; an older one is dropped.
pub read: u64,
/// None until read, or when nothing lists mail.
pub mails: Option<Result<Rc<Vec<Row>>, String>>,
at: HashMap<SharedString, usize>,
pub selected: Vec<SharedString>,
/// The one message picked, for the reading pane.
pub chosen: Option<Row>,
}
impl Listing {
pub fn show(&mut self, mails: Option<Result<Indexed, String>>) {
self.at = HashMap::new();
self.mails = mails.map(|read| {
read.map(|indexed| {
self.at = indexed.at;
Rc::new(indexed.rows)
})
});
}
/// Keeps the picks still listed.
fn pick(&mut self, keys: Vec<SharedString>) {
let Some(Ok(mails)) = &self.mails else {
self.selected.clear();
self.chosen = None;
return;
};
self.selected = keys
.into_iter()
.filter(|key| self.at.contains_key(key))
.collect();
self.chosen = match &self.selected[..] {
[key] => Some(mails[self.at[key]].clone()),
_ => None,
};
}
}
impl Mail {
pub fn open_box(&mut self, open: Open, cx: &mut Context<Self>) {
log::info!("mail: open {}", open.key());
if open != self.open {
self.open = open;
self.listing.show(None);
self.listing.pick(Vec::new());
}
self.read_mail(cx);
self.notify(cx);
}
pub fn set_filter(&mut self, filter: Filter, cx: &mut Context<Self>) {
log::info!("mail: filter {filter:?}");
self.filter = filter;
self.read_mail(cx);
cx.notify();
}
pub fn select(&mut self, keys: &[SharedString], cx: &mut Context<Self>) {
self.listing.pick(keys.to_vec());
cx.notify();
}
/// Unread counts, and the open list when it lists mail.
pub fn read_mail(&mut self, cx: &mut Context<Self>) {
self.listing.read += 1;
let (read, scope, filter) = (self.listing.read, self.open.scope(), self.filter);
let none = (
text(cx, "mail.no_sender", &[]),
text(cx, "mail.no_subject", &[]),
);
self.with_store(
cx,
move |store| {
let rows = match scope {
Some(scope) => {
let list = store.list(scope, filter)?;
Some(index(
list.into_iter().map(|each| row(each, &none)).collect(),
))
}
None => None,
};
Ok((rows, store.unread()?))
},
move |mail, read_now, _| {
if mail.listing.read != read {
return log::info!("mail: list read {read} ended after a newer one");
}
match read_now {
Ok((rows, unread)) => {
mail.unread = unread;
mail.listing.show(rows.map(Ok));
}
Err(error) => {
log::warn!("mail: list read {read} failed: {error}");
mail.listing.show(scope.map(|_| Err(error)));
}
}
let keys = std::mem::take(&mut mail.listing.selected);
mail.listing.pick(keys);
},
);
}
/// Unread mail in every account's inbox.
pub fn inboxes_unread(&self) -> u32 {
self.syncs
.values()
.flat_map(|syncing| &syncing.folders)
.filter(|folder| folder.role == Some(Role::Inbox))
.filter_map(|folder| self.unread.get(&folder.id))
.sum()
}
}
/// `none` names a missing sender, then subject.
fn row(summary: Summary, none: &(SharedString, SharedString)) -> Row {
let key = summary.id.0.to_string();
let from = summary.from.first().map(|from| match &from.name {
Some(name) if !name.trim().is_empty() => name.clone(),
_ => from.email.clone(),
});
let from: SharedString = from.map_or_else(|| none.0.clone(), Into::into);
let subject: SharedString = summary.subject.map_or_else(|| none.1.clone(), Into::into);
let row = match summary.date.map(jiff::Timestamp::from_second) {
Some(Ok(at)) => Row::new(key, from, subject, at),
Some(Err(error)) => {
log::warn!("mail: message {} date unshown: {error}", summary.id.0);
Row::undated(key, from, subject)
}
None => Row::undated(key, from, subject),
};
let row = row.unread(!summary.seen).starred(summary.flagged);
match summary.attachments {
Some(count) => row.attachments(count as usize),
None => row,
}
}
-88
View File
@@ -1,88 +0,0 @@
//! Sync now, pause, and folders kept whole.
use ely_gpui_component::{buttons::Button, feedback::Banner, forms::Switch, primitives::Severity};
use ely_mail_engine::{AccountId, Protocol};
use gpui::{
AnyElement, Context, InteractiveElement as _, IntoElement, ParentElement as _, Styled as _, div,
};
use crate::mail::{Mail, text};
impl Mail {
pub fn sync_buttons(&self, id: AccountId, cx: &mut Context<Self>) -> AnyElement {
let running = self.syncs.get(&id).is_some_and(|each| each.task.is_some());
let this = cx.weak_entity();
let now = Button::new("mail-sync-now", text(cx, "mail.sync.now", &[]))
.disabled(running || self.paused)
.on_click({
let this = this.clone();
move |_, _, cx| {
this.update(cx, |mail, cx| mail.sync(id, cx)).ok();
}
});
let key = if self.paused {
"mail.sync.resume"
} else {
"mail.sync.pause"
};
let pause = Button::new("mail-sync-pause", text(cx, key, &[])).on_click(move |_, _, cx| {
this.update(cx, |mail, cx| mail.set_paused(!mail.paused, cx))
.ok();
});
div()
.flex()
.gap_2()
.child(now)
.child(pause)
.into_any_element()
}
pub fn paused_banner(&self, cx: &Context<Self>) -> Option<Banner> {
self.paused.then(|| {
Banner::new(
"mail-sync-paused",
Severity::Info,
text(cx, "mail.sync.paused", &[]),
)
})
}
/// One switch per IMAP folder.
pub fn offline_switches(
&self,
id: AccountId,
protocol: Protocol,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
if protocol != Protocol::Imap {
return None;
}
let folders = &self.syncs.get(&id)?.folders;
if folders.is_empty() {
return None;
}
let this = cx.weak_entity();
let switches = folders.iter().map(|folder| {
let mailbox = folder.id;
let this = this.clone();
let label = text(cx, "mail.sync.offline", &[("name", &folder.name)]);
let switch = Switch::new(("mail-offline", mailbox.0 as u64), folder.offline)
.label(label)
.on_change(move |on, _, cx| {
this.update(cx, |mail, cx| mail.set_offline(id, mailbox, on, cx))
.ok();
});
div()
.debug_selector(move || format!("mail-offline-{}", mailbox.0))
.child(switch)
});
Some(
div()
.flex()
.flex_col()
.gap_2()
.children(switches)
.into_any_element(),
)
}
}
-289
View File
@@ -1,289 +0,0 @@
//! Checking, signing in and adding an account (MAIL-01, 02).
use ely_gpui_component::primitives::Severity;
use ely_mail_engine::{Account, Checked, Error, Kept, Secret, oauth};
use gpui::{Context, SharedString, Window};
use crate::{
adding::{Adding, Step},
kinds::Kind,
mail::{Mail, text},
};
impl Mail {
pub fn start_adding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.adding.is_none() && self.removing.is_none() {
self.adding = Some(Adding::new(Kind::Gmail, window, cx));
}
cx.notify();
}
pub fn close_adding(&mut self, cx: &mut Context<Self>) {
self.adding = None;
cx.notify();
}
/// A field changed: an earlier check no longer holds.
pub fn edited(&mut self, cx: &mut Context<Self>) {
let Some(adding) = &mut self.adding else {
return;
};
if !matches!(adding.step, Step::Editing | Step::Saving) {
adding.stop();
adding.step = Step::Editing;
cx.notify();
}
}
/// Starts a request for the open dialog.
fn ask(&mut self) -> Option<u64> {
let adding = self.adding.as_mut()?;
adding.stop();
self.asked += 1;
adding.asked = Some(self.asked);
Some(self.asked)
}
/// The open dialog, if it awaits `asked`.
fn awaiting(&mut self, asked: u64) -> Option<&mut Adding> {
self.adding
.as_mut()
.filter(|adding| adding.asked == Some(asked))
}
fn halt(&mut self, words: SharedString, cx: &mut Context<Self>) {
if let Some(adding) = &mut self.adding {
adding.stop();
adding.step = Step::Failed(words);
}
cx.notify();
}
/// The words when the address is already here.
fn taken(&self, address: &str, cx: &Context<Self>) -> Option<SharedString> {
self.accounts()
.iter()
.any(|(_, account)| account.address.eq_ignore_ascii_case(address))
.then(|| text(cx, "mail.add.taken", &[("address", address)]))
}
/// Checks a typed-in password with both servers.
pub fn check(&mut self, cx: &mut Context<Self>) {
let Some(adding) = &self.adding else { return };
let ready = adding
.account(None, cx)
.and_then(|account| Ok((account, adding.password(cx)?)));
match ready {
Ok((account, password)) => {
self.probe(account, password.clone(), Secret::Password(password), cx)
}
Err(words) => self.halt(words, cx),
}
}
/// Logs in with `secret`; `kept` goes to the vault after.
fn probe(&mut self, account: Account, kept: String, secret: Secret, cx: &mut Context<Self>) {
if let Some(words) = self.taken(&account.address, cx) {
return self.halt(words, cx);
}
let tls = match &self.tls {
Ok(tls) => tls.clone(),
Err(error) => return self.halt(text(cx, "mail.error.tls", &[("error", error)]), cx),
};
let Some(asked) = self.ask() else { return };
if let Some(adding) = &mut self.adding {
adding.step = Step::Checking;
}
log::info!("mail: checking a {:?} account", account.protocol);
let probe = account.clone();
let work = async move { probe.check(secret, &tls).await };
let task = self.on_runtime(cx, work, move |mail, done, cx| {
mail.checked(asked, account, kept, done, cx)
});
if let Some(adding) = &mut self.adding {
adding.task = Some(task);
}
cx.notify();
}
fn checked(
&mut self,
asked: u64,
account: Account,
kept: String,
done: Result<Checked, String>,
cx: &mut Context<Self>,
) {
let Some(adding) = self.awaiting(asked) else {
return log::info!("mail: a stale check dropped");
};
adding.task = None;
adding.step = match done {
Ok(checked) => Step::Checked {
account,
kept,
checked,
},
Err(error) => Step::Failed(text(cx, "mail.add.check_failed", &[("error", &error)])),
};
}
/// Opens the provider's page, then checks what it grants.
pub fn sign_in(&mut self, cx: &mut Context<Self>) {
let Some(adding) = &self.adding else { return };
let provider = match adding.auth().provider() {
Some(Ok(provider)) => provider,
Some(Err(error)) => return self.halt(unsigned(&error, cx), cx),
None => return log::error!("mail: sign-in asked of a password account"),
};
if let Err(words) = adding.servers(cx) {
return self.halt(words, cx);
}
let hint = adding.fields.address.read(cx).text().trim().to_owned();
let Some(asked) = self.ask() else { return };
if let Some(adding) = &mut self.adding {
adding.step = Step::Signing;
}
let work = async move { oauth::SignIn::start(provider, &hint).await };
let task = self.on_runtime(cx, work, move |mail, started, cx| {
if mail.awaiting(asked).is_none() {
return log::info!("mail: a stale sign-in dropped");
}
match started.map_err(|error| text(cx, "mail.add.check_failed", &[("error", &error)])) {
Ok(Ok(sign_in)) => mail.wait_for_browser(asked, sign_in, cx),
Ok(Err(error)) => mail.halt(unsigned(&error, cx), cx),
Err(words) => mail.halt(words, cx),
}
});
if let Some(adding) = &mut self.adding {
adding.task = Some(task);
}
cx.notify();
}
fn wait_for_browser(&mut self, asked: u64, sign_in: oauth::SignIn, cx: &mut Context<Self>) {
cx.open_url(sign_in.url());
log::info!("mail: the browser has the sign-in page");
let work = async move {
let tokens = sign_in.finish().await?;
let email = tokens
.email
.clone()
.ok_or_else(|| Error::SignIn("the provider named no address".into()))?;
let refresh = tokens
.refresh
.clone()
.ok_or_else(|| Error::SignIn("no refresh token granted".into()))?;
Ok::<_, Error>((tokens.access, refresh, email))
};
let task = self.on_runtime(cx, work, move |mail, done, cx| {
if mail.awaiting(asked).is_none() {
return log::info!("mail: a stale sign-in dropped");
}
let account = match done {
Ok(Ok((access, refresh, email))) => mail.adding.as_ref().map(|adding| {
adding
.account(Some(&email), cx)
.map(|account| (account, access, refresh))
}),
Ok(Err(error)) => Some(Err(unsigned(&error, cx))),
Err(error) => Some(Err(text(cx, "mail.add.check_failed", &[("error", &error)]))),
};
match account {
Some(Ok((account, access, refresh))) => {
mail.probe(account, refresh, Secret::Token(access), cx)
}
Some(Err(words)) => mail.halt(words, cx),
None => {}
}
});
if let Some(adding) = &mut self.adding {
adding.task = Some(task);
}
}
/// Stores the checked account, then its secret.
pub fn save(&mut self, cx: &mut Context<Self>) {
let Some(Adding {
step:
Step::Checked {
account,
kept,
checked,
},
..
}) = &self.adding
else {
return;
};
if !checked.passed() {
return;
}
if let Some(words) = self.taken(&account.address, cx) {
return self.halt(words, cx);
}
let (account, kept) = (account.clone(), kept.clone());
let Some(asked) = self.ask() else { return };
if let Some(adding) = &mut self.adding {
adding.step = Step::Saving;
}
let vault = self.vault.clone();
let work = move |store: &mut ely_mail_engine::Store| {
let id = match store.add_account(&account) {
Ok(id) => id,
Err(Error::Taken(address)) => return Ok(Err(address)),
Err(error) => return Err(error),
};
let held = vault.put(&account.secret_name(), &kept);
Ok(Ok((id, account, held)))
};
self.with_store(cx, work, move |mail, saved, cx| match saved {
Ok(Err(address)) => {
let words = text(cx, "mail.add.taken", &[("address", &address)]);
match mail.awaiting(asked) {
Some(_) => mail.halt(words, cx),
None => mail.toast(words, Severity::Danger, cx),
}
}
Ok(Ok((id, account, held))) => {
log::info!("mail: account {} added", id.0);
let words = text(cx, "mail.add.added", &[("address", &account.address)]);
if let Some(Ok(accounts)) = &mut mail.accounts {
accounts.push((id, account));
}
mail.open_box(crate::boxes::Open::Account(id), cx);
mail.sync(id, cx);
if mail.awaiting(asked).is_some() {
mail.adding = None;
}
mail.toast(words, Severity::Success, cx);
if let Kept::Session(reason) = held {
mail.toast(
text(cx, "mail.add.session_only", &[("error", &reason)]),
Severity::Warning,
cx,
);
}
}
Err(error) => {
let words = text(cx, "mail.add.save_failed", &[("error", &error)]);
match mail.awaiting(asked) {
Some(_) => mail.halt(words, cx),
None => mail.toast(words, Severity::Danger, cx),
}
}
});
}
}
/// Sign-in failures, with this build's missing client named.
fn unsigned(error: &Error, cx: &gpui::App) -> SharedString {
match error {
Error::Unavailable(name) => text(cx, &format!("mail.add.unavailable.{name}"), &[]),
other => text(
cx,
"mail.add.sign_in_failed",
&[("error", &other.to_string())],
),
}
}
-87
View File
@@ -1,87 +0,0 @@
//! The kinds of account and their known servers.
use ely_mail_engine::{Auth, Protocol, Security, Server};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Gmail,
Microsoft,
ICloud,
Imap,
Pop,
}
pub const KINDS: [Kind; 5] = [
Kind::Gmail,
Kind::Microsoft,
Kind::ICloud,
Kind::Imap,
Kind::Pop,
];
/// Where iCloud makes app-specific passwords.
pub const APPLE_ACCOUNT: &str = "https://account.apple.com";
impl Kind {
pub fn key(self) -> &'static str {
match self {
Kind::Gmail => "gmail",
Kind::Microsoft => "microsoft",
Kind::ICloud => "icloud",
Kind::Imap => "imap",
Kind::Pop => "pop",
}
}
pub fn from_key(key: &str) -> Option<Kind> {
KINDS.into_iter().find(|kind| kind.key() == key)
}
/// `keep` is POP3's server copy.
pub fn protocol(self, keep: bool) -> Protocol {
match self {
Kind::Pop => Protocol::Pop { keep },
_ => Protocol::Imap,
}
}
/// The provider's servers and sign-in; None when typed in.
pub fn known(self) -> Option<(Auth, Server, Server)> {
let server = |host: &str, port, security| Server {
host: host.into(),
port,
security,
};
match self {
Kind::Gmail => Some((
Auth::Google,
server("imap.gmail.com", 993, Security::Tls),
server("smtp.gmail.com", 465, Security::Tls),
)),
Kind::Microsoft => Some((
Auth::Microsoft,
server("outlook.office365.com", 993, Security::Tls),
server("smtp.office365.com", 587, Security::StartTls),
)),
// Apple: IMAP takes the full address too.
Kind::ICloud => Some((
Auth::Password,
server("imap.mail.me.com", 993, Security::Tls),
server("smtp.mail.me.com", 587, Security::StartTls),
)),
Kind::Imap | Kind::Pop => None,
}
}
}
/// The usual port for a protocol under a security.
pub fn port(protocol: Option<Protocol>, security: Security) -> u16 {
match (protocol, security) {
(Some(Protocol::Imap), Security::Tls) => 993,
(Some(Protocol::Imap), Security::StartTls) => 143,
(Some(Protocol::Pop { .. }), Security::Tls) => 995,
(Some(Protocol::Pop { .. }), Security::StartTls) => 110,
(None, Security::Tls) => 465,
(None, Security::StartTls) => 587,
}
}
-236
View File
@@ -1,236 +0,0 @@
//! The window: accounts and the dialogs that change them.
use std::{
future::Future,
path::Path,
sync::{Arc, Mutex},
};
use ely_gpui_component::primitives::Severity;
use ely_mail_engine::{Account, AccountId, Filter, Store, TlsConnector, Vault};
use ely_platform::AppId;
use ely_ui::Root;
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, SharedString, WeakEntity, Window};
use tokio::runtime::Runtime;
use crate::{
adding::Adding,
boxes::{Listing, Open, Unread},
removing::Removing,
side::Side,
syncing::Syncs,
};
/// What the engine runs on and keeps to.
pub struct Parts {
pub store: Result<Store, String>,
pub vault: Vault,
pub tls: Result<TlsConnector, String>,
}
impl Parts {
pub fn system(dir: &Path) -> Self {
Self {
store: Store::open(dir).map_err(|err| err.to_string()),
vault: Vault::system(),
tls: ely_mail_engine::system_tls().map_err(|err| err.to_string()),
}
}
}
pub struct Mail {
pub store: Result<Arc<Mutex<Store>>, String>,
pub read_only: Option<String>,
pub vault: Arc<Vault>,
pub tls: Result<TlsConnector, String>,
runtime: Arc<Runtime>,
/// None until read.
pub accounts: Option<Result<Vec<(AccountId, Account)>, String>>,
/// Remove acts on its account.
pub open: Open,
pub filter: Filter,
pub listing: Listing,
pub unread: Unread,
pub adding: Option<Adding>,
pub removing: Option<Removing>,
pub syncs: Syncs,
/// Numbers every sync, even across forgotten accounts.
pub runs: u64,
/// Until resumed, no account syncs.
pub paused: bool,
/// Numbers every request across dialogs.
pub asked: u64,
pub focus: FocusHandle,
pub root: WeakEntity<Root>,
pub side: Entity<Side>,
}
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
ely_ui::i18n::text(cx, key, args)
}
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let dir = match ely_platform::data_dir(app) {
Ok(dir) => dir,
Err(error) => ely_platform::startup::fail(app, &format!("{error:#}")),
};
let parts = Parts::system(&dir);
let mail = cx.new(|cx| Mail::new(parts, cx));
let root = ely_ui::tool_root(app, mail.clone().into(), crate::menu::menus(cx), window, cx);
mail.update(cx, |mail, cx| {
mail.root = root.downgrade();
window.focus(&mail.focus, cx);
});
root
}
impl Mail {
pub fn new(parts: Parts, cx: &mut Context<Self>) -> Self {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("mail")
.enable_all()
.build()
.unwrap_or_else(|error| {
ely_platform::startup::fail(AppId::Mail, &format!("no mail runtime: {error}"))
});
let read_only = match &parts.store {
Ok(store) => store.read_only().map(str::to_owned),
Err(error) => {
log::error!("mail: the store did not open: {error}");
None
}
};
if let Err(error) = &parts.tls {
log::error!("mail: no system TLS: {error}");
}
let this = cx.weak_entity();
let mut mail = Mail {
store: parts.store.map(|store| Arc::new(Mutex::new(store))),
read_only,
vault: Arc::new(parts.vault),
tls: parts.tls,
runtime: Arc::new(runtime),
accounts: None,
open: Open::Inboxes,
filter: Filter::default(),
listing: Listing::default(),
unread: Unread::new(),
adding: None,
removing: None,
syncs: Syncs::new(),
runs: 0,
paused: false,
asked: 0,
focus: cx.focus_handle(),
root: WeakEntity::new_invalid(),
side: cx.new(|_| Side::new(this)),
};
mail.read_accounts(cx);
Self::sync_on_a_timer(cx);
mail
}
pub fn read_accounts(&mut self, cx: &mut Context<Self>) {
self.with_store(
cx,
|store| store.accounts(),
|mail, read, cx| {
let first = mail.accounts.is_none();
if let Ok(accounts) = &read {
let still = |id| accounts.iter().any(|(each, _)| *each == id);
if mail.open.account().is_some_and(|id| !still(id)) {
mail.open = Open::Inboxes;
}
}
mail.accounts = Some(read);
if first {
mail.sync_all(cx);
}
mail.read_mail(cx);
},
);
}
pub fn accounts(&self) -> &[(AccountId, Account)] {
match &self.accounts {
Some(Ok(accounts)) => accounts,
_ => &[],
}
}
/// Redraws the window and its account list.
pub fn notify(&self, cx: &mut Context<Self>) {
self.side.update(cx, |_, cx| cx.notify());
cx.notify();
}
/// Store work off the window's thread.
pub fn with_store<R: Send + 'static>(
&self,
cx: &mut Context<Self>,
work: impl FnOnce(&mut Store) -> ely_mail_engine::Result<R> + Send + 'static,
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + 'static,
) {
let store = match &self.store {
Ok(store) => store.clone(),
Err(error) => {
self.failed("mail.error.store", error, cx);
return;
}
};
let read = cx.background_spawn(async move {
let mut store = store
.lock()
.map_err(|_| "a store task panicked".to_owned())?;
work(&mut store).map_err(|err| err.to_string())
});
cx.spawn(async move |this, cx| {
let read = read.await;
this.update(cx, |mail, cx| {
then(mail, read, cx);
mail.notify(cx);
})
.ok();
})
.detach();
}
/// Network work on the mail runtime; abort stops it.
pub fn on_runtime<R: Send + 'static>(
&self,
cx: &mut Context<Self>,
work: impl Future<Output = R> + Send + 'static,
then: impl FnOnce(&mut Mail, Result<R, String>, &mut Context<Mail>) + 'static,
) -> tokio::task::AbortHandle {
let task = self.runtime.spawn(work);
let abort = task.abort_handle();
cx.spawn(async move |this, cx| {
let done = task.await;
if done.as_ref().is_err_and(|err| err.is_cancelled()) {
return;
}
let done = done.map_err(|err| format!("a mail task failed: {err}"));
this.update(cx, |mail, cx| {
then(mail, done, cx);
mail.notify(cx);
})
.ok();
})
.detach();
abort
}
/// Not logged: the words may name an address.
pub fn toast(&self, text: SharedString, severity: Severity, cx: &mut App) {
if let Some(root) = self.root.upgrade() {
root.update(cx, |root, cx| root.toast(text, severity, cx));
}
}
pub fn failed(&self, key: &str, error: &str, cx: &mut App) {
log::warn!("mail: {key}");
let words = text(cx, key, &[("error", error)]);
self.toast(words, Severity::Danger, cx);
}
}
+1 -21
View File
@@ -1,25 +1,5 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod add_view;
mod adding;
mod boxes;
mod controls;
mod flow;
mod kinds;
mod mail;
mod menu;
mod panes;
mod removing;
mod side;
mod syncing;
mod view;
#[cfg(test)]
mod tests;
use ely_platform::AppId;
fn main() {
ely_ui::run(AppId::Mail, mail::open);
ely_ui::run(AppId::Mail, ely_ui::blank);
}
-20
View File
@@ -1,20 +0,0 @@
//! The Account and Sync menus.
use gpui::{App, Menu, MenuItem, actions};
use crate::mail::text;
actions!(mail, [AddAccount, RemoveAccount, SyncAll]);
pub const CONTEXT: &str = "mail";
pub fn menus(cx: &App) -> Vec<Menu> {
vec![
Menu::new(text(cx, "mail.menu.account", &[])).items([
MenuItem::action(text(cx, "mail.add.open", &[]), AddAccount),
MenuItem::action(text(cx, "mail.remove.open", &[]), RemoveAccount),
]),
Menu::new(text(cx, "mail.menu.sync", &[]))
.items([MenuItem::action(text(cx, "mail.sync.now", &[]), SyncAll)]),
]
}
-121
View File
@@ -1,121 +0,0 @@
//! The message list and the reading pane (C12.9).
use ely_gpui_component::{
buttons::{ToggleGroup, ToggleItem},
data_display::DescriptionList,
feedback::{EmptyState, ErrorView},
layout::SplitPane,
mail::MailList,
primitives::IconName,
};
use ely_mail_engine::Filter;
use gpui::{
AnyElement, Axis, Context, IntoElement, ParentElement as _, SharedString, Styled as _, div, px,
};
use crate::{
mail::{Mail, text},
view::local_time,
};
type Filtered = fn(Filter) -> bool;
const FILTERS: [(&str, &str, IconName, Filtered); 3] = [
("unread", "mail.filter.unread", IconName::Mail, |f| f.unread),
("flagged", "mail.filter.flagged", IconName::Star, |f| {
f.flagged
}),
(
"attachments",
"mail.filter.attachments",
IconName::Paperclip,
|f| f.attachments,
),
];
impl Mail {
pub fn panes(&self, cx: &mut Context<Self>) -> AnyElement {
SplitPane::new("mail-panes", Axis::Horizontal, px(240.))
.pane(self.list_pane(cx))
.pane(self.reader(cx))
.into_any_element()
}
fn list_pane(&self, cx: &mut Context<Self>) -> AnyElement {
let on: Vec<&str> = FILTERS
.iter()
.filter(|(_, _, _, on)| on(self.filter))
.map(|(key, _, _, _)| *key)
.collect();
let toggles = FILTERS.iter().fold(
ToggleGroup::new("mail-filters").multiple().selected(on),
|group, (key, label, icon, _)| {
let words = text(cx, label, &[]);
group.item(ToggleItem::new(*key).icon(*icon).label(words))
},
);
let this = cx.weak_entity();
let toggles = toggles.on_change(move |keys, _, cx| {
let has = |key: &str| keys.iter().any(|each| each == key);
let filter = Filter {
unread: has("unread"),
flagged: has("flagged"),
attachments: has("attachments"),
};
this.update(cx, |mail, cx| mail.set_filter(filter, cx)).ok();
});
let list = match &self.listing.mails {
None => div().into_any_element(),
Some(Err(detail)) => ErrorView::new(
"mail-list-error",
IconName::TriangleAlert,
text(cx, "mail.list.unread", &[]),
)
.detail(detail.clone())
.into_any_element(),
Some(Ok(mails)) => {
let this = cx.weak_entity();
MailList::new("mail-list", mails.clone())
.selected(self.listing.selected.clone())
.on_select(move |keys, _, cx| {
this.update(cx, |mail, cx| mail.select(keys, cx)).ok();
})
.into_any_element()
}
};
div()
.size_full()
.flex()
.flex_col()
.child(div().p_2().child(toggles))
.child(div().flex_1().min_h_0().child(list))
.into_any_element()
}
fn reader(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(row) = &self.listing.chosen else {
return EmptyState::new(
"mail-reader-empty",
IconName::Mail,
text(cx, "mail.reader.none", &[]),
)
.into_any_element();
};
let label = |key: &str| text(cx, key, &[]);
let date: SharedString = match row.at {
Some(at) => local_time(at.as_second()).into(),
None => label("mail.reader.no_date"),
};
let details = DescriptionList::new()
.text(label("mail.reader.from"), row.from.clone())
.text(label("mail.reader.date"), date);
div()
.p_6()
.flex()
.flex_col()
.gap_4()
.child(div().text_xl().child(row.subject.clone()))
.child(details)
.into_any_element()
}
}
-97
View File
@@ -1,97 +0,0 @@
//! Removing an account: its local data and secret only.
use ely_gpui_component::primitives::Severity;
use ely_mail_engine::{AccountId, Cached, Error, Kept};
use gpui::Context;
use crate::mail::{Mail, text};
#[derive(Clone)]
pub struct Removing {
pub id: AccountId,
pub address: String,
pub cached: Cached,
}
impl Mail {
/// Measures what goes, then asks.
pub fn ask_remove(&mut self, cx: &mut Context<Self>) {
let Some(id) = self.open.account() else {
let words = text(cx, "mail.remove.none", &[]);
return self.toast(words, Severity::Info, cx);
};
self.with_store(
cx,
move |store| store.cached(id),
move |mail, cached, cx| {
let address = mail
.accounts()
.iter()
.find(|(each, _)| *each == id)
.map(|(_, account)| account.address.clone());
match (cached, address) {
(Ok(cached), Some(address))
if mail.adding.is_none() && mail.removing.is_none() =>
{
mail.removing = Some(Removing {
id,
address,
cached,
});
}
(Ok(_), _) => {
log::info!("mail: removal asked of a gone account or over a dialog")
}
(Err(error), _) => mail.failed("mail.remove.failed", &error, cx),
}
},
);
}
/// The secret goes first, and back if the rest fails.
pub fn remove(&mut self, removing: Removing, cx: &mut Context<Self>) {
let Some((_, account)) = self
.accounts()
.iter()
.find(|(each, _)| *each == removing.id)
else {
return log::info!("mail: the account to remove is already gone");
};
let (id, name, vault) = (removing.id, account.secret_name(), self.vault.clone());
self.stop_sync(id);
let address = removing.address;
self.with_store(
cx,
move |store| {
let secret = vault.get(&name)?;
vault.remove(&name)?;
store.remove_account(id).map_err(|error| {
match secret.map(|secret| vault.put(&name, &secret)) {
Some(Kept::Session(reason)) => Error::Vault(format!(
"{error}; its secret is back for this session only: {reason}"
)),
_ => error,
}
})
},
move |mail, removed, cx| {
match removed {
Ok(()) => {
log::info!("mail: account {} removed", id.0);
mail.toast(
text(cx, "mail.remove.done", &[("address", &address)]),
Severity::Success,
cx,
);
}
Err(error) => {
mail.failed("mail.remove.failed", &error, cx);
mail.sync(id, cx);
}
}
mail.read_accounts(cx);
},
);
cx.notify();
}
}
-126
View File
@@ -1,126 +0,0 @@
//! Every inbox, then each account's folders, down the left.
use ely_gpui_component::{
layout::ScrollArea,
mail::{Mailbox, MailboxList},
primitives::IconName,
};
use ely_mail_engine::{LocalFolder, Role};
use gpui::{
App, Context, IntoElement, ParentElement as _, Render, SharedString, Styled as _, WeakEntity,
Window, div,
};
use crate::{
boxes::Open,
mail::{Mail, text},
};
pub struct Side {
mail: WeakEntity<Mail>,
}
impl Side {
pub fn new(mail: WeakEntity<Mail>) -> Self {
Self { mail }
}
}
impl Render for Side {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(mail) = self.mail.upgrade() else {
return div().into_any_element();
};
let mail = mail.read(cx);
let inboxes = Mailbox::new(
Open::Inboxes.key(),
text(cx, "mail.boxes.all", &[]),
IconName::Inbox,
)
.unread(mail.inboxes_unread());
let mut list =
MailboxList::new("mail-boxes").section(text(cx, "mail.boxes.unified", &[]), [inboxes]);
for (id, account) in mail.accounts() {
let folders = mail.syncs.get(id).map_or(&[][..], |each| &each.folders);
let mut sorted: Vec<&LocalFolder> = folders.iter().collect();
sorted.sort_by(|a, b| rank(a.role).cmp(&rank(b.role)).then(a.name.cmp(&b.name)));
let boxes = sorted.into_iter().map(|folder| {
let open = Open::Mailbox(*id, folder.id);
// Boxes with nothing unread are absent.
let unread = mail.unread.get(&folder.id).copied().unwrap_or(0);
Mailbox::new(open.key(), name(folder, cx), icon(folder.role)).unread(unread)
});
let settings = Mailbox::new(
Open::Account(*id).key(),
text(cx, "mail.boxes.settings", &[]),
IconName::Settings,
);
list = list.section(account.address.clone(), boxes.chain([settings]));
}
let this = self.mail.clone();
let list = list.open(mail.open.key()).on_open(move |key, _, cx| {
this.update(cx, |mail, cx| match mail.opened(key) {
Some(open) => mail.open_box(open, cx),
None => log::error!("mail: the mailbox list sent an unknown box"),
})
.ok();
});
ScrollArea::new("mail-side-scroll")
.vertical()
.size_full()
.child(div().p_2().child(list))
.into_any_element()
}
}
impl Mail {
/// The box a side key names, if still here.
fn opened(&self, key: &SharedString) -> Option<Open> {
if *key == Open::Inboxes.key() {
return Some(Open::Inboxes);
}
self.accounts().iter().find_map(|(id, _)| {
let folders = self.syncs.get(id).map_or(&[][..], |each| &each.folders);
let boxes = folders.iter().map(|folder| Open::Mailbox(*id, folder.id));
boxes
.chain([Open::Account(*id)])
.find(|open| open.key() == *key)
})
}
}
/// Inbox in the reader's words; others as named.
fn name(folder: &LocalFolder, cx: &App) -> SharedString {
match folder.role {
Some(Role::Inbox) => text(cx, "mail.boxes.inbox", &[]),
_ => folder.name.clone().into(),
}
}
fn rank(role: Option<Role>) -> u8 {
match role {
Some(Role::Inbox) => 0,
Some(Role::Flagged) => 1,
Some(Role::Drafts) => 2,
Some(Role::Sent) => 3,
Some(Role::Archive) => 4,
Some(Role::All) => 5,
Some(Role::Junk) => 6,
Some(Role::Trash) => 7,
None => 8,
}
}
fn icon(role: Option<Role>) -> IconName {
match role {
Some(Role::Inbox) => IconName::Inbox,
Some(Role::Flagged) => IconName::Star,
Some(Role::Drafts) => IconName::File,
Some(Role::Sent) => IconName::Send,
Some(Role::Archive) => IconName::Archive,
Some(Role::All) => IconName::Mail,
Some(Role::Junk) => IconName::TriangleAlert,
Some(Role::Trash) => IconName::Trash2,
None => IconName::Folder,
}
}
-187
View File
@@ -1,187 +0,0 @@
//! Accounts sync on open, on demand and on a timer.
use std::{
collections::HashMap,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Protocol, Synced, sync};
use gpui::Context;
use tokio::task::AbortHandle;
use crate::mail::Mail;
/// How often every account syncs while the window is open.
pub const EVERY: Duration = Duration::from_secs(10 * 60);
/// One account's sync as the window shows it.
#[derive(Default)]
pub struct Syncing {
pub task: Option<AbortHandle>,
/// Which sync `task` is; older completions are stale.
pub run: u64,
/// A folder changed mid-sync: sync once more.
pub again: bool,
pub synced: Option<Synced>,
pub failed: Option<String>,
pub folders: Vec<LocalFolder>,
}
pub type Syncs = HashMap<AccountId, Syncing>;
impl Mail {
pub fn sync(&mut self, id: AccountId, cx: &mut Context<Self>) {
if self.paused {
return log::info!("mail: account {} not synced: paused", id.0);
}
let Some((_, account)) = self.accounts().iter().find(|(each, _)| *each == id) else {
return log::info!("mail: sync asked of a gone account");
};
if self.syncs.get(&id).is_some_and(|each| each.task.is_some()) {
return log::info!("mail: account {} is already syncing", id.0);
}
let Ok(store) = self.store.clone() else {
return log::error!("mail: no store to sync with");
};
let tls = match self.tls.clone() {
Ok(tls) => tls,
Err(error) => {
log::warn!("mail: account {} cannot sync without TLS", id.0);
self.syncs.entry(id).or_default().failed = Some(error);
return self.read_synced(id, cx);
}
};
let (account, vault) = (account.clone(), self.vault.clone());
self.runs += 1;
let run = self.runs;
self.syncs.entry(id).or_default().run = run;
let work = async move {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| ely_mail_engine::Error::Store(format!("the clock: {err}")))?
.as_secs() as i64;
let secret = sync::secret(&account, &vault).await?;
match account.protocol {
Protocol::Imap => sync::imap(&account, id, secret, &tls, &store, now).await,
Protocol::Pop { .. } => sync::pop(&account, id, secret, &tls, &store, now).await,
}
};
let task = self.on_runtime(cx, work, move |mail, done, cx| {
let failed = match done {
Ok(Ok(_)) => None,
Ok(Err(error)) => Some(error.to_string()),
Err(error) => Some(error),
};
if failed.is_some() {
log::warn!("mail: account {} did not sync", id.0);
}
let Some(syncing) = mail.syncs.get_mut(&id) else {
return log::info!("mail: a sync ended for a removed account");
};
if syncing.run != run {
return log::info!("mail: account {} sync {run} ended after a newer one", id.0);
}
syncing.task = None;
syncing.failed = failed;
let again = std::mem::take(&mut syncing.again);
mail.read_synced(id, cx);
if again {
log::info!("mail: account {} syncs again for a changed folder", id.0);
mail.sync(id, cx);
}
});
self.syncs.entry(id).or_default().task = Some(task);
self.read_synced(id, cx);
}
pub fn sync_all(&mut self, cx: &mut Context<Self>) {
if self.paused {
return log::info!("mail: sync is paused");
}
let ids: Vec<_> = self.accounts().iter().map(|(id, _)| *id).collect();
for id in ids {
self.sync(id, cx);
}
}
/// Pausing stops running syncs; resuming syncs every account.
pub fn set_paused(&mut self, paused: bool, cx: &mut Context<Self>) {
log::info!("mail: sync paused: {paused}");
self.paused = paused;
if paused {
for syncing in self.syncs.values_mut() {
syncing.again = false;
if let Some(task) = syncing.task.take() {
task.abort();
}
}
} else {
self.sync_all(cx);
}
self.notify(cx);
}
/// Syncs every account each `EVERY` until the window goes.
pub fn sync_on_a_timer(cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
loop {
cx.background_executor().timer(EVERY).await;
if this.update(cx, |mail, cx| mail.sync_all(cx)).is_err() {
break;
}
}
})
.detach();
}
/// Takes the folder's mail whole, then syncs to fetch it.
pub fn set_offline(
&mut self,
id: AccountId,
mailbox: MailboxId,
offline: bool,
cx: &mut Context<Self>,
) {
self.with_store(
cx,
move |store| store.set_offline(mailbox, offline),
move |mail, done, cx| match done {
Ok(()) => {
mail.read_synced(id, cx);
match mail.syncs.get_mut(&id) {
Some(syncing) if offline && syncing.task.is_some() => {
log::info!("mail: account {} syncs again after this one", id.0);
syncing.again = true;
}
_ if offline => mail.sync(id, cx),
_ => {}
}
}
Err(error) => mail.failed("mail.sync.offline_failed", &error, cx),
},
);
}
/// Stops the account's sync and forgets it.
pub fn stop_sync(&mut self, id: AccountId) {
if let Some(task) = self.syncs.remove(&id).and_then(|each| each.task) {
task.abort();
}
}
fn read_synced(&mut self, id: AccountId, cx: &mut Context<Self>) {
self.with_store(
cx,
move |store| Ok((store.synced(id)?, store.folders(id)?)),
move |mail, read, cx| match (read, mail.syncs.get_mut(&id)) {
(Ok((synced, folders)), Some(syncing)) => {
syncing.synced = Some(synced);
syncing.folders = folders;
mail.read_mail(cx);
}
(Err(error), Some(_)) => mail.failed("mail.sync.unread", &error, cx),
(_, None) => {}
},
);
}
}
-192
View File
@@ -1,192 +0,0 @@
//! Accounts in the window: add, check, keep, remove (C12.5).
use std::{
collections::HashMap,
path::Path,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, Instant},
};
use ely_mail_engine::{
Account, Auth, Backend, Checked, Protocol, Security, Server, Store, TlsConnector, Vault,
};
use gpui::{App, Entity, TestAppContext, VisualTestContext};
use crate::{
adding::Step,
kinds::Kind,
mail::{Mail, Parts},
};
mod controls;
mod flows;
mod panes;
mod syncs;
/// A credential store that keeps or refuses.
#[derive(Clone, Default)]
pub(crate) struct Keys {
pub kept: Arc<Mutex<HashMap<String, String>>>,
pub refuse: Option<&'static str>,
pub deletes: Arc<AtomicUsize>,
}
impl Backend for Keys {
fn get(&self, name: &str) -> Result<Option<String>, String> {
Ok(self.kept.lock().unwrap().get(name).cloned())
}
fn set(&self, name: &str, secret: &str) -> Result<(), String> {
if let Some(reason) = self.refuse {
return Err(reason.into());
}
self.kept.lock().unwrap().insert(name.into(), secret.into());
Ok(())
}
fn delete(&self, name: &str) -> Result<(), String> {
self.deletes.fetch_add(1, Ordering::SeqCst);
if let Some(reason) = self.refuse {
return Err(reason.into());
}
self.kept.lock().unwrap().remove(name);
Ok(())
}
}
pub(crate) fn settle(cx: &mut VisualTestContext, done: impl Fn(&App) -> bool) {
let deadline = Instant::now() + Duration::from_secs(20);
while Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
cx.run_until_parked();
cx.update(|window, _| window.refresh());
if cx.update(|_, app| done(app)) {
return;
}
}
panic!("the state never settled");
}
pub(crate) fn window<'a>(
cx: &'a mut TestAppContext,
dir: &Path,
keys: Keys,
) -> (Entity<Mail>, &'a mut VisualTestContext) {
let tls = ely_mail_engine::system_tls().map_err(|err| err.to_string());
window_with(cx, dir, keys, tls)
}
pub(crate) fn window_with<'a>(
cx: &'a mut TestAppContext,
dir: &Path,
keys: Keys,
tls: Result<TlsConnector, String>,
) -> (Entity<Mail>, &'a mut VisualTestContext) {
cx.executor().allow_parking();
cx.update(ely_gpui_component::init_for_tests);
cx.update(|cx| {
ely_ui::settings::install_defaults(cx);
ely_ui::i18n::install_as("en-US", cx);
// As tool_root does: menus before the window.
let menus = crate::menu::menus(cx);
let menus = ely_ui::commands::menus(
ely_platform::AppId::Mail,
&ely_ui::commands::OPENING,
false,
menus,
cx,
);
cx.set_menus(menus);
});
let parts = Parts {
store: Store::open(dir).map_err(|err| err.to_string()),
vault: Vault::new(Box::new(keys)),
tls,
};
let (mail, cx) = cx.add_window_view(move |_, cx| Mail::new(parts, cx));
settle(cx, |app| mail.read(app).accounts.is_some());
(mail, cx)
}
/// A port nothing listens on.
pub(crate) fn closed_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
}
/// On this machine's discard port, so syncs end at once.
pub(crate) fn account(address: &str) -> Account {
let server = |port| Server {
host: "127.0.0.1".into(),
port,
security: Security::Tls,
};
Account {
address: address.into(),
user: address.into(),
auth: Auth::Password,
protocol: Protocol::Imap,
incoming: server(9),
outgoing: server(9),
}
}
/// Opens the dialog on `kind`.
pub(crate) fn adding(mail: &Entity<Mail>, kind: Kind, cx: &mut VisualTestContext) {
cx.update(|window, app| {
mail.update(app, |mail, cx| {
mail.start_adding(window, cx);
mail.adding.as_mut().unwrap().set_kind(kind, cx);
})
});
}
pub(crate) fn type_in(
mail: &Entity<Mail>,
cx: &mut VisualTestContext,
pick: fn(&crate::adding::Fields) -> &Entity<ely_gpui_component::forms::TextInput>,
words: &str,
) {
cx.update(|_, app| {
let field = pick(&mail.read(app).adding.as_ref().unwrap().fields).clone();
field.update(app, |field, cx| field.set_text(words.to_owned(), cx));
});
cx.run_until_parked();
}
/// A passed check, as servers would answer.
pub(crate) fn passed(
mail: &Entity<Mail>,
account: Account,
kept: &str,
cx: &mut VisualTestContext,
) {
cx.update(|_, app| {
mail.update(app, |mail, _| {
mail.adding.as_mut().unwrap().step = Step::Checked {
account,
kept: kept.into(),
checked: Checked {
incoming: Ok(()),
outgoing: Ok(()),
},
};
})
});
}
pub(crate) fn step_words(mail: &Entity<Mail>, cx: &mut VisualTestContext) -> String {
cx.update(
|_, app| match &mail.read(app).adding.as_ref().unwrap().step {
Step::Failed(words) => words.to_string(),
Step::Editing => "editing".into(),
Step::Signing => "signing".into(),
Step::Checking => "checking".into(),
Step::Saving => "saving".into(),
Step::Checked { .. } => "checked".into(),
},
)
}
-338
View File
@@ -1,338 +0,0 @@
use std::{
net::TcpListener,
sync::mpsc,
time::{Duration, Instant},
};
use ely_mail_engine::{AccountId, LocalFolder, MailboxId, Role, Store};
use gpui::{
Entity, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px, size,
};
use super::{Keys, account, settle, window};
use crate::{boxes::Open, mail::Mail, syncing::EVERY, view::imap_range};
/// An account on a server that never answers.
fn silent(dir: &std::path::Path, port: u16) -> AccountId {
let mut ann = account("ann@example.com");
ann.incoming.port = port;
let mut store = Store::open(dir).unwrap();
let id = store.add_account(&ann).unwrap();
store
.set_folders(id, &[("INBOX", Some(Role::Inbox))])
.unwrap();
id
}
fn with_secret() -> Keys {
let keys = Keys::default();
let secret = ("account:ann@example.com".to_owned(), "pw".to_owned());
keys.kept.lock().unwrap().extend([secret]);
keys
}
/// Waits on the network task alone, not the window.
fn finished(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
let deadline = Instant::now() + Duration::from_secs(20);
let done = |cx: &mut VisualTestContext| {
cx.update(|_, app| {
let task = mail.read(app).syncs[&id].task.as_ref();
task.unwrap().is_finished()
})
};
while !done(cx) {
assert!(Instant::now() < deadline, "the sync never ended");
std::thread::sleep(Duration::from_millis(10));
}
}
/// An account whose sync fails at once: no secret saved.
fn failing(dir: &std::path::Path) -> AccountId {
let mut store = Store::open(dir).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
store
.set_folders(ann, &[("INBOX", Some(Role::Inbox))])
.unwrap();
ann
}
fn failed(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) -> bool {
cx.update(|_, app| mail.read(app).syncs[&id].failed.is_some())
}
fn forget(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
cx.update(|_, app| {
mail.update(app, |mail, _| {
mail.syncs.get_mut(&id).unwrap().failed = None
})
});
}
fn settled_failure(mail: &Entity<Mail>, id: AccountId, cx: &mut VisualTestContext) {
settle(cx, |app| {
mail.read(app)
.syncs
.get(&id)
.is_some_and(|each| each.failed.is_some() && each.task.is_none())
});
}
#[test]
fn every_account_syncs_again_on_the_timer() {
let dir = tempfile::tempdir().unwrap();
let ann = failing(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
settled_failure(&mail, ann, cx);
forget(&mail, ann, cx);
cx.executor().advance_clock(EVERY);
settled_failure(&mail, ann, cx);
}
#[test]
fn a_paused_sync_waits_until_resumed() {
let dir = tempfile::tempdir().unwrap();
let ann = failing(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
settled_failure(&mail, ann, cx);
forget(&mail, ann, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx)));
cx.executor().advance_clock(EVERY);
cx.run_until_parked();
assert!(!failed(&mail, ann, cx), "a paused account synced");
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(false, cx)));
settled_failure(&mail, ann, cx);
}
#[test]
fn a_folder_kept_offline_is_saved_and_synced() {
let dir = tempfile::tempdir().unwrap();
let ann = failing(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
settle(cx, |app| {
mail.read(app)
.syncs
.get(&ann)
.is_some_and(|each| each.failed.is_some() && !each.folders.is_empty())
});
let inbox = cx.update(|_, app| mail.read(app).syncs[&ann].folders[0].clone());
assert!(!inbox.offline);
forget(&mail, ann, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox.id, true, cx)));
settle(cx, |app| {
let syncing = &mail.read(app).syncs[&ann];
syncing.folders[0].offline && syncing.failed.is_some() && syncing.task.is_none()
});
assert!(Store::open(dir.path()).unwrap().offline(inbox.id).unwrap());
}
#[test]
fn an_old_end_queued_across_a_resume_keeps_the_new_sync() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let dir = tempfile::tempdir().unwrap();
let ann = silent(dir.path(), listener.local_addr().unwrap().port());
let keys = Keys::default();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
settled_failure(&mail, ann, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.sync(ann, cx)));
finished(&mail, ann, cx);
keys.kept
.lock()
.unwrap()
.insert("account:ann@example.com".into(), "pw".into());
let resumed = cx.update(|_, app| {
mail.update(app, |mail, cx| {
mail.set_paused(true, cx);
mail.set_paused(false, cx);
mail.syncs[&ann].task.clone().unwrap()
})
});
cx.run_until_parked();
assert!(
cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()),
"an old end cleared the new sync"
);
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
let deadline = Instant::now() + Duration::from_secs(5);
while !resumed.is_finished() {
assert!(Instant::now() < deadline, "pausing left the sync running");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn an_old_end_after_a_failed_removal_keeps_the_new_sync() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let dir = tempfile::tempdir().unwrap();
let ann = silent(dir.path(), listener.local_addr().unwrap().port());
let keys = Keys::default();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
settled_failure(&mail, ann, cx);
cx.update(|_, app| {
mail.update(app, |mail, cx| {
mail.stop_sync(ann);
mail.sync(ann, cx);
})
});
finished(&mail, ann, cx);
keys.kept
.lock()
.unwrap()
.insert("account:ann@example.com".into(), "pw".into());
let restarted = cx.update(|_, app| {
mail.update(app, |mail, cx| {
mail.stop_sync(ann);
mail.sync(ann, cx);
mail.syncs[&ann].task.clone().unwrap()
})
});
cx.run_until_parked();
assert!(
cx.update(|_, app| mail.read(app).syncs[&ann].task.is_some()),
"an old end cleared the restarted sync"
);
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_paused(true, cx)));
let deadline = Instant::now() + Duration::from_secs(5);
while !restarted.is_finished() {
assert!(Instant::now() < deadline, "pausing left the sync running");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn a_folder_kept_offline_mid_sync_syncs_once_more() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let (hang_up, hung) = mpsc::channel::<()>();
let server = std::thread::spawn(move || {
let held = listener.accept().unwrap();
hung.recv().unwrap();
drop((held, listener));
});
let dir = tempfile::tempdir().unwrap();
let ann = silent(dir.path(), port);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), with_secret());
settle(cx, |app| {
mail.read(app)
.syncs
.get(&ann)
.is_some_and(|each| each.task.is_some() && !each.folders.is_empty())
});
let (inbox, run) = cx.update(|_, app| {
let syncing = &mail.read(app).syncs[&ann];
(syncing.folders[0].id, syncing.run)
});
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_offline(ann, inbox, true, cx)));
settle(cx, |app| mail.read(app).syncs[&ann].again);
hang_up.send(()).unwrap();
server.join().unwrap();
settle(cx, |app| {
let syncing = &mail.read(app).syncs[&ann];
syncing.run == run + 1 && syncing.task.is_none() && syncing.failed.is_some()
});
}
#[test]
fn the_account_page_scrolls_to_its_last_folder() {
let dir = tempfile::tempdir().unwrap();
let ann = failing(dir.path());
let names: Vec<String> = (0..40).map(|n| format!("Folder {n:02}")).collect();
let folders: Vec<_> = names.iter().map(|name| (name.as_str(), None)).collect();
let last = *Store::open(dir.path())
.unwrap()
.set_folders(ann, &folders)
.unwrap()
.last()
.unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(ann), cx)));
cx.simulate_resize(size(px(1100.), px(800.)));
settle(cx, |app| {
mail.read(app)
.syncs
.get(&ann)
.is_some_and(|each| each.folders.len() == 40)
});
let selector: &'static str = format!("mail-offline-{}", last.0).leak();
let height = cx.update(|window, _| window.viewport_size().height);
let before = cx
.debug_bounds(selector)
.expect("the last switch is laid out");
assert!(before.bottom() > height, "the folders fit the window");
let at = point(px(400.), height / 2.0);
for _ in 0..20 {
cx.simulate_event(ScrollWheelEvent {
position: at,
delta: ScrollDelta::Pixels(point(px(0.), px(-1000.))),
..Default::default()
});
cx.run_until_parked();
}
let after = cx
.debug_bounds(selector)
.expect("the last switch is laid out");
assert!(
after.bottom() <= height,
"the last switch is out of reach: {after:?}"
);
}
#[test]
fn the_range_names_the_folders_kept_whole() {
let folder = |id, name: &str, offline| LocalFolder {
id: MailboxId(id),
name: name.into(),
role: None,
offline,
};
let cx = TestAppContext::single();
cx.update(|app| {
ely_ui::i18n::install_as("en-US", app);
let rows = |folders: &[LocalFolder]| -> Vec<String> {
imap_range(folders, app)
.into_iter()
.map(|(key, value)| format!("{key}: {value}"))
.collect()
};
let both = [folder(1, "INBOX", true), folder(2, "Sent", false), folder(3, "Work", true)];
assert_eq!(
rows(&both),
[
"Downloaded: Other folders: headers of all mail; text from the last 90 days; attachments when asked",
"Kept whole: Every message in INBOX, Work, with attachments",
]
);
assert_eq!(rows(&[folder(1, "INBOX", true)]), ["Kept whole: Every message in INBOX, with attachments"]);
assert_eq!(
rows(&[folder(2, "Sent", false)]),
["Downloaded: Headers of all mail; text from the last 90 days; attachments when asked"]
);
});
}
#[test]
fn remove_needs_an_open_account() {
let dir = tempfile::tempdir().unwrap();
Store::open(dir.path())
.unwrap()
.add_account(&account("ann@example.com"))
.unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
settle(cx, |app| mail.read(app).accounts().len() == 1);
cx.update(|_, app| assert_eq!(mail.read(app).open, Open::Inboxes));
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
cx.run_until_parked();
cx.update(|_, app| {
let mail = mail.read(app);
assert!(mail.removing.is_none());
assert_eq!(mail.accounts().len(), 1);
});
}
-488
View File
@@ -1,488 +0,0 @@
use std::sync::atomic::Ordering;
use ely_mail_engine::{Auth, Error, Fetched, Key, Protocol, Security, Store, oauth};
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::{Keys, account, adding, closed_port, passed, settle, step_words, type_in, window};
use crate::{adding::Step, boxes::Open, kinds::Kind, mail::Mail};
#[test]
fn an_imap_form_says_what_is_missing() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Imap, cx);
let check = |cx: &mut gpui::VisualTestContext| {
cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx)))
};
check(cx);
assert_eq!(step_words(&mail, cx), "Type the email address.");
type_in(&mail, cx, |f| &f.address, "ann@example.com");
check(cx);
assert_eq!(step_words(&mail, cx), "Type the user name.");
type_in(&mail, cx, |f| &f.user, "ann");
check(cx);
assert_eq!(step_words(&mail, cx), "Type both server names.");
type_in(&mail, cx, |f| &f.incoming, "imap.example.com");
type_in(&mail, cx, |f| &f.outgoing, "smtp.example.com");
type_in(&mail, cx, |f| &f.outgoing_port, "70000");
check(cx);
assert_eq!(
step_words(&mail, cx),
"70000 is not a port; use 1 to 65535."
);
type_in(&mail, cx, |f| &f.outgoing_port, "587");
check(cx);
assert_eq!(step_words(&mail, cx), "Type the password.");
}
#[test]
fn ports_follow_security_but_a_typed_one_stays() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Pop, cx);
let ports = |cx: &mut gpui::VisualTestContext| {
cx.update(|_, app| {
let fields = &mail.read(app).adding.as_ref().unwrap().fields;
(
fields.incoming_port.read(app).text().to_owned(),
fields.outgoing_port.read(app).text().to_owned(),
)
})
};
assert_eq!(ports(cx), ("995".into(), "587".into()));
let secure = |cx: &mut gpui::VisualTestContext, outgoing, security| {
cx.update(|_, app| {
mail.update(app, |mail, cx| {
mail.adding
.as_mut()
.unwrap()
.set_security(outgoing, security, cx)
})
});
};
secure(cx, false, Security::StartTls);
assert_eq!(ports(cx), ("110".into(), "587".into()));
type_in(&mail, cx, |f| &f.outgoing_port, "2525");
secure(cx, true, Security::Tls);
assert_eq!(ports(cx), ("110".into(), "2525".into()));
cx.update(|_, app| mail.update(app, |mail, cx| mail.close_adding(cx)));
adding(&mail, Kind::Imap, cx);
assert_eq!(ports(cx), ("993".into(), "587".into()));
type_in(&mail, cx, |f| &f.incoming_port, "995");
secure(cx, true, Security::Tls);
assert_eq!(ports(cx), ("995".into(), "465".into()));
secure(cx, false, Security::StartTls);
assert_eq!(ports(cx), ("995".into(), "465".into()));
}
#[test]
fn a_check_reports_each_server() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Imap, cx);
type_in(&mail, cx, |f| &f.address, "ann@example.com");
type_in(&mail, cx, |f| &f.user, "ann");
type_in(&mail, cx, |f| &f.password, "pw");
type_in(&mail, cx, |f| &f.incoming, "127.0.0.1");
type_in(&mail, cx, |f| &f.incoming_port, &closed_port().to_string());
type_in(&mail, cx, |f| &f.outgoing, "127.0.0.1");
type_in(&mail, cx, |f| &f.outgoing_port, &closed_port().to_string());
cx.update(|_, app| mail.update(app, |mail, cx| mail.check(cx)));
assert_eq!(step_words(&mail, cx), "checking");
settle(cx, |app| {
matches!(
mail.read(app).adding.as_ref().unwrap().step,
Step::Checked { .. }
)
});
cx.update(|_, app| {
let Step::Checked {
checked,
account,
kept,
} = &mail.read(app).adding.as_ref().unwrap().step
else {
unreachable!()
};
assert!(
matches!(checked.incoming, Err(Error::Connect(_))),
"{:?}",
checked.incoming
);
assert!(
matches!(checked.outgoing, Err(Error::Connect(_))),
"{:?}",
checked.outgoing
);
assert_eq!(
(account.user.as_str(), account.protocol, kept.as_str()),
("ann", Protocol::Imap, "pw")
);
let said = crate::add_view::failure(checked.incoming.as_ref().unwrap_err(), app);
assert!(said.starts_with("cannot connect ("), "{said}");
});
type_in(&mail, cx, |f| &f.password, "pw2");
assert_eq!(step_words(&mail, cx), "editing");
}
#[test]
fn an_icloud_account_takes_apples_servers() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::ICloud, cx);
type_in(&mail, cx, |f| &f.address, "ann@icloud.com");
let account = cx
.update(|_, app| mail.read(app).adding.as_ref().unwrap().account(None, app))
.unwrap();
assert_eq!(
(account.incoming.host.as_str(), account.incoming.port),
("imap.mail.me.com", 993)
);
assert_eq!(
(
account.outgoing.host.as_str(),
account.outgoing.port,
account.outgoing.security
),
("smtp.mail.me.com", 587, Security::StartTls)
);
assert_eq!(
(account.user.as_str(), account.auth),
("ann@icloud.com", Auth::Password)
);
}
#[test]
fn a_build_without_a_client_id_says_so() {
if oauth::google().is_ok() {
return eprintln!("SKIP: this build has a Google client ID");
}
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Gmail, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.sign_in(cx)));
assert_eq!(
step_words(&mail, cx),
"Gmail sign-in is not in this build: it was built without a Google client ID."
);
}
#[test]
fn a_refused_keychain_keeps_the_account_and_says_why() {
let dir = tempfile::tempdir().unwrap();
let keys = Keys {
refuse: Some("locked"),
..Keys::default()
};
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
adding(&mail, Kind::Imap, cx);
passed(&mail, account("ann@example.com"), "pw", cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
settle(cx, |app| mail.read(app).adding.is_none());
cx.update(|_, app| {
let mail = mail.read(app);
assert_eq!(mail.accounts().len(), 1);
assert_eq!(mail.open, Open::Account(mail.accounts()[0].0));
assert_eq!(
mail.vault
.get("account:ann@example.com")
.unwrap()
.as_deref(),
Some("pw")
);
});
assert!(keys.kept.lock().unwrap().is_empty());
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
settle(cx, |app| mail.read(app).removing.is_some());
press_remove(&mail, cx);
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
settle(cx, |_| store.try_lock().is_ok());
cx.run_until_parked();
cx.update(|_, app| {
let mail = mail.read(app);
assert_eq!(mail.accounts().len(), 1);
assert_eq!(
mail.vault
.get("account:ann@example.com")
.unwrap()
.as_deref(),
Some("pw")
);
});
}
#[test]
fn an_account_is_kept_and_removed_with_its_secret() {
let dir = tempfile::tempdir().unwrap();
let keys = Keys::default();
{
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
adding(&mail, Kind::Imap, cx);
passed(&mail, account("ann@example.com"), "pw", cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
settle(cx, |app| mail.read(app).adding.is_none());
adding(&mail, Kind::Imap, cx);
passed(&mail, account("ANN@example.com"), "pw", cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
}
assert_eq!(
keys.kept
.lock()
.unwrap()
.get("account:ann@example.com")
.map(String::as_str),
Some("pw")
);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
cx.update(|_, app| assert_eq!(mail.read(app).accounts()[0].1, account("ann@example.com")));
open_first(&mail, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
settle(cx, |app| mail.read(app).removing.is_some());
cx.update(|_, app| {
let removing = mail.read(app).removing.as_ref().unwrap();
assert_eq!(
(removing.address.as_str(), removing.cached.messages),
("ann@example.com", 0)
);
});
press_remove(&mail, cx);
settle(cx, |app| mail.read(app).accounts().is_empty());
assert!(keys.kept.lock().unwrap().is_empty());
}
#[test]
fn a_refused_secret_removal_keeps_the_account() {
let dir = tempfile::tempdir().unwrap();
Store::open(dir.path())
.unwrap()
.add_account(&account("ann@example.com"))
.unwrap();
let keys = Keys {
refuse: Some("locked"),
..Keys::default()
};
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
open_first(&mail, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
settle(cx, |app| mail.read(app).removing.is_some());
press_remove(&mail, cx);
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
settle(cx, |app| mail.read(app).accounts().len() == 1);
assert_eq!(
Store::open(dir.path()).unwrap().accounts().unwrap().len(),
1
);
}
#[test]
fn a_failed_removal_puts_the_secret_back() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path()).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
let inbox = store.mailbox(ann, "INBOX").unwrap();
let raw: &[u8] = b"Subject: Kept
still here
";
let key = Key::Imap {
uidvalidity: 1,
uid: 1,
};
let kept = store.put(inbox, &key, &[], Fetched::Whole(raw)).unwrap();
drop(store);
let keys = Keys::default();
keys.kept
.lock()
.unwrap()
.insert("account:ann@example.com".into(), "pw".into());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
// A folder where the original would be set aside.
let shard = std::fs::read_dir(dir.path().join("content"))
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let name = std::fs::read_dir(&shard)
.unwrap()
.next()
.unwrap()
.unwrap()
.file_name();
std::fs::create_dir(shard.join(format!(".{}.gone", name.to_str().unwrap()))).unwrap();
open_first(&mail, cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.ask_remove(cx)));
settle(cx, |app| mail.read(app).removing.is_some());
press_remove(&mail, cx);
settle(cx, |_| keys.deletes.load(Ordering::SeqCst) == 1);
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
settle(cx, |_| store.try_lock().is_ok());
cx.run_until_parked();
assert_eq!(
keys.kept
.lock()
.unwrap()
.get("account:ann@example.com")
.map(String::as_str),
Some("pw")
);
let store = store.lock().unwrap();
assert_eq!(store.accounts().unwrap().len(), 1);
assert_eq!(store.original(kept).unwrap().unwrap(), raw);
}
#[test]
fn two_saves_in_flight_add_an_address_once() {
let dir = tempfile::tempdir().unwrap();
let keys = Keys::default();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys.clone());
adding(&mail, Kind::Imap, cx);
passed(&mail, account("ann@example.com"), "pw", cx);
cx.update(|window, app| {
mail.update(app, |mail, cx| {
mail.save(cx);
mail.close_adding(cx);
mail.start_adding(window, cx);
})
});
passed(&mail, account("ANN@example.com"), "pw2", cx);
cx.update(|_, app| mail.update(app, |mail, cx| mail.save(cx)));
// Either save may reach the store first.
settle(cx, |app| {
let mail = mail.read(app);
mail.accounts().len() == 1
&& !matches!(
mail.adding.as_ref().map(|adding| &adding.step),
Some(Step::Saving)
)
});
let won = Store::open(dir.path()).unwrap().accounts().unwrap();
assert_eq!(won.len(), 1);
let (address, secret) = if won[0].1 == account("ann@example.com") {
assert_eq!(step_words(&mail, cx), "ANN@example.com is already added.");
("ann", "pw")
} else {
assert_eq!(won[0].1, account("ANN@example.com"));
cx.update(|_, app| assert!(mail.read(app).adding.is_none()));
("ANN", "pw2")
};
let kept = keys.kept.lock().unwrap();
assert_eq!(kept.len(), 1);
assert_eq!(
kept.get(&format!("account:{address}@example.com"))
.map(String::as_str),
Some(secret)
);
}
#[test]
fn the_keyboard_opens_another_accounts_settings() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path()).unwrap();
store.add_account(&account("ann@example.com")).unwrap();
let bo = store.add_account(&account("bo@example.com")).unwrap();
drop(store);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
// As `mail::open` does.
cx.update(|window, app| window.focus(&mail.read(app).focus.clone(), app));
for _ in 0..2 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
for _ in 0..8 {
cx.simulate_keystrokes("tab space");
cx.run_until_parked();
if cx.update(|_, app| mail.read(app).open) == Open::Account(bo) {
return;
}
}
panic!("no Tab stop let Space open the second account");
}
/// Through the dialog: back to its last stop, Remove.
fn press_remove(mail: &Entity<Mail>, cx: &mut VisualTestContext) {
for _ in 0..2 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
cx.simulate_keystrokes("shift-tab enter");
cx.simulate_event(gpui::KeyUpEvent {
keystroke: gpui::Keystroke::parse("enter").unwrap(),
});
settle(cx, |app| mail.read(app).removing.is_none());
}
/// Opens the first account's page, as a user would.
fn open_first(mail: &Entity<Mail>, cx: &mut VisualTestContext) {
settle(cx, |app| !mail.read(app).accounts().is_empty());
let first = cx.update(|_, app| mail.read(app).accounts()[0].0);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(first), cx)));
}
/// The save lands after a second dialog opened.
fn save_then_reopen(fail: bool) {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Imap, cx);
passed(&mail, account("ann@example.com"), "pw", cx);
if fail {
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
let added = store
.lock()
.unwrap()
.add_account(&account("ann@example.com"));
added.unwrap();
}
cx.update(|window, app| {
mail.update(app, |mail, cx| {
mail.save(cx);
mail.close_adding(cx);
mail.start_adding(window, cx);
let adding = mail.adding.as_mut().unwrap();
adding.set_kind(Kind::Pop, cx);
let address = adding.fields.address.clone();
address.update(cx, |field, cx| {
field.set_text("bob@example.com".to_owned(), cx)
});
})
});
if fail {
let store = cx.update(|_, app| mail.read(app).store.clone().unwrap());
settle(cx, |_| store.try_lock().is_ok());
cx.run_until_parked();
cx.update(|_, app| assert!(mail.read(app).accounts().is_empty()));
} else {
settle(cx, |app| mail.read(app).accounts().len() == 1);
}
cx.update(|_, app| {
let adding = mail.read(app).adding.as_ref().unwrap();
assert_eq!(adding.kind, Kind::Pop);
assert!(matches!(adding.step, Step::Editing));
assert_eq!(adding.fields.address.read(app).text(), "bob@example.com");
});
}
#[test]
fn a_late_save_leaves_a_newer_dialog_alone() {
save_then_reopen(false);
}
#[test]
fn a_late_failed_save_leaves_a_newer_dialog_alone() {
save_then_reopen(true);
}
-258
View File
@@ -1,258 +0,0 @@
use std::time::{Duration, Instant};
use ely_gpui_component::mail::Mail as Row;
use ely_mail_engine::{AccountId, Fetched, Filter, Key, MailboxId, Role, Store};
use gpui::{
Entity, ScrollDelta, ScrollWheelEvent, SharedString, TestAppContext, VisualTestContext, point,
px, size,
};
use super::{Keys, account, settle, window};
use crate::{
boxes::{self, Open},
mail::Mail,
};
fn message(subject: &str, date: &str) -> Vec<u8> {
format!(
"From: Ann <ann@example.com>\r\nSubject: {subject}\r\nDate: {date}\r\n\
Content-Type: text/plain\r\n\r\nhi\r\n"
)
.into_bytes()
}
fn put(store: &mut Store, mailbox: MailboxId, uid: u32, flags: &[&str], subject: &str, day: u8) {
let key = Key::Imap {
uidvalidity: 1,
uid,
};
let bytes = message(subject, &format!("{day} Jan 2024 10:00:00 +0000"));
store
.put(mailbox, &key, flags, Fetched::Whole(&bytes))
.unwrap();
}
/// Ann has an inbox and an archive, Bob an inbox.
fn seeded(dir: &std::path::Path) -> (AccountId, MailboxId) {
let mut store = Store::open(dir).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
let bob = store.add_account(&account("bob@example.com")).unwrap();
let [ann_inbox, archive] = store
.set_folders(
ann,
&[
("INBOX", Some(Role::Inbox)),
("Archive", Some(Role::Archive)),
],
)
.unwrap()
.try_into()
.unwrap();
let [bob_inbox] = store
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
.unwrap()
.try_into()
.unwrap();
put(&mut store, ann_inbox, 1, &["\\Seen"], "old", 1);
put(&mut store, ann_inbox, 2, &[], "new", 3);
put(&mut store, archive, 3, &[], "kept", 2);
put(&mut store, bob_inbox, 4, &["\\Flagged"], "bob", 2);
(ann, archive)
}
/// The listed subjects, once a list is read.
fn listed(mail: &Entity<Mail>, cx: &mut VisualTestContext) -> Option<Vec<String>> {
cx.update(|_, app| match &mail.read(app).listing.mails {
Some(Ok(rows)) => Some(rows.iter().map(|row| row.subject.to_string()).collect()),
Some(Err(error)) => panic!("the list failed: {error}"),
None => None,
})
}
fn shows(mail: &Entity<Mail>, cx: &mut VisualTestContext, subjects: &[&str]) {
let want: Vec<String> = subjects.iter().map(|each| each.to_string()).collect();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
while listed(mail, cx).as_ref() != Some(&want) {
assert!(
std::time::Instant::now() < deadline,
"listed {:?}, not {want:?}",
listed(mail, cx)
);
cx.run_until_parked();
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn key_of(mail: &Entity<Mail>, cx: &mut VisualTestContext, subject: &str) -> SharedString {
cx.update(|_, app| {
let mails = mail.read(app).listing.mails.clone();
let rows = mails.unwrap().unwrap();
rows.iter()
.find(|row| row.subject == subject)
.unwrap()
.key
.clone()
})
}
#[test]
fn every_inbox_is_one_list_newest_first() {
let dir = tempfile::tempdir().unwrap();
seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
cx.update(|_, app| assert_eq!(mail.read(app).open, Open::Inboxes));
shows(&mail, cx, &["new", "bob", "old"]);
settle(cx, |app| mail.read(app).inboxes_unread() == 2);
}
#[test]
fn filters_narrow_the_list() {
let dir = tempfile::tempdir().unwrap();
seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &["new", "bob", "old"]);
let unread = Filter {
unread: true,
..Filter::default()
};
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_filter(unread, cx)));
shows(&mail, cx, &["new", "bob"]);
let flagged = Filter {
unread: true,
flagged: true,
..Filter::default()
};
cx.update(|_, app| mail.update(app, |mail, cx| mail.set_filter(flagged, cx)));
shows(&mail, cx, &["bob"]);
}
#[test]
fn a_folder_lists_its_own_mail() {
let dir = tempfile::tempdir().unwrap();
let (ann, archive) = seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &["new", "bob", "old"]);
let open = Open::Mailbox(ann, archive);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
shows(&mail, cx, &["kept"]);
}
#[test]
fn one_pick_fills_the_reader() {
let dir = tempfile::tempdir().unwrap();
let (ann, archive) = seeded(dir.path());
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &["new", "bob", "old"]);
let (new, bob) = (key_of(&mail, cx, "new"), key_of(&mail, cx, "bob"));
let chosen = |cx: &mut VisualTestContext| {
cx.update(|_, app| {
let chosen = mail.read(app).listing.chosen.as_ref();
chosen.map(|row| row.subject.to_string())
})
};
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(std::slice::from_ref(&new), cx)));
assert_eq!(chosen(cx).as_deref(), Some("new"));
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&[new, bob], cx)));
assert_eq!(chosen(cx), None);
let open = Open::Mailbox(ann, archive);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
cx.update(|_, app| assert!(mail.read(app).listing.selected.is_empty()));
}
#[test]
fn a_rebuilt_mailbox_drops_the_pick() {
let dir = tempfile::tempdir().unwrap();
let (ann, archive) = seeded(dir.path());
// The newest row, whose id SQLite would hand out again.
let mut store = Store::open(dir.path()).unwrap();
put(&mut store, archive, 9, &[], "picked", 4);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
let open = Open::Mailbox(ann, archive);
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(open, cx)));
shows(&mail, cx, &["picked", "kept"]);
let picked = key_of(&mail, cx, "picked");
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&[picked], cx)));
store.open_mailbox(archive, 2).unwrap();
let key = Key::Imap {
uidvalidity: 2,
uid: 3,
};
let bytes = message("other", "2 Jan 2024 10:00:00 +0000");
store
.put(archive, &key, &[], Fetched::Whole(&bytes))
.unwrap();
drop(store);
cx.update(|_, app| mail.update(app, |mail, cx| mail.read_mail(cx)));
shows(&mail, cx, &["other"]);
cx.update(|_, app| {
let listing = &mail.read(app).listing;
assert!(listing.selected.is_empty(), "kept {:?}", listing.selected);
assert!(listing.chosen.is_none());
});
}
#[test]
fn picking_every_one_of_many_messages_is_quick() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
shows(&mail, cx, &[]);
let rows: Vec<Row> = (0..100_000)
.map(|n| Row::undated(n.to_string(), "Ann", "hi"))
.collect();
let keys: Vec<SharedString> = rows.iter().map(|row| row.key.clone()).collect();
let listed = boxes::index(rows);
cx.update(|_, app| mail.update(app, |mail, _| mail.listing.show(Some(Ok(listed)))));
let started = Instant::now();
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&keys, cx)));
cx.update(|_, app| mail.update(app, |mail, cx| mail.select(&keys, cx)));
let took = started.elapsed();
assert!(took < Duration::from_secs(1), "picking took {took:?}");
cx.update(|_, app| assert_eq!(mail.read(app).listing.selected.len(), 100_000));
}
#[test]
fn the_side_scrolls_past_many_folders_to_the_next_account() {
let dir = tempfile::tempdir().unwrap();
let mut store = Store::open(dir.path()).unwrap();
let ann = store.add_account(&account("ann@example.com")).unwrap();
let bob = store.add_account(&account("bob@example.com")).unwrap();
let names: Vec<String> = (0..40).map(|n| format!("Folder {n:02}")).collect();
let folders: Vec<_> = names.iter().map(|name| (name.as_str(), None)).collect();
store.set_folders(ann, &folders).unwrap();
store
.set_folders(bob, &[("INBOX", Some(Role::Inbox))])
.unwrap();
drop(store);
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
cx.update(|_, app| mail.update(app, |mail, cx| mail.open_box(Open::Account(bob), cx)));
cx.simulate_resize(size(px(1100.), px(800.)));
settle(cx, |app| {
let syncs = &mail.read(app).syncs;
syncs.get(&ann).is_some_and(|each| each.folders.len() == 40) && syncs.contains_key(&bob)
});
let selector = "item-selected-Account settings";
let height = cx.update(|window, _| window.viewport_size().height);
let before = cx.debug_bounds(selector).expect("Bob's settings row");
assert!(before.bottom() > height, "the side fits the window");
let at = point(px(100.), height / 2.0);
for _ in 0..20 {
cx.simulate_event(ScrollWheelEvent {
position: at,
delta: ScrollDelta::Pixels(point(px(0.), px(-1000.))),
..Default::default()
});
cx.run_until_parked();
}
let after = cx.debug_bounds(selector).expect("Bob's settings row");
assert!(
after.bottom() <= height,
"Bob's settings are out of reach: {after:?}"
);
}
-115
View File
@@ -1,115 +0,0 @@
use ely_mail_engine::{Protocol, Store};
use gpui::TestAppContext;
use super::{Keys, account, adding, closed_port, settle, type_in, window, window_with};
use crate::kinds::Kind;
#[test]
fn every_account_syncs_on_open_and_says_why_it_failed() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(dir.path()).unwrap();
let mut ann = account("ann@example.com");
ann.incoming.port = closed_port();
let ann = store.add_account(&ann).unwrap();
let mut pop = ely_mail_engine::Account {
protocol: Protocol::Pop { keep: true },
..account("bo@example.com")
};
pop.incoming.port = closed_port();
let bo = store.add_account(&pop).unwrap();
drop(store);
let keys = Keys::default();
keys.kept
.lock()
.unwrap()
.extend(["ann", "bo"].map(|name| (format!("account:{name}@example.com"), "pw".into())));
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), keys);
settle(cx, |app| {
[ann, bo].iter().all(|id| {
mail.read(app)
.syncs
.get(id)
.is_some_and(|each| each.task.is_none() && each.synced.is_some())
})
});
cx.update(|_, app| {
for id in [ann, bo] {
let syncing = &mail.read(app).syncs[&id];
let failed = syncing.failed.as_deref().unwrap();
assert!(failed.starts_with("cannot connect"), "{failed}");
assert_eq!(syncing.synced.unwrap().at, None);
}
});
}
#[test]
fn an_account_without_its_secret_says_so() {
let dir = tempfile::tempdir().unwrap();
let ann = Store::open(dir.path())
.unwrap()
.add_account(&account("ann@example.com"))
.unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
settle(cx, |app| {
mail.read(app)
.syncs
.get(&ann)
.is_some_and(|each| each.failed.is_some())
});
cx.update(|_, app| {
let failed = mail.read(app).syncs[&ann].failed.clone().unwrap();
assert_eq!(
failed,
"credential store failed: no saved secret for account:ann@example.com"
);
});
}
#[test]
fn a_missing_tls_is_the_sync_failure() {
let dir = tempfile::tempdir().unwrap();
let ann = Store::open(dir.path())
.unwrap()
.add_account(&account("ann@example.com"))
.unwrap();
let mut cx = TestAppContext::single();
let tls = Err("platform trust store unavailable".to_owned());
let (mail, cx) = window_with(&mut cx, dir.path(), Keys::default(), tls);
settle(cx, |app| {
mail.read(app)
.syncs
.get(&ann)
.is_some_and(|each| each.synced.is_some())
});
cx.update(|_, app| {
let syncing = &mail.read(app).syncs[&ann];
assert_eq!(
syncing.failed.as_deref(),
Some("platform trust store unavailable")
);
assert!(syncing.task.is_none());
});
}
#[test]
fn a_pop_account_keeps_a_server_copy_unless_told() {
let dir = tempfile::tempdir().unwrap();
let mut cx = TestAppContext::single();
let (mail, cx) = window(&mut cx, dir.path(), Keys::default());
adding(&mail, Kind::Pop, cx);
type_in(&mail, cx, |f| &f.address, "bo@example.com");
type_in(&mail, cx, |f| &f.user, "bo");
type_in(&mail, cx, |f| &f.incoming, "pop.example.com");
type_in(&mail, cx, |f| &f.outgoing, "smtp.example.com");
let protocol = |cx: &mut gpui::VisualTestContext| {
cx.update(|_, app| {
let adding = mail.read(app).adding.as_ref().unwrap();
adding.account(None, app).unwrap().protocol
})
};
assert_eq!(protocol(cx), Protocol::Pop { keep: true });
cx.update(|_, app| mail.update(app, |mail, _| mail.adding.as_mut().unwrap().set_keep(false)));
assert_eq!(protocol(cx), Protocol::Pop { keep: false });
}
-284
View File
@@ -1,284 +0,0 @@
//! What the window draws.
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
feedback::{Banner, EmptyState, ErrorView},
layout::ScrollArea,
overlays::ConfirmDialog,
primitives::{IconName, Severity},
typography::format,
};
use ely_mail_engine::{AccountId, Auth, LocalFolder, Protocol, Security, Server, sync};
use ely_platform::AppId;
use ely_ui::Shell;
use gpui::{
AnyElement, App, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render,
SharedString, Styled as _, Window, div,
};
use crate::{
boxes::Open,
mail::{Mail, text},
menu,
};
fn server(server: &Server, cx: &App) -> SharedString {
let security = match server.security {
Security::Tls => "TLS",
Security::StartTls => "STARTTLS",
};
text(
cx,
"mail.account.server",
&[
("host", &server.host),
("port", &server.port.to_string()),
("security", security),
],
)
}
pub fn auth(auth: Auth, cx: &App) -> SharedString {
let key = match auth {
Auth::Password => "mail.auth.password",
Auth::Google => "mail.auth.google",
Auth::Microsoft => "mail.auth.microsoft",
};
text(cx, key, &[])
}
impl Mail {
fn add_button(&self, cx: &App) -> Button {
Button::new("mail-add", text(cx, "mail.add.open", &[]))
.variant(ButtonVariant::Primary)
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::AddAccount), cx))
}
fn body(&self, cx: &mut Context<Self>) -> AnyElement {
let error = |key: &str, detail: &str, cx: &App| {
ErrorView::new("mail-error", IconName::TriangleAlert, text(cx, key, &[]))
.detail(detail.to_owned())
.into_any_element()
};
if let Err(detail) = &self.store {
return error("mail.store.unopened", detail, cx);
}
let accounts = match &self.accounts {
None => return div().into_any_element(),
Some(Err(detail)) => return error("mail.store.unread", detail, cx),
Some(Ok(accounts)) => accounts,
};
if accounts.is_empty() {
return EmptyState::new("mail-empty", IconName::Mail, text(cx, "mail.empty", &[]))
.action(self.add_button(cx))
.into_any_element();
}
let Open::Account(open) = self.open else {
return self.panes(cx);
};
let Some((id, account)) = accounts.iter().find(|(id, _)| *id == open) else {
log::error!("mail: account {} open but not listed", open.0);
return error("mail.store.unread", "", cx);
};
let label = |key: &str| text(cx, key, &[]);
let incoming = format!(
"{} · {}",
protocol_name(account.protocol),
server(&account.incoming, cx)
);
let details = DescriptionList::new()
.text(label("mail.account.address"), account.address.clone())
.text(label("mail.account.user"), account.user.clone())
.text(label("mail.account.incoming"), incoming)
.text(
label("mail.account.outgoing"),
server(&account.outgoing, cx),
)
.text(label("mail.account.auth"), auth(account.auth, cx));
let (id, protocol) = (*id, account.protocol);
let (details, failed) = self.sync_rows(id, protocol, details, cx);
let remove = Button::new("mail-remove", label("mail.remove.open"))
.on_click(|_, window, cx| window.dispatch_action(Box::new(menu::RemoveAccount), cx));
let page = div()
.p_6()
.flex()
.flex_col()
.gap_4()
.max_w_128()
.children(self.paused_banner(cx))
.children(failed)
.child(details)
.child(self.sync_buttons(id, cx))
.children(self.offline_switches(id, protocol, cx))
.child(div().child(remove));
ScrollArea::new("mail-account-scroll")
.vertical()
.size_full()
.child(page)
.into_any_element()
}
/// When it last synced, what is here, why it failed.
fn sync_rows(
&self,
id: AccountId,
protocol: Protocol,
details: DescriptionList,
cx: &App,
) -> (DescriptionList, Option<Banner>) {
let Some(syncing) = self.syncs.get(&id) else {
return (details, None);
};
let label = |key: &str| text(cx, key, &[]);
let last = match (&syncing.task, syncing.synced.and_then(|synced| synced.at)) {
(Some(_), _) => label("mail.sync.running"),
(None, Some(at)) => local_time(at).into(),
(None, None) => label("mail.sync.never"),
};
let mut details = details.text(label("mail.sync.last"), last);
if let Protocol::Pop { keep } = protocol {
let server = label(if keep {
"mail.sync.kept"
} else {
"mail.sync.deleted"
});
details = details
.text(label("mail.sync.range"), label("mail.sync.range_pop"))
.text(label("mail.sync.server"), server);
} else {
for (key, value) in imap_range(&syncing.folders, cx) {
details = details.text(key, value);
}
}
if let (Some(synced), Protocol::Imap) = (syncing.synced, protocol) {
for (key, n) in [
("mail.sync.uncached", synced.uncached),
("mail.sync.partial", synced.partial),
] {
let value = format!("{key}_value");
let words = ely_ui::i18n::counted(cx, &value, n, &[("n", &n.to_string())]);
details = details.text(label(key), words);
}
}
let failed = syncing.failed.as_ref().map(|error| {
Banner::new(
"mail-sync-failed",
Severity::Danger,
text(cx, "mail.sync.failed", &[("error", error)]),
)
});
(details, failed)
}
fn remove_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let removing = self.removing.as_ref()?;
let this = cx.weak_entity();
let dismiss = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |mail, cx| {
mail.removing = None;
cx.notify();
})
.ok();
}
};
let size = format::file_size(removing.cached.bytes, false);
let count = removing.cached.messages;
let args = [
("address", removing.address.as_str()),
("n", &count.to_string()),
("size", &size),
];
let message = ely_ui::i18n::counted(cx, "mail.remove.message", count, &args);
let dialog = ConfirmDialog::new(
"mail-remove",
text(cx, "mail.remove.title", &args),
message,
dismiss,
)
.confirm(text(cx, "mail.remove.go", &[]))
.destructive()
.on_confirm({
let removing = removing.clone();
move |_, cx| {
let removing = removing.clone();
this.update(cx, |mail, cx| mail.remove(removing, cx)).ok();
}
});
Some(dialog.into_any_element())
}
}
/// What the rest download, then folders kept whole.
pub fn imap_range(folders: &[LocalFolder], cx: &App) -> Vec<(SharedString, SharedString)> {
let (whole, rest): (Vec<_>, Vec<_>) = folders.iter().partition(|folder| folder.offline);
let label = |key: &str| text(cx, key, &[]);
let days = sync::BODY_DAYS.to_string();
let mut rows = Vec::new();
if whole.is_empty() || !rest.is_empty() {
let key = match whole.is_empty() {
true => "mail.sync.range_value",
false => "mail.sync.range_rest",
};
rows.push((label("mail.sync.range"), text(cx, key, &[("days", &days)])));
}
if !whole.is_empty() {
let sep = label("mail.sync.whole_sep");
let names: Vec<&str> = whole.iter().map(|folder| folder.name.as_str()).collect();
let value = text(cx, "mail.sync.whole_value", &[("names", &names.join(&sep))]);
rows.push((label("mail.sync.whole"), value));
}
rows
}
pub fn protocol_name(protocol: Protocol) -> &'static str {
match protocol {
Protocol::Imap => "IMAP",
Protocol::Pop { .. } => "POP3",
}
}
impl Render for Mail {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let left = (!self.accounts().is_empty()).then(|| self.side.clone().into());
let read_only = self.read_only.as_ref().map(|reason| {
Banner::new(
"mail-read-only",
Severity::Warning,
text(cx, "mail.store.read_only", &[("error", reason)]),
)
});
let shell = Shell::new(AppId::Mail.name(), &self.focus)
.left(left)
.children(read_only)
.child(self.body(cx));
div()
.size_full()
.key_context(menu::CONTEXT)
.track_focus(&self.focus)
.on_action(
cx.listener(|mail, _: &menu::AddAccount, window, cx| mail.start_adding(window, cx)),
)
.on_action(cx.listener(|mail, _: &menu::RemoveAccount, _, cx| mail.ask_remove(cx)))
.on_action(cx.listener(|mail, _: &menu::SyncAll, _, cx| mail.sync_all(cx)))
.child(shell)
.children(self.add_dialog(cx))
.children(self.remove_dialog(cx))
}
}
/// Local time to the minute.
pub fn local_time(at: i64) -> String {
match jiff::Timestamp::from_second(at) {
Ok(at) => at
.to_zoned(jiff::tz::TimeZone::system())
.strftime("%Y-%m-%d %H:%M")
.to_string(),
Err(error) => {
log::error!("mail: a sync time out of range: {error}");
at.to_string()
}
}
}
+2 -11
View File
@@ -5,25 +5,19 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
build = "../../packaging/windows/icon.rs"
[build-dependencies]
embed-resource.workspace = true
[dependencies]
dunce.workspace = true
anyhow.workspace = true
base64.workspace = true
ely-files.workspace = true
ely-gpui-component.workspace = true
ely-office.workspace = true
ely-pdf-engine.workspace = true
ely-platform.workspace = true
ely-ui.workspace = true
ely-web-content.workspace = true
gpui.workspace = true
log.workspace = true
base64.workspace = true
futures.workspace = true
markdown = "=1.0.0"
jiff.workspace = true
notify.workspace = true
serde.workspace = true
@@ -33,7 +27,4 @@ serde_json.workspace = true
workspace = true
[dev-dependencies]
ely-gpui-component = { workspace = true, features = ["test-support"] }
ely-ui = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
tempfile.workspace = true
+446
View File
@@ -0,0 +1,446 @@
//! The built page, live: each package renders offline.
use std::{path::Path, time::Duration};
use ely_gpui_component::theme::{ActiveTheme as _, Mode, Theme};
use ely_platform::AppId;
use ely_web_content::{Event, WebView};
use gpui::{
App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
Window, div,
};
/// What the page holds, each block scrolled in and mounted.
const CHECK: &str = r#"
(async () => {
const view = document.querySelector('#document > div:not([hidden])') ?? document;
const settle = () => new Promise((done) => setTimeout(done, 700));
const seen = (node) => {
if (!node || !node.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
node.scrollIntoView({ block: 'nearest' });
const box = node.getBoundingClientRect();
if (box.width < 1 || box.height < 1) return false;
const top = document.elementFromPoint(box.x + box.width / 2, box.y + Math.min(box.height / 2, 8));
return Boolean(top && (node.contains(top) || top.contains(node)));
};
const blocks = [];
for (const block of view.querySelectorAll('.milkdown-code-block')) {
block.scrollIntoView({ block: 'center' });
await settle();
const diagram = block.querySelector('.ely-diagram svg');
const shape = diagram?.querySelector('.node rect, .node polygon');
const box = diagram?.getBoundingClientRect();
blocks.push({
language: block.querySelector('.language-button')?.textContent.trim() ?? '',
editors: block.querySelectorAll('.cm-editor').length,
editable: block.querySelector('.cm-content')?.contentEditable ?? 'none',
code: seen(block.querySelector('.cm-line')),
math: seen(block.querySelector('.preview .katex')),
diagram: box ? [Math.round(box.width), Math.round(box.height)] : [0, 0],
drawn: seen(diagram),
fill: shape ? getComputedStyle(shape).fill : '',
first: window.firstFill ?? '',
failure: seen(block.querySelector('.ely-failure')) ? block.querySelector('.ely-failure').textContent : '',
});
}
document.scrollingElement.scrollTop = 0;
await settle();
const root = document.getElementById('document');
window.elyQuery({
request: JSON.stringify({
step: 'STEP',
mode: root.dataset.mode,
editable: view.querySelector('.ProseMirror')?.contentEditable ?? 'none',
title: view.querySelector('.milkdown h1')?.textContent ?? '',
heading: seen(view.querySelector('.milkdown h1')),
inline: seen(view.querySelector('.milkdown p .katex')),
display: seen(view.querySelector('.milkdown p .katex-display')),
tasks: view.querySelectorAll('li[data-item-type="task"]').length,
boxes: [...view.querySelectorAll('li[data-item-type="task"]')].map((item) => {
const mark = getComputedStyle(item, '::before');
const range = document.createRange();
range.selectNodeContents(item.querySelector('p'));
const line = range.getClientRects()[0];
const top = item.getBoundingClientRect().top + parseFloat(mark.marginTop);
const size = parseFloat(getComputedStyle(item).fontSize);
const shown = mark.display !== 'none' && mark.visibility === 'visible' && parseFloat(mark.opacity) > 0;
// A laid-out box pushes the text 1.5em in.
const inset = (item.querySelector('p').getBoundingClientRect().left - item.getBoundingClientRect().left) / size;
const drawn = shown && mark.content !== 'none' && parseFloat(mark.borderTopWidth) > 0 && Math.abs(inset - 1.5) < 0.05;
return [parseFloat(mark.width) / size, parseFloat(mark.height) / size, top + parseFloat(mark.height) / 2 - (line.top + line.height / 2), drawn];
}),
cells: view.querySelectorAll('.milkdown td').length,
notes: view.querySelectorAll('[data-type="footnote_definition"]').length,
blocks,
font: [...document.fonts].some((f) => f.family === 'KaTeX_Main' && f.status === 'loaded'),
source: seen(view.querySelector(':scope > .cm-editor .cm-line')),
first: view.querySelector('.cm-line')?.textContent ?? '',
editors: document.querySelectorAll('#document > div:not([hidden])').length,
text: view.textContent.slice(0, 40),
bg: getComputedStyle(document.documentElement).getPropertyValue('--ely-bg').trim(),
}),
onSuccess: () => {},
onFailure: () => {},
});
})();
"#;
const TEXTS: &str = r#"{"searchLanguage":"s","noResult":"n","copy":"c","preview":"p","loading":"l","edit":"e","hide":"h"}"#;
/// Steps in order; each reports once.
const STEPS: [&str; 13] = [
"visual", "themed", "preview", "source", "race", "language", "midtheme", "release", "asked",
"stale", "owner", "find", "look",
];
/// Empty and broken queries: errors, never CodeMirror's own panel.
const FIND: &str = r#"
(async () => {
const report = {};
for (const mode of ['source', 'visual']) {
await ely.open('a{b alpha\n', mode, TEXTS, 1, location.href);
const empty = { search: '' };
ely.find(empty);
ely.step({ spec: empty, forward: true });
ely.replace({ spec: empty, all: false });
for (const search of ['a{', '\\a']) ely.find({ search, regexp: true });
ely.unfind();
report[mode] = document.querySelectorAll('.cm-panels .cm-search').length;
await ely.open('\u{1F600} alpha\n', mode, TEXTS, 1, location.href);
ely.find({ search: '(?=.)', regexp: true });
ely.unfind();
}
await new Promise((done) => setTimeout(done, 300));
window.elyQuery({ request: JSON.stringify({ step: 'find', panels: report }), onSuccess: () => {}, onFailure: () => {} });
})();
"#;
/// The writing view: dim blocks, narrow serif column, centered caret.
const LOOK: &str = r#"
(async () => {
const paragraphs = Array.from({ length: 80 }, (_, n) => `Paragraph ${n + 1} of the look.`)
await ely.open(paragraphs.join('\n\n') + '\n', 'visual', TEXTS, 1, location.href)
ely.look({ focus: true, typewriter: true, width: 'narrow', font: 'serif', size: 120 })
ely.reveal({ line: 159, query: '' })
await new Promise((done) => setTimeout(done, 600))
const blocks = [...document.querySelectorAll('.milkdown .ProseMirror > *')]
const dim = blocks.filter((block) => getComputedStyle(block).opacity === '0.35').length
const bright = document.querySelector('.ely-current')
const caret = window.getSelection().getRangeAt(0).getBoundingClientRect()
const page = document.getElementById('document')
const report = {
step: 'look',
blocks: blocks.length,
dim,
bright: bright ? getComputedStyle(bright).opacity : 'none',
centered: Math.abs(caret.top - window.innerHeight / 2) < window.innerHeight / 4,
width: getComputedStyle(page).maxWidth,
serif: getComputedStyle(page).fontFamily.includes('serif'),
}
ely.look({ focus: false, typewriter: false, width: 'medium', font: 'sans', size: 100 })
window.elyQuery({ request: JSON.stringify(report), onSuccess: () => {}, onFailure: () => {} })
})();
"#;
/// Picks Rust in the first block's list, once mounted.
const TO_RUST: &str = r#"
(function pick() {
const block = document.querySelector('#document > div:not([hidden]) .milkdown-code-block');
const button = block?.querySelector('.language-button');
if (!button) return requestAnimationFrame(pick);
button.click();
setTimeout(() => [...block.querySelectorAll('.language-list-item')]
.find((item) => item.textContent.trim() === 'Rust').click(), 30);
})();
"#;
/// Says when a diagram waits, drawn not yet.
const PENDING: &str = r#"
(function wait() {
if (!document.querySelector('.ely-diagram:empty')) return requestAnimationFrame(wait);
new MutationObserver((_, watch) => {
const shape = document.querySelector('.ely-diagram svg .node rect');
if (!shape) return;
window.firstFill = getComputedStyle(shape).fill;
watch.disconnect();
}).observe(document.body, { childList: true, subtree: true });
window.elyQuery({ request: JSON.stringify({ kind: 'pending' }), onSuccess: () => {}, onFailure: () => {} });
})();
"#;
/// A diagram slow enough to change things mid-draw.
fn big() -> String {
let mut source = String::from("```mermaid\ngraph TD\n");
for at in 0..150 {
source += &format!(" N{at} --> N{}\n N{at} --> N{}\n", at + 1, (at * 7) % 151);
}
serde_json::to_string(&(source + "```\n")).expect("json")
}
/// Short sources, slow draws: blocks mount at once.
fn chains(blocks: usize) -> String {
let mut source = String::new();
for block in 0..blocks {
let side = |name: &str| {
let nodes: Vec<String> = (0..20).map(|at| format!("{name}{block}x{at}")).collect();
nodes.join(" & ")
};
source += &format!(
"```mermaid\ngraph LR\n{} --> {}\n```\n\n",
side("A"),
side("B")
);
}
serde_json::to_string(&source).expect("json")
}
/// Leaves for source mid-draw; counts long stalls after.
const RELEASE: &str = r#"
(async () => {
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
await ely.open(BIG, 'preview', TEXTS, 1, location.href);
await sleep(50);
await ely.open('x', 'source', TEXTS, 1, location.href);
let last = performance.now();
let long = 0;
const tick = setInterval(() => {
const now = performance.now();
if (now - last > 500) long += 1;
last = now;
}, 20);
await sleep(7000);
clearInterval(tick);
window.elyQuery({ request: JSON.stringify({ step: 'release', long }), onSuccess: () => {}, onFailure: () => {} });
})();
"#;
fn flip(cx: &mut App) {
let mode = match cx.theme().is_dark() {
true => Mode::Light,
false => Mode::Dark,
};
Theme::set_mode_now(mode, cx);
}
struct Probe {
view: Entity<WebView>,
step: usize,
/// A fresh page: Mermaid's bundle not yet loaded.
fresh: bool,
/// Find reports that carried an error, and every total.
errors: usize,
totals: Vec<u64>,
}
impl Probe {
/// Starts the current step; its check follows.
fn begin(&mut self, cx: &mut Context<Self>) {
let markdown =
serde_json::to_string(include_str!("../../../tests/fixtures/mark/extended.md"))
.expect("json");
let step = STEPS[self.step];
match step {
"themed" => {
flip(cx);
self.check(step, 2500, cx);
}
// Two quick shows: only the last may stay.
"race" => {
self.view.read(cx).run_js(&format!(
"ely.open('# OLD', 'visual', {TEXTS}, 1, location.href); \
ely.open('# NEW', 'source', {TEXTS}, 1, location.href)"
));
self.check(step, 2500, cx);
}
// Two modes asked at once: the last one shows.
"asked" => {
self.view.read(cx).run_js(&format!(
"ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \
.then(() => {{ ely.mode('visual'); ely.mode('preview') }})"
));
self.check(step, 3000, cx);
}
// A request while visual's shown is unanswered wins.
"stale" => {
self.view.read(cx).run_js(&format!(
"ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \
.then(() => {{ ely.mode('visual'); ely.mode('preview') }})"
));
self.check(step, 3000, cx);
}
// An old answer leaves a newer build's request alone.
"owner" => {
self.view.read(cx).run_js(&format!(
"ely.open('# M\\n', 'source', {TEXTS}, 1, location.href) \
.then(() => ely.mode('visual'))"
));
self.check(step, 3000, cx);
}
"find" => self.view.read(cx).run_js(&FIND.replace("TEXTS", TEXTS)),
"look" => self.view.read(cx).run_js(&LOOK.replace("TEXTS", TEXTS)),
"release" => {
let script = RELEASE.replace("BIG", &chains(3)).replace("TEXTS", TEXTS);
self.view.read(cx).run_js(&script);
}
// Changes land while the bundle still loads.
"language" | "midtheme" if !self.fresh => {
self.fresh = true;
self.view.read(cx).run_js("location.reload()");
}
"language" | "midtheme" => {
self.fresh = false;
let show = format!("ely.open({}, 'visual', {TEXTS}, 1, location.href)", big());
self.view.read(cx).run_js(&show);
}
mode => self.view.read(cx).run_js(&format!(
"ely.open({markdown}, '{mode}', {TEXTS}, 1, location.href)"
)),
}
}
/// Checks after `wait`, when drawing is done.
fn check(&self, step: &'static str, wait: u64, cx: &mut Context<Self>) {
let view = self.view.clone();
cx.spawn(async move |_, cx| {
cx.background_executor()
.timer(Duration::from_millis(wait))
.await;
view.update(cx, |view, _| view.run_js(&CHECK.replace("STEP", step)));
})
.detach();
}
fn heard(
&mut self,
_: &Entity<WebView>,
event: &Event,
_: &mut Window,
cx: &mut Context<Self>,
) {
match event {
Event::Loaded => self.begin(cx),
Event::Query(said, reply) => {
let read: serde_json::Value = serde_json::from_str(said).expect("json");
// Some answers wait; meanwhile newer modes are asked.
let (wait, then) = match (STEPS.get(self.step), read["mode"].as_str()) {
(Some(&"stale"), Some("visual")) => (300, "ely.mode('source')"),
(Some(&"owner"), Some("visual")) => {
(300, "ely.mode('preview'); ely.mode('source')")
}
(Some(&"owner"), Some("preview")) => (600, ""),
_ => (0, ""),
};
if !then.is_empty() {
self.view.read(cx).run_js(then);
}
let reply = reply.clone();
cx.spawn(async move |_, cx| {
cx.background_executor()
.timer(Duration::from_millis(wait))
.await;
reply.ok("");
})
.detach();
if read["kind"] == "shown" {
println!("mark: shown {}", read["mode"]);
let Some(&step) = STEPS.get(self.step) else {
return;
};
// Mid-draw: a new language, or a new theme.
match step {
"language" => self.view.read(cx).run_js(TO_RUST),
"midtheme" => return self.view.read(cx).run_js(PENDING),
_ if step == read["mode"] => {}
_ => return,
}
self.check(step, 5000, cx);
return;
}
if read["kind"] == "found" {
self.errors += read["error"].is_string() as usize;
self.totals.extend(read["total"].as_u64());
return;
}
// Edits report too; the session tests them.
if ["changed", "text", "outline", "place"]
.contains(&read["kind"].as_str().unwrap_or_default())
{
return;
}
if read["kind"] == "pending" {
// Its draw takes seconds; flip once it has begun.
println!("mark: pending");
cx.spawn(async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(600))
.await;
this.update(cx, |probe, cx| {
flip(cx);
probe.check("midtheme", 6000, cx);
})
.ok();
})
.detach();
return;
}
let mut read = read;
if read["step"] == "find" {
read["errors"] = self.errors.into();
read["totals"] = self.totals.clone().into();
}
println!("mark: {read}");
self.step += 1;
if self.step == STEPS.len() {
println!("mark: done");
return cx.quit();
}
// The first view stays a while for the window's pixels.
let wait = if self.step == 1 { 3000 } else { 0 };
cx.spawn(async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(wait))
.await;
this.update(cx, |probe, cx| probe.begin(cx)).ok();
})
.detach();
}
Event::Leave(url) => println!("mark: leave {url}"),
Event::Printed(path, ok) => println!("mark: printed {} {ok}", path.display()),
Event::Failed(why) => {
println!("mark: failed {why}");
cx.quit();
}
}
}
}
impl Render for Probe {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.view.clone())
}
}
fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<ely_ui::Root> {
let work = tempfile::tempdir().expect("a temp dir").keep();
ely_web_content::start(&work, cx).expect("CEF starts");
let content = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark");
let view = WebView::open(&content.join("index.html"), &content, window, cx).expect("a view");
let probe = cx.new(|cx| {
cx.subscribe_in(&view, window, Probe::heard).detach();
Probe {
view,
step: 0,
fresh: false,
errors: 0,
totals: Vec::new(),
}
});
ely_ui::root(app, Some(probe.into()), window, cx)
}
fn main() {
if let Some(code) = ely_web_content::subprocess() {
std::process::exit(code);
}
ely_ui::run(AppId::Mark, open);
}
+115 -315
View File
@@ -1,363 +1,163 @@
//! Pictures dropped or pasted, copied beside the file (MARK-07).
//! Images dropped or pasted, copied beside the file (MARK-07).
use std::{
io,
io::Write as _,
path::{Path, PathBuf},
};
use ely_gpui_component::documents::Attachment;
use ely_platform::clipboard::{self, Clip};
use ely_ui::i18n::text;
use gpui::{App, Context, ImageFormat};
use gpui::{App, Context, WeakEntity, Window};
use serde_json::json;
use crate::view::{Failed, MarkView, Mode};
use crate::view::{Failed, MarkView};
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
/// The folder beside the file that takes its images.
pub const FOLDER: &str = "assets";
/// What goes into `assets/`: a file, or pasted bytes.
enum Source {
File(PathBuf),
Bytes(String, Vec<u8>),
const IMAGES: [&str; 8] = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"];
fn image(path: &Path) -> bool {
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default();
IMAGES.contains(&extension.to_lowercase().as_str())
}
fn said(key: &str, cx: &App) -> Failed {
Failed(text(cx, key, &[]).to_string())
/// A relative link to a file in the folder, escaped.
pub fn link(name: &str) -> String {
format!("{FOLDER}/{}", crate::paper::segment(name))
}
/// Writes `png` into `dir` under a new name.
fn save_png(dir: &Path, png: &[u8]) -> io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(io::Error::other)?
.as_millis();
let mut n = 0;
loop {
let name = match n {
0 => format!("pasted-{stamp}.png"),
n => format!("pasted-{stamp}-{n}.png"),
};
let path = dir.join(name);
match std::fs::File::create_new(&path) {
Ok(mut file) => {
file.write_all(png)?;
file.flush()?;
return Ok(path);
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => n += 1,
Err(error) => return Err(error),
}
}
}
/// The paste hook: an image on the clipboard is attached.
pub fn paste(view: WeakEntity<MarkView>) -> impl Fn(&mut Window, &mut App) -> bool {
move |_, cx| match clipboard::read() {
Ok(Some(Clip::Png(png))) => {
view.update(cx, |view, cx| view.attach(Attach::Png(png), cx))
.ok();
true
}
Ok(_) => false,
Err(error) => {
log::error!("mark: the clipboard did not read: {error:#}");
let why = format!("{error:#}");
view.update(cx, |_, cx| cx.emit(Failed(why))).ok();
false
}
}
}
pub enum Attach {
Files(Vec<PathBuf>),
Png(Vec<u8>),
}
impl MarkView {
/// Copies pictures off the window, then links them.
pub(crate) fn attach(&mut self, attached: &Attachment, cx: &mut Context<Self>) {
if self.mode == Mode::Preview {
return cx.emit(said("mark.attach.preview", cx));
}
let Some(folder) = self.folder.clone() else {
return cx.emit(said("mark.attach.untitled", cx));
};
let sources: Vec<Source> = match attached {
Attachment::Files(paths) => {
let (pictures, others): (Vec<&PathBuf>, Vec<&PathBuf>) =
paths.iter().partition(|path| picture(path));
if !others.is_empty() {
cx.emit(said("mark.attach.images", cx));
}
// A link names the file; its name must be text.
let (pictures, unnamed): (Vec<&PathBuf>, Vec<&PathBuf>) = pictures
.into_iter()
.partition(|path| path.file_name().is_some_and(|name| name.to_str().is_some()));
if !unnamed.is_empty() {
cx.emit(said("mark.attach.name", cx));
}
pictures.into_iter().cloned().map(Source::File).collect()
}
Attachment::Picture(image) => {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("the clock is past 1970")
.as_millis();
let name = format!("pasted-{millis}.{}", extension(image.format()));
vec![Source::Bytes(name, image.bytes().to_vec())]
/// Copies into the file's folder, then links at the caret.
pub fn attach(&mut self, what: Attach, cx: &mut Context<Self>) {
let refused = match (&what, self.attachable, self.mode) {
(_, false, _) => Some("mark.attach.untitled"),
(_, _, crate::page::Mode::Preview) => Some("mark.attach.preview"),
(Attach::Files(files), ..) if !files.iter().all(|file| image(file)) => {
Some("mark.attach.images")
}
_ => None,
};
if sources.is_empty() {
return;
if let Some(key) = refused {
return cx.emit(Failed(text(cx, key, &[]).to_string()));
}
log::info!("mark: {} pictures copy in", sources.len());
let assets = folder.join("assets");
let folder = self.folder.clone();
let dir = folder.join(FOLDER);
cx.spawn(async move |view, cx| {
let copied = cx
.background_executor()
.spawn(async move {
sources
.into_iter()
.map(|source| match source {
Source::File(path) => ely_files::import_attachment(&path, &assets),
Source::Bytes(name, bytes) => {
ely_files::write_attachment(&name, &bytes, &assets)
}
})
.collect::<io::Result<Vec<PathBuf>>>()
match what {
Attach::Files(files) => files
.iter()
.map(|file| ely_files::import_attachment(file, &dir))
.collect(),
Attach::Png(png) => save_png(&dir, &png).map(|path| vec![path]),
}
})
.await;
view.update(cx, |view, cx| view.attached(folder, copied, cx))
view.update(cx, |view, cx| view.attached(&folder, copied, cx))
.ok();
})
.detach();
}
/// Links the copies, unless the file moved meanwhile.
fn attached(
&mut self,
folder: PathBuf,
folder: &Path,
copied: io::Result<Vec<PathBuf>>,
cx: &mut Context<Self>,
) {
let copied = match copied {
Ok(copied) => copied,
Err(error) => {
log::error!("mark: a picture did not copy: {error}");
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
return cx.emit(Failed(said.to_string()));
log::error!("mark: an image did not copy: {error}");
return cx.emit(Failed(error.to_string()));
}
};
if self.folder.as_ref() != Some(&folder) {
log::info!("mark: the file moved while pictures copied");
let assets = folder.join("assets").display().to_string();
let said = text(cx, "mark.attach.moved", &[("folder", &assets)]);
return cx.emit(Failed(said.to_string()));
}
let links: Vec<String> = copied.iter().map(|copy| link(&folder, copy)).collect();
let written = self
.document()
.clone()
.update(cx, |document, cx| document.insert(&links.join("\n"), cx));
if !written {
cx.emit(said("mark.attach.preview", cx));
// Links resolve from the file's folder; it moved meanwhile.
if self.folder != folder {
log::warn!("mark: the file moved while images copied; no links");
let at = folder.join(FOLDER).display().to_string();
return cx.emit(Failed(
text(cx, "mark.attach.moved", &[("folder", &at)]).to_string(),
));
}
log::info!("mark: attached {} images", copied.len());
let images: Vec<_> = copied
.iter()
.filter_map(|path| {
let name = path.file_name()?.to_string_lossy().into_owned();
let alt = path.file_stem()?.to_string_lossy().into_owned();
Some(json!({ "src": link(&name), "alt": alt }))
})
.collect();
crate::page::call(self.web.read(cx), "insert", &json!(images));
}
}
fn picture(path: &Path) -> bool {
path.extension()
.is_some_and(|ext| PICTURES.contains(&ext.to_string_lossy().to_ascii_lowercase().as_str()))
}
fn extension(format: ImageFormat) -> &'static str {
match format {
ImageFormat::Png => "png",
ImageFormat::Jpeg => "jpg",
ImageFormat::Gif => "gif",
ImageFormat::Webp => "webp",
ImageFormat::Svg => "svg",
ImageFormat::Bmp => "bmp",
ImageFormat::Tiff => "tiff",
ImageFormat::Ico => "ico",
ImageFormat::Pnm => "pnm",
}
}
/// The Markdown link to `copy`, relative to `folder`.
fn link(folder: &Path, copy: &Path) -> String {
let relative = copy.strip_prefix(folder).expect("copied under the folder");
let address: Vec<String> = relative
.components()
.map(|part| escaped(part.as_os_str().to_str().expect("named in text")))
.collect();
let stem = copy
.file_stem()
.unwrap_or_default()
.to_str()
.expect("named in text");
// A line break would end the image.
let alt = stem
.replace(|ch: char| ch.is_control(), " ")
.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]");
format!("![{alt}]({})", address.join("/"))
}
/// A path part, every byte but plain ones escaped.
fn escaped(part: &str) -> String {
part.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
(byte as char).to_string()
}
other => format!("%{other:02X}"),
})
.collect()
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, path::Path, rc::Rc};
use ely_gpui_component::documents::Attachment;
use gpui::{Entity, Image, ImageFormat, TestAppContext, VisualTestContext};
use super::link;
use crate::desk::{
Desk,
tests::{open, start},
};
use crate::view::{Failed, MarkView};
/// What the view says, in order.
type Said = Rc<RefCell<Vec<String>>>;
/// A saved file open in ElyMark, and what it says.
fn saved(
cx: &mut TestAppContext,
) -> (
Entity<MarkView>,
&'static mut VisualTestContext,
[tempfile::TempDir; 2],
Said,
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(
&path, "a
",
)
.expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let view = desk.read_with(cx, |desk: &Desk, _| desk.active_view().expect("a tab"));
let said = Rc::new(RefCell::new(Vec::new()));
let heard = said.clone();
cx.update(|_, cx| {
cx.subscribe(&view, move |_, failed: &Failed, _| {
heard.borrow_mut().push(failed.0.clone())
})
.detach()
});
(view, cx, [data, files], said)
}
fn attach(view: &Entity<MarkView>, attached: Attachment, cx: &mut VisualTestContext) {
let document = view.read_with(cx, |view, _| view.document().clone());
document.update(cx, |_, cx| cx.emit(attached));
cx.run_until_parked();
}
fn current(view: &Entity<MarkView>, cx: &mut VisualTestContext) -> String {
view.read_with(cx, |view, cx| view.document().read(cx).text(cx))
}
#[gpui::test]
fn a_dropped_picture_is_copied_beside_and_linked(cx: &mut TestAppContext) {
let (view, cx, [_, files], said) = saved(cx);
let shot = files.path().join("my shot.png");
std::fs::write(&shot, "png").expect("a picture");
attach(&view, Attachment::Files(vec![shot]), cx);
assert!(files.path().join("assets/my shot.png").exists());
assert!(
current(&view, cx).contains("![my shot](assets/my%20shot.png)"),
"{}",
current(&view, cx)
);
assert!(said.borrow().is_empty());
}
#[gpui::test]
fn a_pasted_picture_is_written_and_linked(cx: &mut TestAppContext) {
let (view, cx, [_, files], _) = saved(cx);
attach(
&view,
Attachment::Picture(Image::from_bytes(ImageFormat::Png, vec![1])),
cx,
);
let assets: Vec<_> = std::fs::read_dir(files.path().join("assets"))
.expect("assets")
.collect();
assert_eq!(assets.len(), 1);
assert!(current(&view, cx).contains("](assets/pasted-"));
}
#[gpui::test]
fn other_files_and_untitled_text_take_no_picture(cx: &mut TestAppContext) {
let (view, cx, [_, files], said) = saved(cx);
let note = files.path().join("note.txt");
std::fs::write(&note, "text").expect("a file");
attach(&view, Attachment::Files(vec![note]), cx);
assert!(!files.path().join("assets").exists());
assert_eq!(said.borrow().len(), 1, "other files are refused");
view.update(cx, |view, _| view.folder = None);
attach(
&view,
Attachment::Picture(Image::from_bytes(ImageFormat::Png, vec![1])),
cx,
);
assert_eq!(said.borrow().len(), 2, "untitled text is refused");
assert_eq!(
current(&view, cx),
"a
"
);
}
#[gpui::test]
fn a_file_moved_while_copying_gets_no_link(cx: &mut TestAppContext) {
let (view, cx, [_, files], said) = saved(cx);
let document = view.read_with(cx, |view, _| view.document().clone());
document.update(cx, |_, cx| {
cx.emit(Attachment::Picture(Image::from_bytes(
ImageFormat::Png,
vec![1],
)))
});
let elsewhere = files.path().join("elsewhere");
view.update(cx, |view, cx| view.moved(elsewhere, cx));
cx.run_until_parked();
assert_eq!(
current(&view, cx),
"a
"
);
assert_eq!(said.borrow().len(), 1, "the move is said");
}
#[test]
fn links_escape_their_address_and_alt() {
let folder = Path::new("/notes");
let copy = folder.join("assets").join("a b[1].png");
assert_eq!(
link(folder, &copy),
"![a b\\[1\\]](assets/a%20b%5B1%5D.png)"
);
}
#[test]
fn a_name_with_line_breaks_links_one_image() {
let folder = Path::new("/notes");
let written = link(
folder,
&folder.join("assets").join(
"a
b.png",
),
);
let tree =
markdown::to_mdast(&written, &markdown::ParseOptions::default()).expect("parses");
let blocks = tree.children().expect("a root");
let [markdown::mdast::Node::Paragraph(line)] = blocks.as_slice() else {
panic!("one paragraph, not {blocks:?}")
};
assert!(
matches!(line.children.as_slice(), [markdown::mdast::Node::Image(_)]),
"{written}"
);
}
/// A file name that is no text.
#[cfg(unix)]
fn odd_name() -> std::ffi::OsString {
use std::os::unix::ffi::OsStrExt as _;
std::ffi::OsStr::from_bytes(b"pic-\xFF.png").to_owned()
}
#[cfg(windows)]
fn odd_name() -> std::ffi::OsString {
use std::os::windows::ffi::OsStringExt as _;
std::ffi::OsString::from_wide(&[0x70, 0xD800, 0x2E, 0x70, 0x6E, 0x67])
}
#[gpui::test]
fn a_name_that_is_no_text_is_refused(cx: &mut TestAppContext) {
let (view, cx, [_, files], said) = saved(cx);
let odd = files.path().join(odd_name());
std::fs::write(&odd, "png").expect("an oddly named picture");
attach(&view, Attachment::Files(vec![odd]), cx);
assert!(!files.path().join("assets").exists());
assert_eq!(said.borrow().len(), 1);
assert_eq!(
current(&view, cx),
"a
"
);
fn links_escape_and_names_never_repeat() {
assert_eq!(super::link("a b#1.png"), "assets/a%20b%231.png");
let dir = tempfile::tempdir().expect("a temp dir");
let first = super::save_png(dir.path(), b"x").expect("saves");
let second = super::save_png(dir.path(), b"y").expect("saves");
assert_ne!(first, second);
assert_eq!(std::fs::read(first).expect("reads"), b"x");
}
}
+2 -10
View File
@@ -10,7 +10,7 @@ use crate::desk::Desk;
use crate::outside::Outside;
impl Desk {
/// A text change restarts the wait; finished saves report.
/// A page change restarts the wait; finished saves report.
pub fn autosave(&mut self, id: u64, changed: u64, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
@@ -24,19 +24,11 @@ impl Desk {
if tab.session.path().is_some() {
tab.extra.policy.set_has_path();
}
// Any count other than the last heard is news.
// A page reload restarts its count: any difference is news.
if changed == tab.extra.heard {
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);
-234
View File
@@ -1,234 +0,0 @@
//! Typora's Paragraph and Format menus at the caret (MARK-15).
use ely_gpui_component::{
documents::{BlockCommand, EditRefusal, InlineFormat, MarkdownDocument, TableRefusal},
primitives::Severity,
};
use ely_office::prelude::*;
use ely_ui::{commands, i18n::text};
use gpui::{
Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, SharedString,
actions,
};
use crate::{desk::Desk, keys::item};
actions!(
mark,
[
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Paragraph,
Promote,
Demote,
MathBlock,
CodeFences,
Quote,
OrderedList,
BulletList,
TaskList,
ParagraphBefore,
ParagraphAfter,
LinkReference,
Footnote,
Rule,
Toc,
FrontMatter,
Strong,
Emphasis,
Underline,
InlineCode,
Strike,
Hyperlink,
InsertImage,
ClearFormat,
DeleteBlock
]
);
pub fn bind(cx: &mut App) {
let at = Some(commands::CONTEXT);
cx.bind_keys([
KeyBinding::new("secondary-1", Heading1, at),
KeyBinding::new("secondary-2", Heading2, at),
KeyBinding::new("secondary-3", Heading3, at),
KeyBinding::new("secondary-4", Heading4, at),
KeyBinding::new("secondary-5", Heading5, at),
KeyBinding::new("secondary-6", Heading6, at),
KeyBinding::new("secondary-0", Paragraph, at),
KeyBinding::new("secondary-=", Promote, at),
KeyBinding::new("secondary--", Demote, at),
KeyBinding::new("secondary-shift-m", MathBlock, at),
KeyBinding::new("secondary-shift-k", CodeFences, at),
KeyBinding::new("secondary-shift-q", Quote, at),
KeyBinding::new("secondary-{", OrderedList, at),
KeyBinding::new("secondary-}", BulletList, at),
KeyBinding::new("secondary-shift-x", TaskList, at),
KeyBinding::new("secondary-b", Strong, at),
KeyBinding::new("secondary-i", Emphasis, at),
KeyBinding::new("secondary-u", Underline, at),
KeyBinding::new("secondary-~", InlineCode, at),
KeyBinding::new("alt-%", Strike, at),
KeyBinding::new("secondary-k", Hyperlink, at),
KeyBinding::new("secondary-shift-i", InsertImage, at),
KeyBinding::new("secondary-\\", ClearFormat, at),
]);
}
pub fn paragraph_menu(cx: &App) -> Menu {
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
Menu::new(text(cx, "mark.menu.paragraph", &[])).items([
item("mark.block.h1", Box::new(Heading1)),
item("mark.block.h2", Box::new(Heading2)),
item("mark.block.h3", Box::new(Heading3)),
item("mark.block.h4", Box::new(Heading4)),
item("mark.block.h5", Box::new(Heading5)),
item("mark.block.h6", Box::new(Heading6)),
MenuItem::separator(),
item("mark.block.paragraph", Box::new(Paragraph)),
MenuItem::separator(),
item("mark.block.promote", Box::new(Promote)),
item("mark.block.demote", Box::new(Demote)),
MenuItem::separator(),
MenuItem::submenu(crate::tools::table_menu(cx)),
item("mark.block.math", Box::new(MathBlock)),
item("mark.block.code", Box::new(CodeFences)),
MenuItem::separator(),
item("mark.block.quote", Box::new(Quote)),
MenuItem::separator(),
item("mark.block.ordered", Box::new(OrderedList)),
item("mark.block.bullet", Box::new(BulletList)),
item("mark.block.task", Box::new(TaskList)),
MenuItem::separator(),
item("mark.block.before", Box::new(ParagraphBefore)),
item("mark.block.after", Box::new(ParagraphAfter)),
MenuItem::separator(),
item("mark.block.link_reference", Box::new(LinkReference)),
item("mark.block.footnote", Box::new(Footnote)),
MenuItem::separator(),
item("mark.block.rule", Box::new(Rule)),
item("mark.block.toc", Box::new(Toc)),
item("mark.block.front_matter", Box::new(FrontMatter)),
])
}
pub fn format_menu(cx: &App) -> Menu {
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
Menu::new(text(cx, "mark.menu.format", &[])).items([
item("mark.format.strong", Box::new(Strong)),
item("mark.format.emphasis", Box::new(Emphasis)),
item("mark.format.underline", Box::new(Underline)),
item("mark.format.code", Box::new(InlineCode)),
MenuItem::separator(),
item("mark.format.strike", Box::new(Strike)),
MenuItem::separator(),
item("mark.format.link", Box::new(Hyperlink)),
item("mark.format.image", Box::new(InsertImage)),
MenuItem::separator(),
item("mark.format.clear", Box::new(ClearFormat)),
])
}
/// What to tell the user for `why`.
pub(crate) fn refusal(why: EditRefusal, cx: &App) -> SharedString {
let key = match why {
EditRefusal::Preview => "mark.preview.read_only",
EditRefusal::Code => "mark.refused.code",
EditRefusal::Math => "mark.refused.math",
EditRefusal::Html => "mark.refused.html",
EditRefusal::FrontMatter => "mark.refused.front_matter",
EditRefusal::Outside => "mark.refused.outside",
EditRefusal::Table => "mark.refused.table",
EditRefusal::NoImage => "mark.refused.no_image",
EditRefusal::Present => "mark.refused.present",
EditRefusal::Across => "mark.refused.across",
EditRefusal::TooLong => "mark.refused.too_long",
EditRefusal::Busy => "mark.refused.busy",
EditRefusal::Changed => "mark.refused.changed",
EditRefusal::Misread => "mark.refused.misread",
EditRefusal::Cells(TableRefusal::Outside) => "mark.table.none",
EditRefusal::Cells(TableRefusal::Kept) => "mark.table.kept",
EditRefusal::Cells(TableRefusal::Beyond) => "mark.table.beyond",
EditRefusal::Cells(TableRefusal::Mixed) => "mark.table.mixed",
EditRefusal::Cells(TableRefusal::Edge) => "mark.table.edge",
};
text(cx, key, &[])
}
/// Each action to its document edit.
macro_rules! edits {
($root:expr, $cx:expr, $($action:ident => $edit:expr),* $(,)?) => {
$root$(.on_action($cx.listener(|desk, _: &$action, _, cx| desk.edit($edit, cx))))*
};
}
pub(crate) use edits;
impl Desk {
/// `edit` on the shown document; a refusal is said.
pub(crate) fn edit(
&mut self,
edit: impl FnOnce(
&mut MarkdownDocument,
&mut Context<MarkdownDocument>,
) -> Result<(), EditRefusal>,
cx: &mut Context<Self>,
) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let document = view.read(cx).document().clone();
if let Err(why) = document.update(cx, edit) {
self.toast(refusal(why, cx), Severity::Info, cx);
}
}
pub fn block_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
use BlockCommand as B;
use InlineFormat as F;
edits!(root, cx,
Heading1 => |d, cx| d.block(B::Heading(1), cx),
Heading2 => |d, cx| d.block(B::Heading(2), cx),
Heading3 => |d, cx| d.block(B::Heading(3), cx),
Heading4 => |d, cx| d.block(B::Heading(4), cx),
Heading5 => |d, cx| d.block(B::Heading(5), cx),
Heading6 => |d, cx| d.block(B::Heading(6), cx),
Paragraph => |d, cx| d.block(B::Paragraph, cx),
Promote => |d, cx| d.block(B::Promote, cx),
Demote => |d, cx| d.block(B::Demote, cx),
MathBlock => |d, cx| d.block(B::MathBlock, cx),
CodeFences => |d, cx| d.block(B::CodeBlock, cx),
Quote => |d, cx| d.block(B::Quote, cx),
OrderedList => |d, cx| d.block(B::OrderedList, cx),
BulletList => |d, cx| d.block(B::BulletList, cx),
TaskList => |d, cx| d.block(B::TaskList, cx),
ParagraphBefore => |d, cx| d.block(B::ParagraphBefore, cx),
ParagraphAfter => |d, cx| d.block(B::ParagraphAfter, cx),
LinkReference => |d, cx| d.block(B::LinkDefinition, cx),
Footnote => |d, cx| d.block(B::Footnote, cx),
Rule => |d, cx| d.block(B::Rule, cx),
Toc => |d, cx| d.block(B::Toc, cx),
FrontMatter => |d, cx| d.block(B::FrontMatter, cx),
Strong => |d, cx| d.format(F::Strong, cx),
Emphasis => |d, cx| d.format(F::Emphasis, cx),
Underline => |d, cx| d.format(F::Underline, cx),
InlineCode => |d, cx| d.format(F::Code, cx),
Strike => |d, cx| d.format(F::Strike, cx),
Hyperlink => |d, cx| d.format(F::Link, cx),
InsertImage => |d, cx| d.format(F::Image, cx),
ClearFormat => |d, cx| d.format(F::Clear, cx),
DeleteBlock => |d, cx| d.block(B::Delete, cx),
)
}
}
// macOS menus are the system's, not drawn.
#[cfg(test)]
mod context;
#[cfg(all(test, not(target_os = "macos")))]
mod menus;
#[cfg(test)]
mod tests;
-251
View File
@@ -1,251 +0,0 @@
//! The right-click menu, driven as a user would (R4.10).
use ely_gpui_component::theme::{ActiveTheme as _, TextSize};
use gpui::{
ClipboardItem, Entity, Modifiers, MouseButton, Point, TestAppContext, VisualTestContext, point,
px,
};
use super::tests::{current, document, opened, toasted};
use crate::{desk::Desk, view::Mode};
/// A right press on block `ix`'s first line, character `k`.
fn right_on(ix: usize, k: usize, cx: &mut VisualTestContext) {
let block = cx
.debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str()))
.expect("the block is drawn");
let size = cx.update(|window, cx| {
cx.theme()
.text_size(TextSize::Base)
.to_pixels(window.rem_size())
});
// The test text system sets glyphs 0.6 em apart.
let x = block.left() + px(13.0) + size * 0.6 * (k as f32 + 0.25);
right_at(point(x, block.top() + px(10.0)), cx);
}
fn right_at(at: Point<gpui::Pixels>, cx: &mut VisualTestContext) {
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
cx.run_until_parked();
}
/// Arrow `keys`, then Enter; menus choose on its release.
fn choose(keys: &str, cx: &mut VisualTestContext) {
if !keys.is_empty() {
cx.simulate_keystrokes(keys);
}
cx.simulate_keystrokes("enter");
let enter = gpui::Keystroke::parse("enter").expect("a key");
cx.simulate_event(gpui::KeyUpEvent { keystroke: enter });
cx.run_until_parked();
}
fn clipboard(cx: &mut VisualTestContext) -> Option<String> {
cx.read_from_clipboard().and_then(|item| item.text())
}
fn switch(desk: &Entity<Desk>, mode: Mode, cx: &mut VisualTestContext) {
desk.update_in(cx, |desk, window, cx| desk.switch(mode, window, cx));
cx.run_until_parked();
}
#[gpui::test]
fn paste_from_the_menu_lands_at_the_pressed_caret(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("Intro\n", cx);
cx.write_to_clipboard(ClipboardItem::new_string("New ".into()));
right_on(0, 0, cx);
choose("down", cx);
assert_eq!(current(&desk, cx), "New Intro\n");
}
#[gpui::test]
fn a_link_opens_and_copies_its_address(cx: &mut TestAppContext) {
let (_desk, cx, _files) = opened("[docs](https://x.test)\n", cx);
right_on(0, 1, cx);
choose("down", cx);
assert_eq!(cx.opened_url().as_deref(), Some("https://x.test"));
right_on(0, 1, cx);
choose("down down down", cx);
assert_eq!(clipboard(cx).as_deref(), Some("https://x.test"));
}
#[gpui::test]
fn a_code_block_copies_its_code_and_deletes_by_name(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("```rust\nlet x = 1;\n```\n\nNext\n", cx);
right_on(0, 0, cx);
choose("down down down", cx);
assert_eq!(clipboard(cx).as_deref(), Some("let x = 1;"));
right_on(0, 0, cx);
choose("up", cx);
assert!(
!current(&desk, cx).contains("let x"),
"{}",
current(&desk, cx)
);
}
#[gpui::test]
fn a_quote_is_deleted_from_the_last_row(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("> quoted\n\nNext\n", cx);
right_on(0, 1, cx);
choose("up", cx);
let text = current(&desk, cx);
assert!(!text.contains("quoted") && text.contains("Next"), "{text}");
}
#[gpui::test]
fn a_picture_shows_its_file_or_goes(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("Lead.\n\n![pic](missing.png)\n", cx);
right_on(1, 1, cx);
choose("down", cx);
assert_eq!(toasted(&desk, "no file here", cx), 1);
right_on(1, 1, cx);
choose("down down", cx);
assert!(!current(&desk, cx).contains("!["), "{}", current(&desk, cx));
}
#[gpui::test]
fn source_cuts_the_selection_a_right_press_keeps(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("alpha beta\n", cx);
switch(&desk, Mode::Source, cx);
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
editor.update(cx, |editor, cx| editor.select(std::iter::once(0..5), cx));
let line = cx.debug_bounds("editor-line-0").expect("drawn");
right_at(point(line.left() + px(20.0), line.top() + px(6.0)), cx);
choose("down", cx);
assert_eq!(current(&desk, cx), " beta\n");
assert_eq!(clipboard(cx).as_deref(), Some("alpha"));
}
#[gpui::test]
fn an_open_block_copies_its_selection_and_stays(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("Intro words\n", cx);
let block = cx.debug_bounds("markdown-block-0").expect("drawn");
cx.simulate_mouse_down(block.center(), MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(block.center(), MouseButton::Left, Modifiers::none());
cx.run_until_parked();
cx.simulate_keystrokes("home shift-right shift-right shift-right shift-right shift-right");
let field = cx.debug_bounds("markdown-open").expect("open");
right_at(point(field.left() + px(20.0), field.top() + px(14.0)), cx);
choose("down right", cx);
assert_eq!(clipboard(cx).as_deref(), Some("Intro"));
assert!(
cx.debug_bounds("markdown-open").is_some(),
"the block stays open"
);
assert_eq!(current(&desk, cx), "Intro words\n");
}
#[gpui::test]
fn preview_offers_copies_only(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("alpha **beta**\n", cx);
switch(&desk, Mode::Preview, cx);
let block = cx.debug_bounds("markdown-block-0").expect("drawn");
right_at(block.center(), cx);
choose("down", cx);
assert_eq!(clipboard(cx).as_deref(), Some("alpha beta\n"));
right_at(block.center(), cx);
choose("down down", cx);
assert_eq!(clipboard(cx).as_deref(), Some("alpha **beta**\n"));
}
#[gpui::test]
fn html_code_keeps_picture_addresses(cx: &mut TestAppContext) {
let (_desk, cx, _files) = opened("![pic](missing.png) and *it*\n", cx);
cx.dispatch_action(crate::export::CopyHtml);
cx.run_until_parked();
let html = clipboard(cx).expect("copied");
assert!(
html.contains("src=\"missing.png\"") && html.contains("<em>it</em>"),
"{html}"
);
}
#[gpui::test]
fn copies_convert_off_the_window_thread(cx: &mut TestAppContext) {
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
cx.write_to_clipboard(gpui::ClipboardItem::new_string("before".into()));
// The context's dispatch parks; this one does not.
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
assert_eq!(
clipboard(cx).as_deref(),
Some("before"),
"not on the window thread"
);
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyPlain), cx));
cx.run_until_parked();
assert_eq!(clipboard(cx).as_deref(), Some("alpha it\n"));
}
#[gpui::test]
fn a_later_markdown_copy_outlives_a_pending_conversion(cx: &mut TestAppContext) {
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
cx.update(|window, cx| window.dispatch_action(Box::new(crate::clip::CopyMarkdown), cx));
cx.run_until_parked();
assert_eq!(clipboard(cx).as_deref(), Some("alpha *it*\n"));
}
#[gpui::test]
fn a_later_editor_copy_outlives_a_pending_conversion(cx: &mut TestAppContext) {
use gpui::Focusable as _;
let (desk, cx, _files) = opened("alpha *it*\n", cx);
switch(&desk, Mode::Source, cx);
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
editor.update(cx, |editor, cx| editor.select(std::iter::once(0..5), cx));
let focus = editor.read_with(cx, |editor, cx| editor.focus_handle(cx));
cx.update(|window, cx| window.focus(&focus, cx));
cx.run_until_parked();
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
let copy = gpui::Keystroke::parse("secondary-c").expect("key");
cx.update(|window, cx| window.dispatch_keystroke(copy, cx));
cx.run_until_parked();
assert_eq!(clipboard(cx).as_deref(), Some("alpha"));
}
#[gpui::test]
fn a_later_rich_copy_with_the_same_text_outlives_a_pending_conversion(cx: &mut TestAppContext) {
use ely_platform::clipboard::{self, Clip};
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
return;
}
let rich = |html: &str| Clip::Html {
html: html.into(),
text: "x".into(),
};
let (_desk, cx, _files) = opened("alpha *it*\n", cx);
clipboard::write(&rich("<b>x</b>")).expect("writes");
cx.write_to_clipboard(gpui::ClipboardItem::new_string("x".into()));
cx.update(|window, cx| window.dispatch_action(Box::new(crate::export::CopyHtml), cx));
clipboard::write(&rich("<i>x</i>")).expect("writes");
cx.run_until_parked();
let html = clipboard::html().expect("reads");
assert_eq!(
clipboard(cx).as_deref(),
Some("x"),
"the conversion is dropped"
);
assert!(html.is_some_and(|html| html.contains("<i>x</i>")));
}
#[gpui::test]
fn a_shut_menu_stays_shut_when_its_tab_returns(cx: &mut TestAppContext) {
let (desk, cx, files) = opened("alpha\n", cx);
right_on(0, 1, cx);
assert!(cx.debug_bounds("menu-button-0-0").is_some(), "opened");
cx.simulate_keystrokes("escape");
cx.run_until_parked();
assert!(cx.debug_bounds("menu-button-0-0").is_none(), "shut");
let other = files[1].path().join("b.md");
std::fs::write(&other, "beta\n").expect("b");
crate::desk::tests::open(&desk, &other, cx);
let first = desk.read_with(cx, |desk, _| desk.core.tabs[0].id);
desk.update(cx, |desk, cx| {
desk.core.active = Some(first);
cx.notify();
});
cx.run_until_parked();
assert!(cx.debug_bounds("menu-button-0-0").is_none(), "not replayed");
}
-91
View File
@@ -1,91 +0,0 @@
use ely_gpui_component::theme::{ActiveTheme as _, ControlSize};
use gpui::{
App, Entity, KeyUpEvent, Keystroke, Menu, Modifiers, TestAppContext, VisualTestContext, point,
px,
};
use super::tests::{current, document, opened, press};
use crate::{desk::Desk, view::Mode};
/// Selects `alpha` in the first block, opened.
fn select_alpha(desk: &Entity<Desk>, cx: &mut VisualTestContext) {
let bounds = cx.debug_bounds("markdown-block-0").expect("a block");
let at = point(bounds.left() + px(4.), bounds.top() + px(4.));
cx.simulate_click(at, Modifiers::none());
press(
"home shift-right shift-right shift-right shift-right shift-right",
cx,
);
let selected = document(desk, cx).read_with(cx, |document, cx| document.selected(cx));
assert_eq!(selected, "alpha");
}
/// Runs `menu`'s row `row` from the title bar.
fn choose(menu: fn(&App) -> Menu, row: usize, cx: &mut VisualTestContext) {
cx.update(|window, cx| {
cx.set_menus(vec![menu(cx)]);
window.refresh();
});
cx.run_until_parked();
let at = cx.update(|window, cx| {
let rem = window.rem_size();
let bar = cx.theme().titlebar_height().to_pixels(rem);
let title = cx.theme().control_height(ControlSize::Sm).to_pixels(rem);
point(px(12.), bar + title / 2.)
});
cx.simulate_click(at, Modifiers::none());
cx.run_until_parked();
press(&format!("{} enter", vec!["down"; row].join(" ")), cx);
let enter = Keystroke::parse("enter").expect("a key");
cx.simulate_event(KeyUpEvent { keystroke: enter });
cx.run_until_parked();
}
fn edit_menu(cx: &App) -> Menu {
let look = crate::look::Look::default();
crate::keys::menus(&look, cx).remove(0)
}
#[gpui::test]
fn a_menu_formats_the_open_blocks_selection(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("alpha beta\n", cx);
select_alpha(&desk, cx);
choose(super::format_menu, 1, cx);
assert_eq!(current(&desk, cx), "**alpha** beta\n");
}
#[gpui::test]
fn a_menu_copies_the_open_blocks_selection(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("alpha beta\n", cx);
select_alpha(&desk, cx);
// Copy as Markdown, after Copy as Plain Text.
choose(edit_menu, 2, cx);
let copied = cx.read_from_clipboard().and_then(|item| item.text());
assert_eq!(copied.as_deref(), Some("alpha"));
}
const TABLED: &str = "before\n\n| A |\n| - |\n| 1 |\n\nafter\n";
#[gpui::test]
fn delete_table_from_the_menu_is_one_undo_step(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened(TABLED, cx);
desk.update_in(cx, |desk, window, cx| desk.switch(Mode::Source, window, cx));
let document = document(&desk, cx);
document.update(cx, |document, cx| document.set_caret(4, 2, cx));
choose(crate::tools::table_menu, 13, cx);
assert_eq!(current(&desk, cx), "before\n\nafter\n");
press("secondary-z", cx);
assert_eq!(current(&desk, cx), TABLED);
}
#[gpui::test]
fn delete_table_in_an_open_block_keeps_keyboard_undo(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened(TABLED, cx);
let bounds = cx.debug_bounds("markdown-block-1").expect("the table");
let at = point(bounds.left() + px(4.), bounds.top() + px(4.));
cx.simulate_click(at, Modifiers::none());
choose(crate::tools::table_menu, 13, cx);
assert_eq!(current(&desk, cx), "before\n\nafter\n");
press("secondary-z", cx);
assert_eq!(current(&desk, cx), TABLED);
}
-316
View File
@@ -1,316 +0,0 @@
use ely_gpui_component::documents::MarkdownDocument;
#[cfg(target_os = "windows")]
use gpui::Action;
use gpui::{
ClipboardItem, Entity, Focusable as _, Menu, MenuItem, TestAppContext, VisualTestContext,
};
use crate::{
desk::{
Desk,
tests::{open, start},
},
view::Mode,
};
pub(super) fn opened(
text: &str,
cx: &mut TestAppContext,
) -> (
Entity<Desk>,
&'static mut VisualTestContext,
[tempfile::TempDir; 2],
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, text).expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
(desk, cx, [data, files])
}
pub(super) fn document(
desk: &Entity<Desk>,
cx: &mut VisualTestContext,
) -> Entity<MarkdownDocument> {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
view.read(cx).document().clone()
})
}
pub(super) fn current(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> String {
let document = document(desk, cx);
document.read_with(cx, |document, cx| document.text(cx))
}
fn mode(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> Mode {
desk.read_with(cx, |desk, cx| {
desk.active_view().expect("a tab").read(cx).mode
})
}
/// `keys`, then the background reads.
pub(super) fn press(keys: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(keys);
cx.run_until_parked();
}
/// The window's toasts that hold `words`.
pub(super) fn toasted(desk: &Entity<Desk>, words: &str, cx: &mut VisualTestContext) -> usize {
let root = desk.read_with(cx, |desk, _| desk.core.root.upgrade().expect("the window"));
let toasts = root.read_with(cx, |root, cx| root.toasts(cx));
toasts
.iter()
.filter(|(said, _)| said.contains(words))
.count()
}
#[gpui::test]
fn paragraph_keys_retype_the_caret_block(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("Intro\n", cx);
press("secondary-1", cx);
assert_eq!(current(&desk, cx), "# Intro\n");
press("secondary--", cx);
assert_eq!(current(&desk, cx), "## Intro\n");
press("secondary-=", cx);
assert_eq!(current(&desk, cx), "# Intro\n");
press("secondary-0", cx);
assert_eq!(current(&desk, cx), "Intro\n");
press("secondary-{", cx);
assert_eq!(current(&desk, cx), "1. Intro\n");
press("secondary-}", cx);
assert_eq!(current(&desk, cx), "- Intro\n");
press("secondary-shift-q", cx);
assert_eq!(
current(&desk, cx),
"- > Intro\n",
"the item's text is quoted"
);
}
#[gpui::test]
fn format_keys_wrap_the_caret_word(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("word\n", cx);
press("secondary-b", cx);
assert_eq!(current(&desk, cx), "**word**\n");
press("secondary-\\", cx);
assert_eq!(current(&desk, cx), "word\n");
press("alt-%", cx);
assert_eq!(current(&desk, cx), "~~word~~\n");
}
#[gpui::test]
fn ctrl_slash_toggles_source_inside_the_code_editor(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a\n", cx);
press("secondary-/", cx);
assert_eq!(mode(&desk, cx), Mode::Source);
let editor = document(&desk, cx).read_with(cx, |document, _| document.editor().clone());
let focus = editor.read_with(cx, |editor, cx| editor.focus_handle(cx));
cx.update(|window, cx| window.focus(&focus, cx));
press("secondary-/", cx);
assert_eq!(mode(&desk, cx), Mode::Visual);
assert_eq!(current(&desk, cx), "a\n", "no comment marks");
}
#[gpui::test]
fn a_refused_command_says_why(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("```\ncode\n```\n", cx);
press("secondary-1", cx);
assert_eq!(current(&desk, cx), "```\ncode\n```\n");
assert_eq!(toasted(&desk, "inside code", cx), 1);
}
#[gpui::test]
fn alt_arrows_move_a_table_row(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("| A | B |\n| - | - |\n| 1 | 2 |\n", cx);
press("secondary-/", cx);
document(&desk, cx).update(cx, |document, cx| document.set_caret(2, 2, cx));
press("alt-up", cx);
assert_eq!(
current(&desk, cx),
"| 1 | 2 |\n| ---- | ---- |\n| A | B |\n"
);
press("alt-up", cx);
assert_eq!(toasted(&desk, "Nothing lies past", cx), 1);
}
#[gpui::test]
fn view_keys_follow_typora(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a\n", cx);
press("f8 f9 secondary-+", cx);
let look = desk.read_with(cx, |desk, _| desk.look.clone());
assert!(look.focus && look.typewriter && look.size > 100, "{look:?}");
press("secondary-)", cx);
assert_eq!(desk.read_with(cx, |desk, _| desk.look.size), 100);
}
#[gpui::test]
fn copy_as_markdown_and_paste_as_plain_text(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a **b**\n", cx);
press("secondary-shift-c", cx);
let copied = cx.read_from_clipboard().and_then(|item| item.text());
assert_eq!(copied.as_deref(), Some("a **b**\n"));
cx.write_to_clipboard(ClipboardItem::new_string("*x*".into()));
press("secondary-/", cx);
press("secondary-shift-v", cx);
assert_eq!(current(&desk, cx), "*x*a **b**\n");
}
/// Row names; `-` a line, `▸` a submenu.
fn rows(menu: &Menu) -> Vec<String> {
let mut out = Vec::new();
for item in &menu.items {
match item {
MenuItem::Separator => out.push("-".into()),
MenuItem::Submenu(sub) => {
out.push(format!("▸ {}", sub.name));
out.extend(rows(sub).into_iter().map(|row| format!(" {row}")));
}
MenuItem::Action { name, .. } => out.push(name.to_string()),
MenuItem::SystemMenu(os) => out.push(os.name.to_string()),
}
}
out
}
#[gpui::test]
fn the_paragraph_and_format_menus_follow_typora(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a\n", cx);
let menus = desk.read_with(cx, |desk, cx| crate::keys::menus(&desk.look, cx));
let named: Vec<String> = menus.iter().map(|menu| menu.name.to_string()).collect();
assert_eq!(named, ["Edit", "Paragraph", "Format", "View"]);
let table = [
"Insert Table",
"-",
"Add Row Above",
"Add Row Below",
"Add Column Before",
"Add Column After",
"-",
"Move Row Up",
"Move Row Down",
"Move Column Left",
"Move Column Right",
"-",
"Delete Row",
"Delete Column",
"-",
"Prettify Source Code",
"-",
"Delete Table",
];
let mut paragraph: Vec<String> = [
"Heading 1",
"Heading 2",
"Heading 3",
"Heading 4",
"Heading 5",
"Heading 6",
"-",
"Paragraph",
"-",
"Increase Heading Level",
"Decrease Heading Level",
"-",
"▸ Table",
]
.map(String::from)
.into();
paragraph.extend(table.map(|row| format!(" {row}")));
paragraph.extend(
[
"Math Block",
"Code Fences",
"-",
"Quote",
"-",
"Ordered List",
"Unordered List",
"Task List",
"-",
"Insert Paragraph Before",
"Insert Paragraph After",
"-",
"Link Reference",
"Footnotes",
"-",
"Horizontal Line",
"Table of Contents",
"YAML Front Matter",
]
.map(String::from),
);
assert_eq!(rows(&menus[1]), paragraph);
assert_eq!(
rows(&menus[2]).join("|"),
"Strong|Emphasis|Underline|Code|-|Strike|-|Hyperlink|Insert Image|-|Clear Format"
);
}
/// The keys a menu row shows for `action`.
#[cfg(target_os = "windows")]
fn keys(action: &dyn Action, cx: &mut VisualTestContext) -> String {
cx.update(|window, _| {
let binding = window
.highest_precedence_binding_for_action(action)
.expect("a binding");
let strokes: Vec<String> = binding
.keystrokes()
.iter()
.map(|key| key.unparse())
.collect();
strokes.join(" ")
})
}
#[cfg(target_os = "windows")]
#[gpui::test]
fn every_command_takes_typoras_windows_key(cx: &mut TestAppContext) {
use crate::{blocks::*, clip::*, keys::ToggleSource, look, tools::*};
let (_desk, cx, _files) = opened("a\n", cx);
let typora: [(&dyn Action, &str); 34] = [
(&Heading1, "ctrl-1"),
(&Heading6, "ctrl-6"),
(&Paragraph, "ctrl-0"),
(&Promote, "ctrl-="),
(&Demote, "ctrl--"),
(&MathBlock, "ctrl-shift-m"),
(&CodeFences, "ctrl-shift-k"),
(&Quote, "ctrl-shift-q"),
(&OrderedList, "ctrl-{"),
(&BulletList, "ctrl-}"),
(&TaskList, "ctrl-shift-x"),
(&Strong, "ctrl-b"),
(&Emphasis, "ctrl-i"),
(&Underline, "ctrl-u"),
(&InlineCode, "ctrl-~"),
(&Strike, "alt-%"),
(&Hyperlink, "ctrl-k"),
(&InsertImage, "ctrl-shift-i"),
(&ClearFormat, "ctrl-\\"),
(&InsertTable, "ctrl-t"),
(&RowBelow, "ctrl-enter"),
(&DeleteRow, "ctrl-shift-backspace"),
(&MoveRowUp, "alt-up"),
(&MoveRowDown, "alt-down"),
(&MoveColumnLeft, "alt-left"),
(&MoveColumnRight, "alt-right"),
(&ToggleSource, "ctrl-/"),
(&look::FocusMode, "f8"),
(&look::Typewriter, "f9"),
(&look::Larger, "ctrl-+"),
(&look::Smaller, "ctrl-_"),
(&look::ActualSize, "ctrl-("),
(&CopyMarkdown, "ctrl-shift-c"),
(&PastePlain, "ctrl-shift-v"),
];
for (action, want) in typora {
assert_eq!(keys(action, cx), want, "{}", action.name());
}
}
-87
View File
@@ -1,87 +0,0 @@
//! Typora's Copy as Markdown and Paste as Plain Text.
use ely_gpui_component::primitives::Severity;
use ely_office::prelude::*;
use ely_ui::{commands, i18n::text};
use gpui::{
App, ClipboardItem, Context, Focusable as _, InteractiveElement as _, KeyBinding, Window,
actions,
};
use crate::desk::Desk;
actions!(mark, [CopyMarkdown, PastePlain]);
pub fn bind(cx: &mut App) {
let at = Some(commands::CONTEXT);
cx.bind_keys([
KeyBinding::new("secondary-shift-c", CopyMarkdown, at),
KeyBinding::new("secondary-shift-v", PastePlain, at),
]);
}
impl Desk {
/// Whether the document or nothing else holds focus.
fn on_document(&self, window: &Window, cx: &App) -> bool {
let Some(focused) = window.focused(cx) else {
return true;
};
let held = (self.active_view()).is_some_and(|view| {
let document = view.read(cx).document().read(cx);
document.focus_handle(cx).contains_focused(window, cx)
});
held || focused == self.core.focus
}
/// Hands a field outside the document its own `name` action.
fn to_field(name: &str, window: &mut Window, cx: &mut App) {
match cx.build_action(name, None) {
Ok(action) => window.dispatch_action(action, cx),
Err(error) => log::error!("mark: no action {name}: {error}"),
}
}
/// The selected Markdown, or the whole text, as written.
fn copy_markdown(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.on_document(window, cx) {
return Self::to_field("ely_input::Copy", window, cx);
}
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let document = view.read(cx).document().read(cx);
let selected = document.selected(cx);
let markdown = match selected.is_empty() {
true => document.text(cx),
false => selected,
};
log::info!("mark: {} bytes copied as Markdown", markdown.len());
cx.write_to_clipboard(ClipboardItem::new_string(markdown));
}
/// The clipboard's text at the caret, pictures left out.
fn paste_plain(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.on_document(window, cx) {
return Self::to_field("ely_input::Paste", window, cx);
}
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let Some(pasted) = cx.read_from_clipboard().and_then(|item| item.text()) else {
let said = text(cx, "mark.paste.none", &[]);
return self.toast(said, Severity::Info, cx);
};
let document = view.read(cx).document().clone();
if !document.update(cx, |document, cx| document.insert(&pasted, cx)) {
let said = text(cx, "mark.preview.read_only", &[]);
self.toast(said, Severity::Info, cx);
}
}
pub fn clip_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
root.on_action(
cx.listener(|desk, _: &CopyMarkdown, window, cx| desk.copy_markdown(window, cx)),
)
.on_action(cx.listener(|desk, _: &PastePlain, window, cx| desk.paste_plain(window, cx)))
}
}
+18 -38
View File
@@ -2,7 +2,7 @@
use std::rc::Rc;
use ely_files::Fingerprint;
use ely_files::{Fingerprint, Policy, PolicyEngine};
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
git::{self as merging, Region, Take, ThreeWayMerge},
@@ -30,6 +30,10 @@ pub struct Comparing {
impl Desk {
/// Mine, the base and the disk's, side by side.
pub fn compare(&mut self, id: u64, cx: &mut Context<Self>) {
// The page's newest text first.
if !self.settled(id, cx) {
return self.when_settled(id, move |desk, _, cx| desk.compare(id, cx));
}
let Some(tab) = self.tab(id) else {
return;
};
@@ -98,7 +102,7 @@ impl Desk {
Choice::Mine => {
if let Some(tab) = self.tab_mut(id) {
tab.session.rebase(fingerprint);
tab.extra.restart_autosave();
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
}
self.set_outside(id, Outside::Clear, cx);
self.save(id, None, window, cx);
@@ -108,10 +112,19 @@ impl Desk {
if open > 0 {
return log::error!("mark: a merge with {open} open conflicts");
}
// Disk text is saved; the merge edits it.
if let Some(view) = self.adopt_disk(id, &disk, fingerprint, cx) {
view.update(cx, |view, cx| view.adopt(merged, cx));
let Some(tab) = self.tab_mut(id) else {
return;
};
if let Err(error) = tab.session.reload_at(fingerprint) {
let why = error.to_string();
return self.set_outside(id, Outside::Unread(why), cx);
}
tab.extra.base = disk.text.clone();
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
tab.doc.lock().expect("the text lock").source = disk;
let view = tab.view.clone();
view.update(cx, |view, cx| view.adopt(merged, cx));
self.set_outside(id, Outside::Clear, cx);
}
}
}
@@ -190,36 +203,3 @@ enum Choice {
Mine,
Merged,
}
#[cfg(test)]
mod tests {
use gpui::TestAppContext;
use super::Choice;
use crate::outside::tests::{look, opened, shown};
#[gpui::test]
fn a_merge_lands_as_one_undo_step(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("a\n\nb\n", cx);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
view.update(cx, |view, cx| view.adopt("a\n\nb\n\nmine\n".into(), cx));
cx.run_until_parked();
std::fs::write(&path, "theirs\n\na\n\nb\n").expect("an outside write");
look(&desk, id, cx);
desk.update(cx, |desk, cx| desk.compare(id, cx));
desk.update_in(cx, |desk, window, cx| {
desk.settle(Choice::Merged, window, cx)
});
cx.run_until_parked();
assert_eq!(shown(&desk, cx), "theirs\n\na\n\nb\n\nmine\n");
let focus = view.read_with(cx, |view, _| view.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.run_until_parked();
cx.simulate_keystrokes("secondary-z");
assert_eq!(
shown(&desk, cx),
"a\n\nb\n\nmine\n",
"one undo takes the merge back"
);
}
}
+67 -89
View File
@@ -22,31 +22,38 @@ use gpui::{
ParentElement as _, PathPromptOptions, Render, Styled as _, Window, div,
};
use crate::engine::Mark;
use crate::view::{MarkView, Mode};
use crate::engine::{Mark, Pages};
use crate::keys::{
CheckDisk, ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, PreviewMode,
RowAbove, RowBelow, SourceMode, VisualMode,
};
use crate::page::Mode;
use crate::view::MarkView;
pub struct Desk {
pub core: Core<Desk>,
/// The tab being renamed and its name field.
pub naming: Option<(u64, Entity<TextInput>)>,
/// The export dialog's tab and format.
pub exporting: Option<(u64, crate::output::Format)>,
pub folder: Option<crate::folder::Folder>,
/// Folder listings so far; searches redo on a new one.
pub listings: u64,
/// Files and folders opened lately, newest first.
pub recent: Vec<PathBuf>,
pub recent_file: PathBuf,
/// A file asked to open at a line and column.
pub going: Option<(PathBuf, (usize, usize))>,
/// A file asked to open at a place.
pub going: Option<(PathBuf, serde_json::Value)>,
/// The path each tab last put in the recent list.
pub remembered: HashMap<u64, PathBuf>,
pub(crate) side: Entity<crate::side::Side>,
side: Entity<crate::side::Side>,
pub finder: Option<crate::finding::Finder>,
pub watcher: Option<notify::RecommendedWatcher>,
/// The folders the watcher covers.
pub watched: Vec<PathBuf>,
pub comparing: Option<crate::compare::Comparing>,
pub keeping: crate::restore::Keeping,
/// The export dialog's choice, while it is open.
pub output: Option<crate::export::Output>,
pub printing: Option<crate::export::Printing>,
pub look: crate::look::Look,
pub look_file: PathBuf,
/// Git's word on the shown file, and which tab asked.
@@ -54,10 +61,6 @@ pub struct Desk {
asked: Option<u64>,
/// The shown file's fingerprint when Git was last asked.
asked_print: Option<ely_files::Fingerprint>,
/// The find box, while open.
pub(crate) finding: Option<crate::finding::Find>,
/// The copy being converted; a newer one drops it.
pub(crate) copying: Option<gpui::Task<()>>,
}
impl Host for Desk {
@@ -78,11 +81,6 @@ 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;
@@ -108,8 +106,9 @@ impl Host for Desk {
/// Save As or rename moves where images come from.
fn observed(&mut self, id: u64, view: &Entity<MarkView>, cx: &mut Context<Self>) {
self.watch_folders(cx);
self.look_once(id, cx);
let changed = view.read(cx).changed();
let look = serde_json::to_value(&self.look).expect("a look serializes");
view.update(cx, |view, cx| view.set_look(look, cx));
let changed = view.read(cx).changed;
self.autosave(id, changed, cx);
let recheck = self.tab_mut(id).is_some_and(|tab| {
if !tab.dirty(cx) {
@@ -120,9 +119,8 @@ impl Host for Desk {
if recheck {
self.look(id, cx);
}
self.restore_shown(id, cx);
if self.finding.is_some() {
self.count(cx);
if view.read(cx).shown {
self.restore_shown(id, cx);
}
// A save changes what Git sees.
let print = self.active_tab().and_then(|tab| tab.session.fingerprint());
@@ -134,18 +132,19 @@ impl Host for Desk {
.tab(id)
.and_then(|tab| tab.session.path().map(PathBuf::from));
if let Some(path) = path {
view.update(cx, |view, _| view.attachable = true);
if let Some(folder) = path.parent() {
view.update(cx, |view, cx| view.moved(folder.to_path_buf(), cx));
}
self.track_recent(id, &path, cx);
let real = dunce::canonicalize(&path).ok();
let real = std::fs::canonicalize(&path).ok();
if self
.going
.as_ref()
.is_some_and(|(file, _)| Some(file) == real.as_ref())
&& let Some((_, at)) = self.going.take()
&& let Some((_, place)) = self.going.take()
{
self.reveal_later(view.clone(), at, cx);
view.update(cx, |view, cx| view.reveal(place, cx));
}
}
cx.notify();
@@ -154,21 +153,15 @@ impl Host for Desk {
/// The window root; files given at launch open.
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
let data = ely_platform::data_dir(app)
.unwrap_or_else(|error| startup::fail(app, &format!("{error:#}")));
open_in(app, data, window, cx).0
}
/// The window root and its desk, kept in `data`.
pub(crate) fn open_in(
app: AppId,
data: PathBuf,
window: &mut Window,
cx: &mut App,
) -> (Entity<Root>, Entity<Desk>) {
let fail = |error: String| -> ! { startup::fail(app, &error) };
let data = ely_platform::data_dir(app).unwrap_or_else(|error| fail(format!("{error:#}")));
if let Err(error) = ely_web_content::start(&data, cx) {
fail(format!("{error:#}"));
}
let dir = crate::page::content().unwrap_or_else(|error| fail(format!("{error:#}")));
let work = ely_office::fresh_work(&data).unwrap_or_else(|error| fail(format!("{error}")));
crate::keys::bind(cx);
let pages = Arc::new(Mutex::new(Pages { dir }));
let recent_file = data.join("recent.json");
let look_file = data.join("look.json");
// An unreadable look is reported; the default shows.
@@ -187,9 +180,8 @@ pub(crate) fn open_in(
let this = cx.entity();
let side = cx.new(|cx| crate::side::Side::new(&this, window, cx));
Desk {
core: Core::new(Arc::new(Mutex::new(())), work, cx),
core: Core::new(pages, work, cx),
naming: None,
exporting: None,
folder: None,
listings: 0,
recent,
@@ -197,17 +189,18 @@ pub(crate) fn open_in(
going: None,
remembered: HashMap::new(),
side,
finder: None,
watcher: None,
watched: Vec::new(),
comparing: None,
keeping,
output: None,
printing: None,
look: look.clone(),
look_file,
tracked: None,
asked: None,
asked_print: None,
finding: None,
copying: None,
}
});
let menus = crate::keys::menus(&look, cx);
@@ -222,26 +215,16 @@ pub(crate) fn open_in(
let asked = ely_ui::launch_files(cx);
desk.update(cx, |desk, cx| {
desk.core.root = root.downgrade();
desk.own_window(window, cx);
desk.core.window = Some(window.window_handle());
window.focus(&desk.core.focus, cx);
// Launch files stand alone; unsaved text still returns.
if asked.is_empty() {
for path in kept {
// A restored tab does not rejoin the recent list.
let (id, real) = (desk.core.next, dunce::canonicalize(&path).ok());
desk.open_path(path, cx);
if let Some(real) = real.filter(|_| desk.core.next > id) {
desk.remembered.insert(id, real);
}
}
}
for (at, name) in desk.keeping.untitled() {
// Tab numbers come from `next` as the load starts.
let id = desk.core.next;
for name in desk.keeping.untitled() {
desk.new_from_text(&name, "", cx);
if desk.core.next > id {
desk.keeping.made(at, id);
}
}
for path in asked {
desk.open_path(path, cx);
@@ -258,13 +241,8 @@ pub(crate) fn open_in(
}
})
.detach();
cx.on_app_quit(|desk, cx| {
// A system end skips asking: unsaved tabs keep points.
if desk.core.tabs.iter().any(|tab| tab.dirty(cx)) {
desk.keeping.hold();
} else {
desk.keeping.close();
}
cx.on_app_quit(|desk, _| {
desk.keeping.close();
async {}
})
.detach();
@@ -302,7 +280,7 @@ pub(crate) fn open_in(
desk.should_close(window, cx)
})
});
(root, desk)
root
}
impl Desk {
@@ -341,28 +319,16 @@ impl Desk {
self.new_from_text("untitled.md", "", cx);
}
pub(crate) fn switch(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
fn table(&mut self, op: &str, cx: &mut Context<Self>) {
if let Some(view) = self.active_view() {
view.update(cx, |view, cx| view.switch(mode, window, cx));
view.update(cx, |view, cx| view.table(op, cx));
}
}
/// Shows line and column `at` from outside an update.
pub fn reveal_later(
&mut self,
view: Entity<MarkView>,
at: (usize, usize),
cx: &mut Context<Self>,
) {
cx.spawn(async move |desk, cx| {
let shown = ely_office::in_window(&desk, cx, |_, window, cx| {
view.update(cx, |view, cx| view.reveal(at, window, cx))
});
if let Err(error) = shown {
log::warn!("mark: line {} was not shown: {error:#}", at.0);
}
})
.detach();
fn switch(&mut self, mode: Mode, cx: &mut Context<Self>) {
if let Some(view) = self.active_view() {
view.update(cx, |view, cx| view.switch(mode, cx));
}
}
fn status(&self, cx: &mut Context<Self>) -> StatusBar {
@@ -383,9 +349,9 @@ impl Desk {
control.segment(mode.name(), crate::keys::label(mode, cx), Some(icon))
},
);
let modes = modes.on_change(move |key, window, cx| {
let modes = modes.on_change(move |key, _, cx| {
if let Some(mode) = Mode::parse(key) {
this.update(cx, |desk, cx| desk.switch(mode, window, cx));
this.update(cx, |desk, cx| desk.switch(mode, cx));
}
});
let bar = StatusBar::new().left(state);
@@ -433,17 +399,32 @@ impl Render for Desk {
.on_action(cx.listener(Self::on_save))
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_rename))
.on_action(cx.listener(Self::on_close_tab))
.on_action(cx.listener(Self::on_find))
.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)))
.on_action(cx.listener(|desk, _: &ColumnLeft, _, cx| desk.table("column-left", cx)))
.on_action(cx.listener(|desk, _: &ColumnRight, _, cx| desk.table("column-right", cx)))
.on_action(cx.listener(|desk, _: &DeleteRow, _, cx| desk.table("delete-row", cx)))
.on_action(cx.listener(|desk, _: &DeleteColumn, _, cx| desk.table("delete-column", cx)))
.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx)))
.on_action(cx.listener(|desk, _: &VisualMode, _, cx| desk.switch(Mode::Visual, cx)))
.on_action(cx.listener(|desk, _: &SourceMode, _, cx| desk.switch(Mode::Source, cx)))
.on_action(cx.listener(|desk, _: &PreviewMode, _, cx| desk.switch(Mode::Preview, cx)))
.child(shell)
.children(self.core_dialog(cx))
.children(self.naming_element(cx))
.children(self.find_bar(cx))
.children(self.compare_dialog(cx))
.children(self.find_box(cx))
.children(self.export_dialog(cx));
let root = Self::mode_actions(Self::look_actions(root, cx), cx);
let root = Self::block_actions(Self::table_actions(root, cx), cx);
Self::output_actions(Self::clip_actions(root, cx), cx)
Self::look_actions(root, cx)
}
}
@@ -452,7 +433,7 @@ fn large_banner(
view: &Entity<MarkView>,
cx: &mut Context<Desk>,
) -> Option<ely_gpui_component::feedback::Banner> {
if !view.read(cx).large() {
if !view.read(cx).large {
return None;
}
let size = view.read(cx).bytes() as f64 / (1024.0 * 1024.0);
@@ -463,6 +444,3 @@ fn large_banner(
said,
))
}
#[cfg(test)]
pub(crate) mod tests;
-168
View File
@@ -1,168 +0,0 @@
use std::{cell::RefCell, path::Path, rc::Rc};
use ely_platform::AppId;
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::{Desk, open_in};
use crate::view::Mode;
/// ElyMark over a fresh data folder, as `main` opens it.
pub(crate) fn start(
cx: &mut TestAppContext,
data: &Path,
) -> (Entity<Desk>, &'static mut VisualTestContext) {
// The file watcher runs on a real thread.
cx.executor().allow_parking();
cx.update(ely_gpui_component::init_for_tests);
cx.update(|cx| {
ely_ui::settings::install_defaults(cx);
ely_ui::i18n::install(cx);
ely_ui::install_no_launch_files(cx);
});
let held = Rc::new(RefCell::new(None));
let kept = held.clone();
let data = data.to_path_buf();
let window = cx.update(|cx| {
cx.open_window(Default::default(), |window, cx| {
let (root, desk) = open_in(AppId::Mark, data, window, cx);
kept.replace(Some(desk));
root
})
.expect("a window")
});
let cx = VisualTestContext::from_window(window.into(), cx).into_mut();
cx.update(|window, _| window.activate_window());
cx.run_until_parked();
let desk = held.take().expect("the desk");
(desk, cx)
}
pub(crate) fn open(desk: &Entity<Desk>, path: &Path, cx: &mut VisualTestContext) {
desk.update(cx, |desk, cx| desk.open_path(path.to_path_buf(), cx));
cx.run_until_parked();
}
fn outline(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<(usize, String, usize)> {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
let outline = view.read(cx).outline(cx);
outline
.into_iter()
.map(|heading| (heading.level, heading.title, heading.line))
.collect()
})
}
#[gpui::test]
fn a_file_opens_with_its_outline_and_joins_the_recent_list(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, "# One\n\nText.\n\n## Two\n\nMore.\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
assert_eq!(
outline(&desk, cx),
[(1, "One".into(), 0), (2, "Two".into(), 4)]
);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
view.update_in(cx, |view, window, cx| view.reveal((4, 0), window, cx));
cx.run_until_parked();
assert_eq!(
view.read_with(cx, |view, cx| view.place(cx).line),
4,
"the jump lands"
);
let real = dunce::canonicalize(&path).expect("real");
assert!(desk.read_with(cx, |desk, _| desk.recent.contains(&real)));
let kept = std::fs::read_to_string(data.path().join("recent.json")).expect("the list");
assert!(kept.contains("a.md"), "{kept}");
assert!(!kept.contains(r"\\\\?\\"), "plain paths only: {kept}");
}
#[gpui::test]
fn the_recent_list_clears_from_its_button(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, "Text.\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
assert!(desk.read_with(cx, |desk, _| !desk.recent.is_empty()));
let button = cx.debug_bounds("mark-clear-recent").expect("the button");
cx.simulate_click(button.center(), gpui::Modifiers::none());
cx.run_until_parked();
assert!(desk.read_with(cx, |desk, _| desk.recent.is_empty()));
let kept = std::fs::read_to_string(data.path().join("recent.json")).expect("the list");
assert_eq!(kept, "[]");
assert!(cx.debug_bounds("mark-clear-recent").is_none(), "nothing left to clear");
}
#[gpui::test]
fn the_outline_follows_typing(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, "Text.\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
view.update_in(cx, |view, window, cx| view.switch(Mode::Source, window, cx));
view.update(cx, |view, cx| view.adopt("# New\n\nText.\n".into(), cx));
cx.executor()
.advance_clock(std::time::Duration::from_secs(1));
cx.run_until_parked();
assert_eq!(outline(&desk, cx), [(1, "New".into(), 0)]);
}
#[gpui::test]
fn two_files_are_two_tabs_each_with_its_outline(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let (a, b) = (files.path().join("a.md"), files.path().join("b.md"));
std::fs::write(&a, "# A\n").expect("a");
std::fs::write(&b, "# B\n").expect("b");
let (desk, cx) = start(cx, data.path());
open(&desk, &a, cx);
open(&desk, &b, cx);
assert_eq!(desk.read_with(cx, |desk, _| desk.core.tabs.len()), 2);
assert_eq!(outline(&desk, cx), [(1, "B".into(), 0)]);
let first = desk.read_with(cx, |desk, _| desk.core.tabs[0].id);
desk.update(cx, |desk, cx| {
desk.core.active = Some(first);
cx.notify();
});
assert_eq!(outline(&desk, cx), [(1, "A".into(), 0)]);
}
#[gpui::test]
fn a_folder_lists_its_markdown_files(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
std::fs::create_dir(files.path().join("sub")).expect("sub");
std::fs::write(files.path().join("a.md"), "a").expect("a");
std::fs::write(files.path().join("sub/b.markdown"), "b").expect("b");
std::fs::write(files.path().join("c.txt"), "c").expect("c");
let (desk, cx) = start(cx, data.path());
open(&desk, files.path(), cx);
let mut listed = desk.read_with(cx, |desk, _| {
let folder = desk.folder.as_ref().expect("a folder");
folder
.files
.iter()
.map(|file| file.replace('\\', "/"))
.collect::<Vec<_>>()
});
listed.sort();
assert_eq!(listed, ["a.md", "sub/b.markdown"]);
}
+67 -44
View File
@@ -4,15 +4,21 @@ use std::{
convert::Infallible,
hash::{DefaultHasher, Hash as _, Hasher as _},
io,
path::Path,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use ely_office::{Engine, Host, Load, Tabs as _};
use ely_office::{Engine, Host, Load};
use ely_web_content::WebView;
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window};
use crate::text::Source;
use crate::view::{Failed, MarkView, Refused, Reported};
use crate::view::{Failed, MarkView, Reported};
/// The bundled page every tab shows.
pub struct Pages {
pub dir: PathBuf,
}
/// A file as read, and its text now.
pub struct Text {
@@ -22,20 +28,14 @@ pub struct Text {
written: Option<String>,
}
impl Text {
/// A file as read, not yet edited.
pub fn new(source: Source) -> Text {
let current = source.text.clone();
Text {
source,
current,
written: None,
}
}
}
pub type Doc = Arc<Mutex<Text>>;
/// A load: the file as read, and where it is.
pub struct Opened {
pub source: Source,
pub path: PathBuf,
}
/// The history mark: this text's length and hash.
pub fn mark(text: &str) -> String {
let mut hasher = DefaultHasher::new();
@@ -46,14 +46,13 @@ pub fn mark(text: &str) -> String {
pub struct Mark;
impl Engine for Mark {
/// Files read and written in place: no worker.
type Worker = ();
type Worker = Pages;
type Doc = Doc;
type View = MarkView;
type Loaded = Source;
type Loaded = Opened;
type Import = Infallible;
fn load(_: &mut (), from: Load<Infallible>) -> io::Result<Source> {
fn load(_: &mut Pages, from: Load<Infallible>) -> io::Result<Opened> {
let path = match from {
Load::File(path, _) => path,
Load::Import(_, never) => match *never {},
@@ -63,29 +62,50 @@ impl Engine for Mark {
io::ErrorKind::PermissionDenied => io::Error::other(error),
_ => error,
})?;
Source::decode(bytes)
Ok(Opened {
source: Source::decode(bytes)?,
path: path.to_path_buf(),
})
}
fn open(
_: &Arc<Mutex<()>>,
loaded: Source,
worker: &Arc<Mutex<Pages>>,
loaded: Opened,
window: &mut Window,
cx: &mut App,
) -> io::Result<(Doc, Entity<MarkView>, String)> {
let Opened {
source: loaded,
path,
} = loaded;
let folder = path
.parent()
.expect("a file sits in a folder")
.to_path_buf();
let dir = worker.lock().expect("the pages lock").dir.clone();
let web =
WebView::open(&dir.join("index.html"), &dir, window, cx).map_err(io::Error::other)?;
let mark = mark(&loaded.text);
let doc = Arc::new(Mutex::new(Text::new(loaded)));
let saved = mark.clone();
let view = cx.new(|cx| MarkView::new(Arc::clone(&doc), saved, window, cx));
let current = loaded.text.clone();
let doc = Arc::new(Mutex::new(Text {
source: loaded,
current,
written: None,
}));
web.read(cx)
.allow_images(&folder)
.map_err(io::Error::other)?;
let view = cx.new(|cx| MarkView::new(web, Arc::clone(&doc), mark.clone(), folder, cx));
Ok((doc, view, mark))
}
/// After a write, what it wrote: later edits stay unsaved.
fn history(_: &mut (), doc: &Doc) -> io::Result<String> {
fn history(_: &mut Pages, doc: &Doc) -> io::Result<String> {
let mut text = doc.lock().expect("the text lock");
Ok(text.written.take().unwrap_or_else(|| mark(&text.current)))
}
fn write(_: &mut (), doc: &Doc, path: &Path, format: &str) -> io::Result<()> {
fn write(_: &mut Pages, doc: &Doc, path: &Path, format: &str) -> io::Result<()> {
if !matches!(format, "md" | "markdown") {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
@@ -98,13 +118,13 @@ impl Engine for Mark {
Ok(())
}
fn close(_: &mut (), _: Doc) -> io::Result<()> {
fn close(_: &mut Pages, _: Doc) -> io::Result<()> {
Ok(())
}
/// Pictures leave with their elements; nothing to hand back.
fn retire(_: &Entity<MarkView>, _: &mut App) -> ely_gpui_component::primitives::Retired {
ely_gpui_component::primitives::Retired::default()
fn retire(view: &Entity<MarkView>, cx: &mut App) -> ely_gpui_component::primitives::Retired {
let web = view.read(cx).web.clone();
web.update(cx, |web, _| web.retire_all())
}
fn settled(view: &MarkView) -> bool {
@@ -112,11 +132,11 @@ impl Engine for Mark {
}
fn edited(view: &MarkView) -> u64 {
view.changed()
view.changed
}
fn edit_pending(_: &MarkView) -> bool {
false
fn edit_pending(view: &MarkView) -> bool {
!view.settled()
}
fn focus(view: &MarkView) -> FocusHandle {
@@ -137,11 +157,6 @@ impl Engine for Mark {
failed(host, &error.0, cx)
})
.detach();
cx.subscribe(view, |host, _, refused: &Refused, cx| {
let said = crate::blocks::refusal(refused.0, cx);
host.toast(said, ely_gpui_component::primitives::Severity::Info, cx)
})
.detach();
cx.subscribe(view, move |host, view, _: &Reported, cx| {
let modified = view.read(cx).modified();
reported(host, &view, modified, cx)
@@ -156,22 +171,30 @@ mod tests {
use ely_office::Engine as _;
use super::{Mark, Text, mark};
use super::{Mark, Pages, Text, mark};
use crate::text::Source;
#[test]
fn a_save_marks_the_text_it_wrote() {
let dir = tempfile::tempdir().expect("a temp dir");
let source = Source::decode(b"Alpha.\n".to_vec()).expect("reads");
let doc = Arc::new(Mutex::new(Text::new(source)));
let current = source.text.clone();
let doc = Arc::new(Mutex::new(Text {
source,
current,
written: None,
}));
let mut pages = Pages {
dir: dir.path().to_path_buf(),
};
let path = dir.path().join("save.md");
Mark::write(&mut (), &doc, &path, "md").expect("writes");
Mark::write(&mut pages, &doc, &path, "md").expect("writes");
// An edit lands between the write and the history read.
doc.lock().expect("the text lock").current = "xAlpha.\n".into();
let saved = Mark::history(&mut (), &doc).expect("a mark");
let saved = Mark::history(&mut pages, &doc).expect("a mark");
assert_eq!(saved, mark("Alpha.\n"));
assert_eq!(
Mark::history(&mut (), &doc).expect("a mark"),
Mark::history(&mut pages, &doc).expect("a mark"),
mark("xAlpha.\n")
);
}
+474
View File
@@ -0,0 +1,474 @@
//! Export and print: HTML, linked HTML, PDF, text (MARK-11).
use std::{
io,
path::{Path, PathBuf},
};
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
overlays::Dialog,
primitives::Severity,
};
use ely_office::{cancel, files::file_name, in_window, prelude::*};
use ely_ui::{commands, i18n::text};
use ely_web_content::{Event, WebView};
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement as _, ParentElement as _,
Subscription, Window,
};
use serde_json::Value;
use crate::desk::Desk;
use crate::paper;
/// What an export writes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Output {
Page,
Linked,
Pdf,
Text,
}
impl Output {
const ALL: [Output; 4] = [Output::Page, Output::Linked, Output::Pdf, Output::Text];
fn key(self) -> &'static str {
match self {
Output::Page => "page",
Output::Linked => "linked",
Output::Pdf => "pdf",
Output::Text => "text",
}
}
fn extension(self) -> &'static str {
match self {
Output::Page | Output::Linked => "html",
Output::Pdf => "pdf",
Output::Text => "txt",
}
}
}
/// What an export lacks: images, diagrams, formulas.
#[derive(Clone, Debug, Default)]
pub struct Lost {
images: Vec<String>,
diagrams: Vec<u64>,
/// Each broken formula's line and KaTeX's message.
formulas: Vec<(u64, String)>,
}
impl Lost {
fn of(images: Vec<String>, rendered: &Value) -> Lost {
let diagrams = rendered["failed"].as_array().into_iter().flatten();
let formulas = rendered["formulas"].as_array().into_iter().flatten();
Lost {
images,
diagrams: diagrams.filter_map(Value::as_u64).collect(),
formulas: formulas
.filter_map(|f| Some((f["line"].as_u64()?, f["error"].as_str()?.to_owned())))
.collect(),
}
}
fn whole(&self) -> bool {
self.images.is_empty() && self.diagrams.is_empty() && self.formulas.is_empty()
}
}
/// No export lands on a file a tab holds (FILE-06).
fn not_open(desk: &Desk, path: &Path) -> io::Result<()> {
match desk.holder(path)? {
Some(_) => Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} is open in ElyMark", file_name(path)),
)),
None => Ok(()),
}
}
/// A page printing to PDF in a view nobody sees.
pub struct Printing {
_view: Entity<WebView>,
_heard: Subscription,
}
impl Desk {
pub fn on_export(&mut self, _: &commands::Export, _: &mut Window, cx: &mut Context<Self>) {
if self.active_tab().is_some() {
self.output = Some(Output::Page);
cx.notify();
}
}
pub fn on_print(&mut self, _: &commands::Print, _: &mut Window, cx: &mut Context<Self>) {
let Some(id) = self.active_tab().map(|tab| tab.id) else {
return;
};
let pdf = self.core.work.join(format!("print-{id}.pdf"));
self.render_pdf(id, pdf, None, cx);
}
/// Asks where, renders the whole text, then writes it.
fn export_to(&mut self, output: Output, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
return;
};
let id = tab.id;
let stem = tab
.name
.rsplit_once('.')
.map_or(tab.name.as_ref(), |(stem, _)| stem)
.to_owned();
let dir = ely_office::folder(tab).unwrap_or_else(std::env::temp_dir);
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{}", output.extension())));
cx.spawn(async move |desk, cx| {
let Ok(Ok(Some(path))) = chosen.await else {
return log::info!("mark: the export panel closed without a path");
};
desk.update(cx, |desk, cx| {
if let Err(error) = not_open(desk, &path) {
return desk.exported(Err(error), cx);
}
match output {
Output::Pdf => desk.render_pdf(
id,
desk.core.work.join(format!("export-{id}.pdf")),
Some(path),
cx,
),
_ => desk.write_output(id, output, path, cx),
}
})
.ok();
})
.detach();
}
/// HTML or text, assembled and written off the main thread.
fn write_output(&mut self, id: u64, output: Output, path: PathBuf, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id) else {
return;
};
let (view, folder) = (tab.view.clone(), tab.view.read(cx).folder.clone());
let open = tab.session.path().map(Path::to_path_buf);
let style = self.pages_dir().join("export.css");
let kind = if output == Output::Text {
"text"
} else {
"html"
};
let desk = cx.weak_entity();
view.update(cx, |view, cx| {
view.render(
kind,
move |rendered, cx: &mut App| {
// Checked again: tabs may have opened meanwhile.
let free = desk.update(cx, |desk, _| not_open(desk, &path));
let free = free.map_err(io::Error::other).and_then(|free| free);
let job = cx.background_spawn(async move {
free?;
let rendered = rendered.map_err(io::Error::other)?;
write(output, &rendered, &folder, &style, &path, open.as_deref())
});
cx.spawn(async move |cx| {
let written = job.await;
desk.update(cx, |desk, cx| desk.exported(written, cx)).ok();
})
.detach();
},
cx,
)
});
}
fn exported(&mut self, written: io::Result<(PathBuf, Lost)>, cx: &mut Context<Self>) {
match written {
Ok((path, lost)) if lost.whole() => {
log::info!("mark: exported {}", path.display());
let said = text(cx, "mark.export.done", &[("name", &file_name(&path))]);
self.toast_output(said, path, cx);
}
Ok((path, lost)) => {
// Counts only: image names come from the text.
log::warn!(
"mark: exported {} without {} images and {} diagrams; formulas failed at lines {:?}",
path.display(),
lost.images.len(),
lost.diagrams.len(),
lost.formulas.iter().map(|(line, _)| line).collect::<Vec<_>>()
);
self.warn_lost(&lost, Some(path), cx);
}
Err(error) => {
log::error!("mark: an export failed: {error}");
let said = text(cx, "mark.export.failed", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
}
}
/// Says which images, diagrams and formulas an output lacks.
fn warn_lost(&mut self, lost: &Lost, output: Option<PathBuf>, cx: &mut Context<Self>) {
let mut said = Vec::new();
if !lost.images.is_empty() {
said.push(text(
cx,
"mark.export.missing",
&[("list", &lost.images.join(", "))],
));
}
if !lost.diagrams.is_empty() {
said.push(text(
cx,
"mark.export.diagrams",
&[(
"list",
&lost
.diagrams
.iter()
.map(u64::to_string)
.collect::<Vec<_>>()
.join(", "),
)],
));
}
for (line, error) in &lost.formulas {
let line = line.to_string();
said.push(text(
cx,
"mark.export.formula",
&[("line", &line), ("error", error)],
));
}
// Names and messages quote the text: no log.
let said: gpui::SharedString = said.join(" ").into();
match (output, self.core.root.upgrade()) {
(Some(path), Some(root)) => root.update(cx, |root, cx| root.toast_output(said, Severity::Warning, path, cx)),
_ => self.toast_unlogged(said, Severity::Warning, cx),
}
}
fn pages_dir(&self) -> PathBuf {
self.core.worker.lock().expect("the pages lock").dir.clone()
}
/// The whole text as PDF; then to `to`, else printed.
fn render_pdf(&mut self, id: u64, pdf: PathBuf, to: Option<PathBuf>, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id) else {
return;
};
let (view, folder, name) = (
tab.view.clone(),
tab.view.read(cx).folder.clone(),
tab.name.to_string(),
);
let open = tab.session.path().map(Path::to_path_buf);
let (style, work) = (self.pages_dir().join("export.css"), self.core.work.clone());
let desk = cx.weak_entity();
view.update(cx, |view, cx| {
view.render(
"html",
move |rendered, cx: &mut App| {
let staged = work.join(format!("print-{id}.html"));
let page = rendered.map_err(io::Error::other).and_then(|rendered| {
let (html, images) = paper::Marked::read(&rendered)?.inline(&folder);
let style = format!("<style>{}</style>", std::fs::read_to_string(&style)?);
std::fs::write(&staged, paper::page(&name, &style, &html))?;
Ok(Lost::of(images, &rendered))
});
cx.spawn(async move |cx| {
in_window(&desk, cx, |desk, window, cx| match page {
Ok(lost) => {
let job = Job {
pdf,
to,
open,
lost,
};
desk.print_page(&staged, job, window, cx)
}
Err(error) => desk.exported(Err(error), cx),
})
.ok();
})
.detach();
},
cx,
)
});
}
/// Chromium prints the staged page; the PDF goes where asked.
fn print_page(&mut self, page: &Path, job: Job, window: &mut Window, cx: &mut Context<Self>) {
let root = page
.parent()
.expect("a staged page sits in a folder")
.to_path_buf();
let view = match WebView::open(page, &root, window, cx) {
Ok(view) => view,
Err(error) => return self.exported(Err(io::Error::other(error)), cx),
};
let heard = cx.subscribe(&view, move |desk, view, event: &Event, cx| match event {
Event::Loaded => view.read(cx).print_pdf(&job.pdf),
Event::Printed(path, ok) => {
desk.printing = None;
let printed = ok
.then(|| path.clone())
.ok_or_else(|| io::Error::other("Chromium did not print the page"));
desk.printed(printed, job.clone(), cx);
}
Event::Failed(why) => {
desk.printing = None;
desk.exported(Err(io::Error::other(why.clone())), cx);
}
Event::Query(_, reply) => reply.ok(""),
Event::Leave(_) => {}
});
self.printing = Some(Printing {
_view: view,
_heard: heard,
});
}
fn printed(&mut self, pdf: io::Result<PathBuf>, job: Job, cx: &mut Context<Self>) {
let pdf = match pdf {
Ok(pdf) => pdf,
Err(error) => return self.exported(Err(error), cx),
};
let Some(to) = job.to else {
if !job.lost.whole() {
self.warn_lost(&job.lost, None, cx);
}
return ely_office::print_later(pdf, cx, |desk, why, cx| {
desk.exported(Err(io::Error::other(why)), cx)
});
};
// Never over the open file, not even through a link.
let moved = not_open(self, &to)
.and_then(|()| std::fs::read(&pdf))
.and_then(|bytes| ely_files::export(&to, job.open.as_deref(), &bytes, 0).map(|_| ()));
let _ = std::fs::remove_file(&pdf);
self.exported(moved.map(|()| (to, job.lost)), cx);
}
pub fn export_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let chosen = self.output?;
let this = cx.entity();
let kinds = Output::ALL.into_iter().fold(
SegmentedControl::new("mark-export-kind", chosen.key()),
|control, output| {
control.segment(
output.key(),
text(cx, &format!("mark.export.{}", output.key()), &[]),
None,
)
},
);
let kinds = kinds.on_change({
let this = this.clone();
move |key, _, cx| {
let picked = Output::ALL
.into_iter()
.find(|output| key.as_ref() == output.key());
this.update(cx, |desk, cx| {
desk.output = picked;
cx.notify();
})
}
});
let close = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |desk, cx| {
desk.output = None;
cx.notify();
})
}
};
let label = text(cx, "mark.export.go", &[]);
let dialog = Dialog::new("mark-export", text(cx, "mark.export", &[]), close)
.child(kinds)
.action(cancel(cx))
.action(move |close| {
Button::new("mark-export-go", label)
.variant(ButtonVariant::Primary)
.on_click(move |_, window, cx| {
close(window, cx);
this.update(cx, |desk, cx| desk.export_to(chosen, cx));
})
});
Some(dialog.into_any_element())
}
}
/// A PDF in flight: where printed, where bound, what lacks.
#[derive(Clone)]
struct Job {
pdf: PathBuf,
to: Option<PathBuf>,
/// The tab's own file, never overwritten.
open: Option<PathBuf>,
lost: Lost,
}
/// Writes `output` at `path`; and what it left out.
fn write(
output: Output,
rendered: &Value,
folder: &Path,
style: &Path,
path: &Path,
open: Option<&Path>,
) -> io::Result<(PathBuf, Lost)> {
let title = path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_default();
let (bytes, missing) = match output {
Output::Text => (
rendered["text"]
.as_str()
.unwrap_or_default()
.as_bytes()
.to_vec(),
Vec::new(),
),
Output::Page => {
let (html, missing) = paper::Marked::read(rendered)?.inline(folder);
let style = format!("<style>{}</style>", std::fs::read_to_string(style)?);
(paper::page(&title, &style, &html).into_bytes(), missing)
}
Output::Linked => {
let assets = format!("{title}_files");
let place = path.with_file_name(&assets);
let (html, copies, missing) = paper::Marked::read(rendered)?.linked(folder, &assets);
// A folder already there is never written into.
std::fs::create_dir(&place)?;
let copied = copies
.iter()
.try_for_each(|(from, name)| std::fs::copy(from, place.join(name)).map(|_| ()))
.and_then(|()| std::fs::copy(style, place.join("export.css")).map(|_| ()));
if let Err(error) = copied {
let _ = std::fs::remove_dir_all(&place);
return Err(error);
}
let style = format!(
"<link rel=\"stylesheet\" href=\"{}/export.css\">",
paper::segment(&assets)
);
(paper::page(&title, &style, &html).into_bytes(), missing)
}
Output::Pdf => unreachable!("PDF prints in a page"),
};
if let Err(error) = ely_files::export(path, open, &bytes, 0) {
if output == Output::Linked {
let _ = std::fs::remove_dir_all(path.with_file_name(format!("{title}_files")));
}
return Err(error);
}
Ok((path.to_path_buf(), Lost::of(missing, rendered)))
}
-136
View File
@@ -1,136 +0,0 @@
//! Rich, HTML code and plain text copies (R4.10).
use std::path::PathBuf;
use ely_gpui_component::primitives::Severity;
use ely_office::prelude::*;
use ely_platform::clipboard::{self, Clip};
use ely_ui::i18n::text;
use gpui::{ClipboardItem, Context, Div, InteractiveElement as _, actions};
use crate::desk::Desk;
use crate::output::{self, Page, Pictures};
actions!(mark, [CopyRich, CopyHtml, CopyPlain]);
/// What a copy turns the Markdown into.
#[derive(Clone, Copy)]
enum As {
Rich,
Html,
Plain,
}
/// A converted copy, ready for the clipboard.
enum Copied {
Rich(Clip, Page),
Html(String, Page),
Plain(String),
}
impl Desk {
pub(super) fn copy_actions(root: Div, cx: &mut Context<Self>) -> Div {
root.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy(As::Rich, cx)))
.on_action(cx.listener(|desk, _: &CopyHtml, _, cx| desk.copy(As::Html, cx)))
.on_action(cx.listener(|desk, _: &CopyPlain, _, cx| desk.copy(As::Plain, cx)))
}
/// The selection or whole text, and the file's folder.
fn copied_text(&mut self, cx: &mut Context<Self>) -> Option<(String, Option<PathBuf>)> {
let Some(view) = self.active_view() else {
self.no_document(cx);
return None;
};
let document = view.read(cx).document().read(cx);
let selected = document.selected(cx);
let markdown = match selected.is_empty() {
true => document.text(cx),
false => selected,
};
let beside = (self.active_tab())
.and_then(|tab| tab.session.path())
.and_then(|path| path.parent())
.map(PathBuf::from);
Some((markdown, beside))
}
fn copy_failed(&mut self, error: &std::io::Error, cx: &mut Context<Self>) {
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
/// Converts in the background, then writes the clipboard.
fn copy(&mut self, kind: As, cx: &mut Context<Self>) {
let Some((markdown, beside)) = self.copied_text(cx) else {
return;
};
let before = witness(cx);
self.copying = Some(cx.spawn(async move |desk, cx| {
let converted = cx
.background_executor()
.spawn(async move { convert(kind, markdown, beside) })
.await;
let landed = desk.update(cx, |desk, cx| {
desk.copying = None;
// A later copy, here or elsewhere, wins.
if witness(cx) != before {
return log::info!("mark: the clipboard changed first; a copy is dropped");
}
desk.copied(converted, cx);
});
if landed.is_err() {
log::warn!("mark: a copy finished after its window closed");
}
}));
}
fn copied(&mut self, converted: std::io::Result<Copied>, cx: &mut Context<Self>) {
let written = converted.and_then(|copied| match copied {
Copied::Rich(clip, page) => clipboard::write(&clip)
.map(|()| Some(page))
.map_err(|error| std::io::Error::other(format!("{error:#}"))),
Copied::Html(html, page) => {
log::info!("mark: {} bytes copied as HTML code", html.len());
cx.write_to_clipboard(ClipboardItem::new_string(html));
Ok(Some(page))
}
Copied::Plain(plain) => {
log::info!("mark: {} bytes copied as plain text", plain.len());
cx.write_to_clipboard(ClipboardItem::new_string(plain));
Ok(None)
}
});
match written {
Ok(Some(page)) => self.left_out(&page, cx),
Ok(None) => {}
Err(error) => self.copy_failed(&error, cx),
}
}
}
/// What the clipboard holds, its HTML included.
fn witness(cx: &mut Context<Desk>) -> (Option<ClipboardItem>, Result<Option<String>, String>) {
let html = clipboard::html().map_err(|error| {
log::warn!("mark: the clipboard's HTML did not read: {error:#}");
format!("{error:#}")
});
(cx.read_from_clipboard(), html)
}
/// The copy as `kind`, converted off the window thread.
fn convert(kind: As, markdown: String, beside: Option<PathBuf>) -> std::io::Result<Copied> {
let beside = beside.as_deref();
match kind {
As::Rich => output::body(&markdown, beside, &Pictures::Inline).map(|mut page| {
let html = std::mem::take(&mut page.html);
let clip = Clip::Html {
html,
text: markdown,
};
Copied::Rich(clip, page)
}),
As::Html => output::body(&markdown, beside, &Pictures::Written)
.map(|mut page| Copied::Html(std::mem::take(&mut page.html), page)),
As::Plain => output::plain(&markdown).map(Copied::Plain),
}
}
-289
View File
@@ -1,289 +0,0 @@
//! Export, print and rich copy (MARK-11).
use std::path::PathBuf;
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
overlays::Dialog,
primitives::Severity,
theme::ControlSize,
};
use ely_office::prelude::*;
use ely_ui::{
commands::{Export, Print},
i18n::text,
};
use gpui::{
AnyElement, Context, Div, InteractiveElement, IntoElement, ParentElement as _, TaskExt as _,
};
use crate::desk::Desk;
use crate::output::{self, Format, Page, Target};
mod copy;
pub use copy::{CopyHtml, CopyPlain, CopyRich};
const FORMATS: [(Format, &str, &str); 4] = [
(Format::Page, "page", "html"),
(Format::Linked, "linked", "html"),
(Format::Pdf, "pdf", "pdf"),
(Format::Text, "text", "txt"),
];
fn key(format: Format) -> &'static str {
FORMATS
.iter()
.find(|(each, ..)| *each == format)
.expect("listed")
.1
}
impl Desk {
pub fn output_actions(root: Div, cx: &mut Context<Self>) -> Div {
let root = root
.on_action(cx.listener(|desk, _: &Export, _, cx| desk.ask_export(cx)))
.on_action(cx.listener(|desk, _: &Print, _, cx| desk.print(cx)));
Self::copy_actions(root, cx)
}
pub(crate) fn no_document(&self, cx: &mut Context<Self>) {
let said = text(cx, "command.blocked.no_document", &[]);
self.toast(said, Severity::Warning, cx);
}
fn ask_export(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.no_document(cx);
};
self.exporting = Some((tab, Format::Page));
cx.notify();
}
pub fn export_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let (_, format) = self.exporting?;
let (picking, going, closing) = (cx.entity(), cx.entity(), cx.entity());
let formats = FORMATS
.iter()
.fold(
SegmentedControl::new("mark-export-format", key(format)),
|control, (_, value, _)| {
control.segment(*value, text(cx, &format!("mark.export.{value}"), &[]), None)
},
)
.size(ControlSize::Sm)
.on_change(move |value, _, cx| {
let Some((chosen, ..)) =
FORMATS.iter().find(|(_, each, _)| *each == value.as_ref())
else {
return;
};
let chosen = *chosen;
picking.update(cx, |desk, cx| {
if let Some((_, format)) = &mut desk.exporting {
*format = chosen;
}
cx.notify();
})
});
let go = text(cx, "mark.export.go", &[]);
let card = Dialog::new("mark-export", text(cx, "mark.export", &[]), move |_, cx| {
closing.update(cx, |desk, cx| {
desk.exporting = None;
cx.notify();
})
})
.child(formats)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("mark-export-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| going.update(cx, |desk, cx| desk.export_asked(cx)))
});
Some(card.into_any_element())
}
/// Asks where, then writes the chosen format.
fn export_asked(&mut self, cx: &mut Context<Self>) {
let Some((tab, format)) = self.exporting.take() else {
return;
};
cx.notify();
let Some(source) = self.tab(tab) else {
return;
};
let Some(dir) = ely_office::folder(source) else {
return self.export_failed("no documents folder", cx);
};
let stem = source
.name
.rsplit_once('.')
.map_or(source.name.as_ref(), |(stem, _)| stem);
let extension = FORMATS
.iter()
.find(|(each, ..)| *each == format)
.expect("listed")
.2;
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{extension}")));
cx.spawn(async move |desk, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
desk.update(cx, |desk, cx| match chosen {
Ok(Some(path)) => desk.export_to(tab, path, format, cx),
Ok(None) => log::info!("mark: the export panel was cancelled"),
Err(error) => desk.export_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
pub fn export_to(&mut self, tab: u64, path: PathBuf, format: Format, cx: &mut Context<Self>) {
if !self.settled(tab, cx) {
return self.when_settled(tab, move |desk, _, cx| {
desk.export_to(tab, path, format, cx)
});
}
let (Some(source), Some(markdown)) = (self.tab(tab), self.text_of(tab)) else {
return;
};
match self.holder(&path) {
Ok(None) => {}
Ok(Some(_)) => return self.export_failed("an open document is there", cx),
Err(error) => return self.export_failed(&error.to_string(), cx),
}
let session = source.session.path().map(PathBuf::from);
let beside = session
.as_deref()
.and_then(|path| path.parent())
.map(PathBuf::from);
let (revision, work) = (source.session.revision(), source.work.clone());
let opened = path.clone();
cx.spawn(async move |desk, cx| {
let written = cx
.background_executor()
.spawn(async move {
let target = Target {
path: &path,
session: session.as_deref(),
revision,
work: &work,
};
output::write(format, &markdown, beside.as_deref(), &target)
})
.await;
desk.update(cx, |desk, cx| match written {
Ok(page) => {
let name = ely_office::files::file_name(&opened);
desk.toast_output(text(cx, "mark.export.done", &[("name", &name)]), opened, cx);
desk.left_out(&page, cx);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let said = text(
cx,
"mark.export.folder_taken",
&[("name", &error.to_string())],
);
desk.toast(said, Severity::Danger, cx);
}
Err(error) => desk.export_failed(&error.to_string(), cx),
})
})
.detach_and_log_err(cx);
}
/// Says what an export could not carry.
fn left_out(&self, page: &Page, cx: &mut Context<Self>) {
// Names and errors quote the document: not logged.
if !page.missing.is_empty() {
let said = text(
cx,
"mark.export.missing",
&[("list", &page.missing.join(", "))],
);
self.toast_unlogged(said, Severity::Warning, cx);
}
if !page.diagrams.is_empty() {
let lines: Vec<String> = page.diagrams.iter().map(usize::to_string).collect();
let said = text(cx, "mark.export.diagrams", &[("list", &lines.join(", "))]);
self.toast(said, Severity::Warning, cx);
}
if !page.formulas.is_empty() {
let each = page.formulas.iter().map(|(line, error)| {
text(
cx,
"mark.export.formula",
&[("line", &line.to_string()), ("error", error)],
)
.to_string()
});
let said = text(
cx,
"mark.export.formulas",
&[("list", &each.collect::<Vec<_>>().join("; "))],
);
self.toast_unlogged(said, Severity::Warning, cx);
}
}
fn export_failed(&self, error: &str, cx: &mut Context<Self>) {
let said = text(cx, "mark.export.failed", &[("error", error)]);
self.toast(said, Severity::Danger, cx);
}
/// Prints the PDF export through the system panel.
fn print(&mut self, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.no_document(cx);
};
if !self.settled(tab, cx) {
return self.when_settled(tab, |desk, _, cx| desk.print(cx));
}
let (Some(source), Some(markdown)) = (self.tab(tab), self.text_of(tab)) else {
return;
};
let beside = source
.session
.path()
.and_then(|path| path.parent())
.map(PathBuf::from);
let (revision, work) = (source.session.revision(), source.work.clone());
let pdf = print_copy(&work);
log::info!("mark: prints tab {tab}");
cx.spawn(async move |desk, cx| {
let written = cx
.background_executor()
.spawn(async move {
let target = Target {
path: &pdf,
session: None,
revision,
work: &work,
};
output::write(Format::Pdf, &markdown, beside.as_deref(), &target)
.map(|page| (page, pdf))
})
.await;
desk.update(cx, |desk, cx| match written {
Ok((page, pdf)) => {
desk.left_out(&page, cx);
ely_office::print_later(pdf, cx);
}
Err(error) => desk.print_failed(&error.to_string(), cx),
})
})
.detach_and_log_err(cx);
}
}
/// A file of its own for each print job.
fn print_copy(work: &std::path::Path) -> PathBuf {
static JOBS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let job = JOBS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
work.join(format!("print-{job}.pdf"))
}
#[cfg(test)]
mod tests;
-95
View File
@@ -1,95 +0,0 @@
use gpui::TestAppContext;
use crate::desk::tests::{open, start};
use crate::output::Format;
#[gpui::test]
fn export_asks_for_a_format_then_writes_it(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("note.md");
std::fs::write(&path, "# Note\n\nText $x$.\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
cx.dispatch_action(ely_ui::commands::Export);
cx.run_until_parked();
let (tab, format) = desk.read_with(cx, |desk, _| desk.exporting.expect("the dialog"));
assert_eq!(format, Format::Page);
let out = files.path().join("note.html");
desk.update(cx, |desk, cx| {
desk.exporting = None;
desk.export_to(tab, out.clone(), Format::Page, cx)
});
cx.run_until_parked();
let html = std::fs::read_to_string(&out).expect("written");
assert!(
html.contains("<h1 id=\"h-0\">Note</h1>") && html.contains("<svg"),
"{html}"
);
}
#[gpui::test]
fn export_never_writes_over_the_open_file(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("note.md");
std::fs::write(&path, "# Note\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let tab = desk.read_with(cx, |desk, _| desk.core.active.expect("a tab"));
desk.update(cx, |desk, cx| {
desk.export_to(tab, path.clone(), Format::Text, cx)
});
cx.run_until_parked();
assert_eq!(std::fs::read_to_string(&path).expect("reads"), "# Note\n");
}
#[gpui::test]
fn rich_copy_takes_the_selection_as_html(cx: &mut TestAppContext) {
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
return;
}
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("note.md");
std::fs::write(&path, "# Note\n\n**Bold** words.\n").expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
view.update_in(cx, |view, window, cx| {
view.switch(crate::view::Mode::Source, window, cx)
});
cx.run_until_parked();
let document = view.read_with(cx, |view, _| view.document().clone());
document.update(cx, |document, cx| {
document
.editor()
.update(cx, |editor, cx| editor.select(std::iter::once(8..22), cx))
});
cx.dispatch_action(crate::export::CopyRich);
cx.run_until_parked();
let clip = ely_platform::clipboard::read()
.expect("reads")
.expect("a clip");
let ely_platform::clipboard::Clip::Html { html, text } = clip else {
panic!("not HTML: {clip:?}");
};
assert_eq!(text, "**Bold** words");
assert!(
html.contains("<strong>Bold</strong>") && !html.contains("Note"),
"{html}"
);
}
#[test]
fn each_print_job_writes_its_own_file() {
let work = std::path::Path::new("work");
assert_ne!(super::print_copy(work), super::print_copy(work));
}
+114 -187
View File
@@ -1,229 +1,179 @@
//! Find and replace in the shown file (MARK-06).
use std::ops::Range;
//! Find and replace over the shown editor (MARK-06).
use ely_gpui_component::primitives::raise;
use ely_gpui_component::{
editor::{FindOptions, FindWidget, find_all},
editor::{FindOptions, FindWidget},
forms::{InputEvent, TextInput},
primitives::{Severity, raise},
theme::ActiveTheme as _,
};
use ely_office::prelude::*;
use ely_ui::i18n::text;
use gpui::{
AnyElement, AppContext as _, Context, Entity, Focusable as _, InteractiveElement as _,
IntoElement, ParentElement as _, SharedString, Styled as _, Subscription, Window, div,
IntoElement as _, ParentElement as _, Styled as _, Subscription, Window, actions, div,
};
use serde_json::json;
use crate::desk::Desk;
use crate::view::MarkView;
/// The find box over one tab, and what it found.
pub struct Find {
tab: u64,
actions!(mark, [Find]);
/// Where the shown editor's search stands.
#[derive(Clone, Debug, PartialEq)]
pub struct Found {
pub current: Option<usize>,
pub total: usize,
/// A pattern that does not compile.
pub error: Option<String>,
}
pub struct Finder {
query: Entity<TextInput>,
replace: Option<Entity<TextInput>>,
with: Entity<TextInput>,
options: FindOptions,
hits: Vec<Range<usize>>,
current: Option<usize>,
/// The pattern did not compile; why.
error: Option<SharedString>,
_query: Subscription,
replacing: bool,
/// The tab last searched; another one is searched anew.
tab: Option<u64>,
_follow: Subscription,
}
impl Desk {
pub fn on_find(
&mut self,
_: &ely_office::FindReplace,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(tab) = self.core.active else {
let said = text(cx, "command.blocked.no_document", &[]);
return self.toast(said, Severity::Warning, cx);
};
if let Some(find) = self.finding.as_ref().filter(|find| find.tab == tab) {
return window.focus(&find.query.focus_handle(cx), cx);
/// Opens the find box, or takes focus to it.
pub fn on_find(&mut self, _: &Find, window: &mut Window, cx: &mut Context<Self>) {
if let Some(finder) = &self.finder {
let focus = finder.query.focus_handle(cx);
return window.focus(&focus, cx);
}
self.close_find(window, cx);
let query = cx.new(|cx| TextInput::new(window, cx));
let subscription =
cx.subscribe_in(&query, window, |desk, _, event, window, cx| match event {
InputEvent::Changed => desk.count(cx),
InputEvent::Submit => desk.step(true, window, cx),
_ => {}
});
window.focus(&query.focus_handle(cx), cx);
log::info!("mark: find opens on tab {tab}");
self.finding = Some(Find {
tab,
query,
replace: None,
options: FindOptions::default(),
hits: Vec::new(),
current: None,
error: None,
_query: subscription,
let with = cx.new(|cx| TextInput::new(window, cx));
cx.subscribe(&query, |desk, _, event: &InputEvent, cx| match event {
InputEvent::Changed => desk.seek(cx),
InputEvent::Submit => desk.step(true, cx),
_ => {}
})
.detach();
cx.subscribe(&with, |desk, _, event: &InputEvent, cx| match event {
InputEvent::Changed => desk.seek(cx),
InputEvent::Submit => desk.replace(false, cx),
_ => {}
})
.detach();
let focus = query.focus_handle(cx);
let follow = cx.observe_self(|desk, cx| {
let shown = desk.active_tab().map(|tab| tab.id);
if desk
.finder
.as_ref()
.is_some_and(|finder| finder.tab != shown)
{
desk.seek(cx);
}
});
self.finder = Some(Finder {
query,
with,
options: FindOptions::default(),
replacing: false,
tab: None,
_follow: follow,
});
window.focus(&focus, cx);
cx.notify();
}
fn found_view(&self) -> Option<(Entity<MarkView>, &Find)> {
let find = self.finding.as_ref()?;
Some((self.tab(find.tab)?.view.clone(), find))
/// The query and options as the page's search spec.
fn spec(&self, cx: &Context<Self>) -> Option<serde_json::Value> {
let finder = self.finder.as_ref()?;
Some(json!({
"search": finder.query.read(cx).text(),
"replace": finder.with.read(cx).text(),
"caseSensitive": finder.options.case,
"regexp": finder.options.regex,
"wholeWord": finder.options.word,
}))
}
/// Matches again in the tab's text, marked in source.
pub fn count(&mut self, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let query = find.query.read(cx).text().to_string();
let options = find.options;
let document = view.read(cx).document().clone();
let found = find_all(&document.read(cx).text(cx), &query, options);
let Some(find) = self.finding.as_mut() else {
return;
};
(find.hits, find.error) = match found {
Ok(hits) => (hits, None),
Err(why) => (Vec::new(), Some(why.into())),
};
find.current = find.current.filter(|current| *current < find.hits.len());
let (hits, current) = (find.hits.clone(), find.current);
mark(&document, &hits, current, cx);
cx.notify();
fn seek(&mut self, cx: &mut Context<Self>) {
let shown = self.active_tab().map(|tab| tab.id);
if let Some(finder) = self.finder.as_mut() {
finder.tab = shown;
}
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
view.update(cx, |view, cx| view.find(spec, cx));
}
}
fn step(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let count = find.hits.len();
if count == 0 {
return;
fn step(&mut self, forward: bool, cx: &mut Context<Self>) {
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
view.update(cx, |view, cx| view.step(spec, forward, cx));
}
let current = match (find.current, forward) {
(None, true) => 0,
(None, false) => count - 1,
(Some(at), true) => (at + 1) % count,
(Some(at), false) => (at + count - 1) % count,
};
let range = find.hits[current].clone();
let hits = find.hits.clone();
if let Some(find) = self.finding.as_mut() {
find.current = Some(current);
}
let document = view.read(cx).document().clone();
mark(&document, &hits, Some(current), cx);
document.update(cx, |document, cx| document.show_match(range, window, cx));
cx.notify();
}
fn replace(&mut self, every: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some((view, find)) = self.found_view() else {
return;
};
let with = find
.replace
.as_ref()
.map_or(String::new(), |field| field.read(cx).text().to_string());
let edits: Vec<(Range<usize>, String)> = match (every, find.current) {
(true, _) => find
.hits
.iter()
.map(|hit| (hit.clone(), with.clone()))
.collect(),
(false, Some(at)) => vec![(find.hits[at].clone(), with)],
(false, None) => return self.step(true, window, cx),
};
if edits.is_empty() {
return;
fn replace(&mut self, all: bool, cx: &mut Context<Self>) {
if let (Some(spec), Some(view)) = (self.spec(cx), self.active_view()) {
view.update(cx, |view, cx| view.replace(spec, all, cx));
}
// The next match starts past the replaced text.
let after = edits[0].0.start + edits[0].1.len();
let document = view.read(cx).document().clone();
if !document.update(cx, |document, cx| document.replace(edits, cx)) {
let said = text(cx, "mark.preview.read_only", &[]);
return self.toast(said, Severity::Info, cx);
}
self.count(cx);
if every {
return;
}
let Some(find) = self.finding.as_mut() else {
return;
};
let next = find.hits.iter().position(|hit| hit.start >= after);
find.current = next.or((!find.hits.is_empty()).then_some(0));
let Some(range) = find.current.map(|at| find.hits[at].clone()) else {
return;
};
let hits = find.hits.clone();
mark(&document, &hits, find.current, cx);
document.update(cx, |document, cx| document.show_match(range, window, cx));
}
fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(find) = self.finding.take() else {
return;
};
if let Some(tab) = self.tab(find.tab) {
let document = tab.view.read(cx).document().clone();
mark(&document, &[], None, cx);
window.focus(&tab.view.read(cx).focus.clone(), cx);
self.finder = None;
if let Some(view) = self.active_view() {
view.update(cx, |view, cx| view.unfind(cx));
let focus = view.read(cx).focus.clone();
window.focus(&focus, cx);
}
cx.notify();
}
/// The find box over the file, while open.
pub fn find_box(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let find = self
.finding
pub fn find_bar(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let finder = self.finder.as_ref()?;
let found = self
.active_view()
.and_then(|view| view.read(cx).found.clone());
let (current, total) = found
.as_ref()
.filter(|find| Some(find.tab) == self.core.active)?;
.map_or((None, 0), |found| (found.current, found.total));
let this = cx.entity();
let mut widget = FindWidget::new("mark-find", &find.query, find.current, find.hits.len())
.options(find.options)
let widget = FindWidget::new("mark-find", &finder.query, current, total)
.options(finder.options)
.on_options({
let this = this.clone();
move |options, _, cx| {
this.update(cx, |desk, cx| {
if let Some(find) = desk.finding.as_mut() {
find.options = options;
if let Some(finder) = desk.finder.as_mut() {
finder.options = options;
}
desk.count(cx);
desk.seek(cx);
})
}
})
.on_step({
let this = this.clone();
move |forward, window, cx| {
this.update(cx, |desk, cx| desk.step(forward, window, cx))
}
move |forward, _, cx| this.update(cx, |desk, cx| desk.step(forward, cx))
})
.on_replace({
let this = this.clone();
move |every, window, cx| this.update(cx, |desk, cx| desk.replace(every, window, cx))
move |all, _, cx| this.update(cx, |desk, cx| desk.replace(all, cx))
})
.on_toggle_replace({
let this = this.clone();
move |show, window, cx| {
move |open, _, cx| {
this.update(cx, |desk, cx| {
if let Some(find) = desk.finding.as_mut() {
find.replace = show.then(|| cx.new(|cx| TextInput::new(window, cx)));
if let Some(finder) = desk.finder.as_mut() {
finder.replacing = open;
}
cx.notify();
})
}
})
.on_close(move |window, cx| this.update(cx, |desk, cx| desk.close_find(window, cx)));
if let Some(field) = &find.replace {
widget = widget.replace(field);
}
if let Some(error) = &find.error {
widget = widget.error(error.clone());
}
let widget = match finder.replacing {
true => widget.replace(&finder.with),
false => widget,
};
let widget = match found.and_then(|found| found.error) {
Some(error) => widget.error(error),
None => widget,
};
// Over the page's top right, as elsewhere.
let place = div()
.absolute()
.top_2()
@@ -233,26 +183,3 @@ impl Desk {
Some(raise("mark-find-layer", place).into_any_element())
}
}
/// The matches marked in the source; the current one stronger.
fn mark(
document: &Entity<ely_gpui_component::documents::MarkdownDocument>,
hits: &[Range<usize>],
current: Option<usize>,
cx: &mut Context<Desk>,
) {
let tone = cx.theme().colors.warning;
let marks = hits
.iter()
.enumerate()
.map(|(at, hit)| {
let strength = if Some(at) == current { 0.45 } else { 0.2 };
(hit.clone(), tone.opacity(strength))
})
.collect();
let editor = document.read(cx).editor().clone();
editor.update(cx, |editor, cx| editor.set_backgrounds(marks, cx));
}
#[cfg(test)]
mod tests;
-200
View File
@@ -1,200 +0,0 @@
use ely_gpui_component::forms::TextInput;
use gpui::{AppContext as _, Entity, TestAppContext, VisualTestContext};
use crate::desk::{
Desk,
tests::{open, start},
};
fn opened(
text: &str,
cx: &mut TestAppContext,
) -> (
Entity<Desk>,
&'static mut VisualTestContext,
[tempfile::TempDir; 2],
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, text).expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
(desk, cx, [data, files])
}
fn query(desk: &Entity<Desk>, words: &str, cx: &mut VisualTestContext) {
desk.update_in(cx, |desk, window, cx| {
desk.on_find(&ely_office::FindReplace, window, cx)
});
cx.simulate_input(words);
cx.run_until_parked();
}
fn current_text(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> String {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
view.read(cx).document().read(cx).text(cx)
})
}
#[gpui::test]
fn every_match_is_counted_and_replaced_in_one_step(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a b a\n", cx);
query(&desk, "a", cx);
assert_eq!(
desk.read_with(cx, |desk, _| desk
.finding
.as_ref()
.map(|find| find.hits.len())),
Some(2)
);
desk.update_in(cx, |desk, window, cx| {
let field = cx.new(|cx| TextInput::new(window, cx));
field.update(cx, |field, cx| field.set_text("x", cx));
desk.finding.as_mut().expect("open").replace = Some(field);
desk.replace(true, window, cx);
});
cx.run_until_parked();
assert_eq!(current_text(&desk, cx), "x b x\n");
let focus = desk.read_with(cx, |desk, cx| {
desk.active_view().expect("a tab").read(cx).focus.clone()
});
cx.update(|window, cx| window.focus(&focus, cx));
cx.run_until_parked();
cx.simulate_keystrokes("secondary-z");
assert_eq!(
current_text(&desk, cx),
"a b a\n",
"one undo takes all back"
);
}
#[gpui::test]
fn a_bad_pattern_says_why(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a\n", cx);
desk.update_in(cx, |desk, window, cx| {
desk.on_find(&ely_office::FindReplace, window, cx)
});
desk.update(cx, |desk, cx| {
desk.finding.as_mut().expect("open").options.regex = true;
desk.count(cx);
});
cx.simulate_input("(");
cx.run_until_parked();
let error = desk.read_with(cx, |desk, _| {
desk.finding.as_ref().and_then(|find| find.error.clone())
});
assert!(error.is_some());
}
#[gpui::test]
fn a_step_shows_the_next_match(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("one\n\ntwo one\n", cx);
query(&desk, "one", cx);
desk.update_in(cx, |desk, window, cx| {
desk.step(true, window, cx);
desk.step(true, window, cx);
});
let current = desk.read_with(cx, |desk, _| {
desk.finding.as_ref().and_then(|find| find.current)
});
assert_eq!(current, Some(1));
}
#[gpui::test]
fn the_table_menu_inserts_and_edits_a_table(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("Intro\n", cx);
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.dispatch_action(crate::tools::InsertTable);
assert!(current_text(&desk, cx).contains("| ---- | ---- | ---- |"));
cx.dispatch_action(crate::tools::DeleteColumn);
cx.run_until_parked();
assert!(
current_text(&desk, cx).contains("| ---- | ---- |\n"),
"{}",
current_text(&desk, cx)
);
}
fn replace_one(desk: &Entity<Desk>, with: &str, cx: &mut VisualTestContext) {
desk.update_in(cx, |desk, window, cx| {
let field = cx.new(|cx| TextInput::new(window, cx));
field.update(cx, |field, cx| field.set_text(with, cx));
desk.finding.as_mut().expect("open").replace = Some(field);
desk.replace(false, window, cx);
});
cx.run_until_parked();
}
fn current_hit(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> Option<std::ops::Range<usize>> {
desk.read_with(cx, |desk, _| {
let find = desk.finding.as_ref()?;
find.current.map(|at| find.hits[at].clone())
})
}
#[gpui::test]
fn a_replacement_that_still_matches_moves_on(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a a\n", cx);
query(&desk, "a", cx);
desk.update_in(cx, |desk, window, cx| desk.step(true, window, cx));
replace_one(&desk, "aa", cx);
assert_eq!(current_text(&desk, cx), "aa a\n");
assert_eq!(
current_hit(&desk, cx),
Some(3..4),
"the next original match"
);
}
#[gpui::test]
fn replacing_the_last_match_wraps_to_the_first(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a b a\n", cx);
query(&desk, "a", cx);
desk.update_in(cx, |desk, window, cx| desk.step(false, window, cx));
replace_one(&desk, "x", cx);
assert_eq!(current_text(&desk, cx), "a b x\n");
assert_eq!(current_hit(&desk, cx), Some(0..1));
}
#[gpui::test]
fn preview_keeps_its_text_from_tables_and_replacement(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("a\n", cx);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
view.update_in(cx, |view, window, cx| {
view.switch(crate::view::Mode::Preview, window, cx)
});
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.dispatch_action(crate::tools::InsertTable);
query(&desk, "a", cx);
desk.update_in(cx, |desk, window, cx| desk.step(true, window, cx));
replace_one(&desk, "x", cx);
assert_eq!(current_text(&desk, cx), "a\n");
}
#[gpui::test]
fn clipboard_keys_in_find_reach_the_field(cx: &mut TestAppContext) {
let (desk, cx, _files) = opened("alpha beta\n", cx);
query(&desk, "beta", cx);
let field = desk.read_with(cx, |desk, _| {
desk.finding.as_ref().expect("open").query.clone()
});
field.update(cx, |field, cx| field.select(0..4, cx));
cx.simulate_keystrokes("secondary-shift-c");
cx.run_until_parked();
let copied = cx.read_from_clipboard().and_then(|item| item.text());
assert_eq!(copied.as_deref(), Some("beta"));
cx.write_to_clipboard(gpui::ClipboardItem::new_string("alpha".into()));
cx.simulate_keystrokes("secondary-shift-v");
cx.run_until_parked();
assert_eq!(
field.read_with(cx, |field, _| field.text().to_string()),
"alpha"
);
assert_eq!(current_text(&desk, cx), "alpha beta\n");
}
+4 -45
View File
@@ -49,43 +49,21 @@ pub struct Hit {
pub file: PathBuf,
/// Counted from one.
pub line: usize,
/// The first match, in characters from zero.
pub column: usize,
pub text: String,
}
/// `ch` without case; final sigma folds as Unicode does.
fn fold(ch: char) -> impl Iterator<Item = char> {
ch.to_lowercase()
.map(|low| if low == 'ς' { 'σ' } else { low })
}
/// Where folded `query` starts in `line`, in characters.
fn column_of(line: &str, query: &str) -> Option<usize> {
// Each folded byte keeps its character's place.
let (mut folded, mut places) = (String::new(), Vec::new());
for (place, ch) in line.chars().enumerate() {
for low in fold(ch) {
folded.push(low);
places.extend(std::iter::repeat_n(place, low.len_utf8()));
}
}
folded.find(query).map(|at| places[at])
}
/// Lines of `text` holding `query`, any case.
pub fn lines_with(file: &Path, text: &str, query: &str, hits: &mut Vec<Hit>) {
let query: String = query.chars().flat_map(fold).collect();
let query = query.to_lowercase();
for (at, line) in text.lines().enumerate() {
if hits.len() == MOST_HITS {
return;
}
if let Some(column) = column_of(line, &query) {
if line.to_lowercase().contains(&query) {
let shown: String = line.trim().chars().take(160).collect();
hits.push(Hit {
file: file.to_path_buf(),
line: at + 1,
column,
text: shown,
});
}
@@ -130,27 +108,8 @@ mod tests {
);
let lines: Vec<_> = hits
.iter()
.map(|hit| (hit.line, hit.column, hit.text.as_str()))
.map(|hit| (hit.line, hit.text.as_str()))
.collect();
assert_eq!(lines, [(2, 5, "some Words here"), (4, 0, "WORDS")]);
}
#[test]
fn the_column_counts_characters_through_case_changes() {
// İ lowers to two characters.
assert_eq!(super::column_of("İİ née Word", "word"), Some(7));
assert_eq!(super::column_of("中文 搜索", "搜索"), Some(3));
assert_eq!(super::column_of("none", "word"), None);
}
#[test]
fn sigma_matches_in_every_form() {
for line in ["ΟΣ", "οσ", "ος"] {
for query in ["ΟΣ", "οσ", "ος"] {
let mut hits = Vec::new();
super::lines_with(Path::new("a.md"), line, query, &mut hits);
assert_eq!(hits.len(), 1, "{query} in {line}");
}
}
assert_eq!(lines, [(2, "some Words here"), (4, "WORDS")]);
}
}
+42 -80
View File
@@ -1,34 +1,39 @@
//! Modes, menus and keys, as Typora's (MARK-01, MARK-02, MARK-15).
//! Modes, menus and keys (MARK-01, MARK-02).
use ely_ui::{commands, i18n::text};
use gpui::{Action, App, Context, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions};
use gpui::{App, KeyBinding, Menu, MenuItem, actions};
use crate::{desk::Desk, view::Mode};
use crate::page::Mode;
actions!(
mark,
[VisualMode, SourceMode, PreviewMode, ToggleSource, CheckDisk]
[
VisualMode,
SourceMode,
PreviewMode,
InsertTable,
RowAbove,
RowBelow,
ColumnLeft,
ColumnRight,
DeleteRow,
DeleteColumn,
CheckDisk
]
);
pub fn bind(cx: &mut App) {
let at = Some(commands::CONTEXT);
cx.bind_keys([
KeyBinding::new("secondary-/", ToggleSource, at),
// Over the code editor's `//` toggle.
KeyBinding::new("secondary-/", ToggleSource, Some("ElyEditor")),
KeyBinding::new("f8", crate::look::FocusMode, at),
KeyBinding::new("f9", crate::look::Typewriter, at),
KeyBinding::new("secondary-+", crate::look::Larger, at),
KeyBinding::new("secondary-_", crate::look::Smaller, at),
KeyBinding::new("secondary-)", crate::look::ActualSize, at),
KeyBinding::new("secondary-f", ely_office::FindReplace, at),
KeyBinding::new("secondary-1", VisualMode, at),
KeyBinding::new("secondary-2", SourceMode, at),
KeyBinding::new("secondary-3", PreviewMode, at),
KeyBinding::new("secondary-f", crate::finding::Find, at),
KeyBinding::new("secondary-shift-f", crate::look::FocusMode, at),
KeyBinding::new("secondary-=", crate::look::Larger, at),
KeyBinding::new("secondary--", crate::look::Smaller, at),
KeyBinding::new("secondary-0", crate::look::ActualSize, at),
]);
// Windows' Chinese input keeps Ctrl+Shift+0 for itself.
#[cfg(target_os = "windows")]
cx.bind_keys([KeyBinding::new("ctrl-(", crate::look::ActualSize, at)]);
crate::blocks::bind(cx);
crate::tools::bind(cx);
crate::clip::bind(cx);
}
/// The label of `mode`.
@@ -36,39 +41,30 @@ pub fn label(mode: Mode, cx: &App) -> gpui::SharedString {
text(cx, &format!("mark.mode.{}", mode.name()), &[])
}
/// A menu row named by `key`, ticked when `checked`.
pub(crate) fn checked(key: &str, action: Box<dyn Action>, checked: bool, cx: &App) -> MenuItem {
MenuItem::Action {
pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
use crate::look::{self, Font, Width};
let checked = |key: &str, action: Box<dyn gpui::Action>, checked: bool| MenuItem::Action {
name: text(cx, key, &[]),
action,
os_action: None,
checked,
disabled: false,
}
}
pub(crate) fn item(key: &str, action: Box<dyn Action>, cx: &App) -> MenuItem {
checked(key, action, false, cx)
}
pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
use crate::look::{self, Font, Width};
let checked = |key: &str, action: Box<dyn Action>, on: bool| checked(key, action, on, cx);
let item = |key: &str, action: Box<dyn Action>| item(key, action, cx);
};
let item = |key: &str, action: Box<dyn gpui::Action>| checked(key, action, false);
let table = Menu::new(text(cx, "mark.menu.table", &[])).items([
item("mark.table.insert", Box::new(InsertTable)),
MenuItem::separator(),
item("mark.table.row_above", Box::new(RowAbove)),
item("mark.table.row_below", Box::new(RowBelow)),
item("mark.table.column_left", Box::new(ColumnLeft)),
item("mark.table.column_right", Box::new(ColumnRight)),
MenuItem::separator(),
item("mark.table.delete_row", Box::new(DeleteRow)),
item("mark.table.delete_column", Box::new(DeleteColumn)),
]);
vec![
Menu::new(text(cx, "menu.edit", &[])).items([
item("mark.copy.plain", Box::new(crate::export::CopyPlain)),
item("mark.copy.markdown", Box::new(crate::clip::CopyMarkdown)),
item("mark.copy.html", Box::new(crate::export::CopyHtml)),
item("mark.copy.rich", Box::new(crate::export::CopyRich)),
item("mark.paste.plain", Box::new(crate::clip::PastePlain)),
MenuItem::separator(),
item("office.find.open", Box::new(ely_office::FindReplace)),
]),
crate::blocks::paragraph_menu(cx),
crate::blocks::format_menu(cx),
table,
Menu::new(text(cx, "menu.view", &[])).items([
item("mark.view.source", Box::new(ToggleSource)),
MenuItem::action(label(Mode::Visual, cx), VisualMode),
MenuItem::action(label(Mode::Source, cx), SourceMode),
MenuItem::action(label(Mode::Preview, cx), PreviewMode),
@@ -112,45 +108,11 @@ pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
look.font == Font::Mono,
),
MenuItem::separator(),
item("mark.look.actual", Box::new(look::ActualSize)),
item("mark.look.larger", Box::new(look::Larger)),
item("mark.look.smaller", Box::new(look::Smaller)),
item("mark.look.actual", Box::new(look::ActualSize)),
MenuItem::separator(),
item("mark.outside.check", Box::new(CheckDisk)),
]),
]
}
impl Desk {
/// Source and visual, as Typora's Source Code Mode.
fn toggle_source(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let mode = match view.read(cx).mode {
Mode::Source => Mode::Visual,
Mode::Visual | Mode::Preview => Mode::Source,
};
self.switch(mode, window, cx);
}
pub fn mode_actions(root: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
root.on_action(cx.listener(|desk, _: &CheckDisk, _, cx| desk.look_all(cx)))
.on_action(
cx.listener(|desk, _: &ToggleSource, window, cx| desk.toggle_source(window, cx)),
)
.on_action(
cx.listener(|desk, _: &VisualMode, window, cx| {
desk.switch(Mode::Visual, window, cx)
}),
)
.on_action(
cx.listener(|desk, _: &SourceMode, window, cx| {
desk.switch(Mode::Source, window, cx)
}),
)
.on_action(cx.listener(|desk, _: &PreviewMode, window, cx| {
desk.switch(Mode::Preview, window, cx)
}))
}
}
+9 -173
View File
@@ -2,9 +2,7 @@
use std::{io, path::Path};
use ely_gpui_component::{documents::DocumentLook, primitives::Severity, theme::ActiveTheme as _};
use ely_office::prelude::*;
use gpui::{App, Context, actions, rems};
use gpui::{Context, actions};
use serde::{Deserialize, Serialize};
use crate::desk::Desk;
@@ -55,36 +53,6 @@ impl Default for Look {
}
}
/// A serif face each system carries.
const SERIF: &str = if cfg!(target_os = "linux") {
"DejaVu Serif"
} else {
"Georgia"
};
impl Look {
/// How the document shows text by this look.
pub fn document(&self, cx: &App) -> DocumentLook {
let measure = match self.width {
Width::Narrow => 36.0,
Width::Medium => 48.0,
Width::Wide => 64.0,
};
let font = match self.font {
Font::Sans => None,
Font::Serif => Some(SERIF.into()),
Font::Mono => Some(cx.theme().mono_family.clone()),
};
DocumentLook {
focus: self.focus,
typewriter: self.typewriter,
measure: Some(rems(measure)),
font,
scale: self.size as f32 / 100.0,
}
}
}
/// The kept look; none kept is the default.
pub fn load(file: &Path) -> io::Result<Look> {
match std::fs::read(file) {
@@ -98,19 +66,16 @@ pub fn load(file: &Path) -> io::Result<Look> {
const SIZES: (u32, u32, u32) = (70, 200, 10);
impl Desk {
/// Changes the look; its file and menus follow.
/// Changes the look; pages, file and menus follow.
pub fn relook(&mut self, change: impl FnOnce(&mut Look), cx: &mut Context<Self>) {
change(&mut self.look);
self.look.size = self.look.size.clamp(SIZES.0, SIZES.1);
log::info!("mark: the look is now {:?}", self.look);
self.show_look(cx);
let json = serde_json::to_vec_pretty(&self.look).expect("a look serializes");
if let Err(error) = ely_files::save_atomic(&self.look_file, &json, 0) {
log::error!("mark: the look did not save: {error}");
let said =
ely_ui::i18n::text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
self.apply_look(cx);
let menus = crate::keys::menus(&self.look, cx);
ely_ui::set_document_menus(
ely_platform::AppId::Mark,
@@ -121,31 +86,15 @@ impl Desk {
cx.notify();
}
/// Every tab's document takes the look.
fn apply_look(&mut self, cx: &mut Context<Self>) {
let look = self.look.document(cx);
let documents: Vec<_> = self
.core
.tabs
.iter()
.map(|tab| tab.view.read(cx).document().clone())
.collect();
for document in documents {
document.update(cx, |document, cx| document.set_look(look.clone(), cx));
/// Every tab's page takes the look.
pub fn show_look(&mut self, cx: &mut Context<Self>) {
let look = serde_json::to_value(&self.look).expect("a look serializes");
for tab in &self.core.tabs {
let look = look.clone();
tab.view.update(cx, |view, cx| view.set_look(look, cx));
}
}
/// A new tab's document takes the look once.
pub fn look_once(&mut self, id: u64, cx: &mut Context<Self>) {
let look = self.look.document(cx);
let Some(tab) = self.tab_mut(id).filter(|tab| !tab.extra.looked) else {
return;
};
tab.extra.looked = true;
let document = tab.view.read(cx).document().clone();
document.update(cx, |document, cx| document.set_look(look, cx));
}
pub fn look_actions(element: gpui::Div, cx: &mut Context<Self>) -> gpui::Div {
use gpui::InteractiveElement as _;
let step = SIZES.2;
@@ -194,70 +143,6 @@ impl Desk {
#[cfg(test)]
mod tests {
use ely_gpui_component::documents::DocumentLook;
use gpui::{
Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point,
rems,
};
use crate::desk::{
Desk,
tests::{open, start},
};
fn document_look(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> DocumentLook {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
view.read(cx).document().read(cx).look().clone()
})
}
#[gpui::test]
fn a_new_tab_takes_the_kept_look(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let kept = r#"{"focus":true,"typewriter":true,"width":"narrow","font":"serif","size":120}"#;
std::fs::write(data.path().join("look.json"), kept).expect("a look");
let path = files.path().join("a.md");
std::fs::write(
&path, "a
",
)
.expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let look = document_look(&desk, cx);
assert!(look.focus && look.typewriter);
assert_eq!((look.measure, look.scale), (Some(rems(36.0)), 1.2));
assert_eq!(look.font.as_deref(), Some(super::SERIF));
}
#[gpui::test]
fn a_menu_change_reaches_open_tabs_and_is_kept(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(
&path, "a
",
)
.expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.dispatch_action(super::Wide);
cx.dispatch_action(super::Larger);
let look = document_look(&desk, cx);
assert_eq!((look.measure, look.scale), (Some(rems(64.0)), 1.1));
let kept = std::fs::read_to_string(data.path().join("look.json")).expect("kept");
assert!(kept.contains("\"wide\"") && kept.contains("110"), "{kept}");
}
#[test]
fn a_kept_look_reads_back_and_none_is_default() {
let dir = tempfile::tempdir().expect("a temp dir");
@@ -278,53 +163,4 @@ mod tests {
(super::Width::Wide, super::Font::Serif, 120)
);
}
#[gpui::test]
fn ctrl_and_the_wheel_size_the_text(cx: &mut TestAppContext) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(
&path, "a
",
)
.expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let focus = desk.read_with(cx, |desk, _| desk.core.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
let at = cx.update(|window, _| window.viewport_size()).center();
let wheel = |modifiers, y| ScrollWheelEvent {
position: at,
delta: ScrollDelta::Lines(point(0.0, y)),
modifiers,
..Default::default()
};
let other = match cfg!(target_os = "macos") {
true => Modifiers {
control: true,
..Default::default()
},
false => Modifiers {
platform: true,
..Default::default()
},
};
cx.simulate_event(wheel(other, 3.0));
cx.run_until_parked();
assert_eq!(
document_look(&desk, cx).scale,
1.0,
"only Ctrl, or Cmd on macOS"
);
cx.simulate_event(wheel(Modifiers::secondary_key(), 3.0));
cx.run_until_parked();
assert_eq!(document_look(&desk, cx).scale, 1.1, "a notch is one step");
cx.simulate_event(wheel(Modifiers::secondary_key(), -1.0));
cx.simulate_event(wheel(Modifiers::secondary_key(), -1.0));
cx.run_until_parked();
assert_eq!(document_look(&desk, cx).scale, 0.9);
}
}
+6 -7
View File
@@ -1,10 +1,5 @@
// Release builds open no console on Windows.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod attach;
mod autosave;
mod blocks;
mod clip;
mod compare;
mod desk;
mod engine;
@@ -13,19 +8,23 @@ mod finding;
mod folder;
mod keys;
mod look;
mod output;
mod outside;
mod page;
mod paper;
mod places;
mod rename;
mod restore;
mod side;
mod text;
mod tools;
mod vcs;
mod view;
use ely_platform::AppId;
fn main() {
// CEF runs its helper processes from this same binary.
if let Some(code) = ely_web_content::subprocess() {
std::process::exit(code);
}
ely_ui::run(AppId::Mark, desk::open);
}
-26
View File
@@ -1,26 +0,0 @@
//! Addresses, read as browsers read them.
/// A plain, web or mail address, read as browsers do.
pub(super) fn safe(url: &str) -> bool {
// Browsers trim spaces and controls, then drop tabs and newlines.
let read: String = url
.trim_matches(|ch: char| ch <= ' ')
.chars()
.filter(|ch| !matches!(ch, '\t' | '\n' | '\r'))
.collect();
match scheme(&read) {
None => true,
Some(scheme) => ["http", "https", "mailto"].contains(&scheme.to_lowercase().as_str()),
}
}
/// An address's scheme, when it has one.
pub(super) fn scheme(url: &str) -> Option<&str> {
let end = url.find(':')?;
let name = &url[..end];
let plain = name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch));
// A drive letter is a path, not a scheme.
(plain && name.len() > 1).then_some(name)
}
-100
View File
@@ -1,100 +0,0 @@
//! An export written to disk.
use std::{
io,
path::Path,
sync::atomic::{AtomicU64, Ordering},
};
use super::{Page, Pictures, body, document, page_of, pictures::Placed, plain};
/// What an export writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
/// One HTML file, pictures inside.
Page,
/// HTML and a folder of its pictures.
Linked,
Pdf,
Text,
}
/// Where one export goes, and what it leaves alone.
pub struct Target<'a> {
pub path: &'a Path,
/// The tab's own file, never written over.
pub session: Option<&'a Path>,
pub revision: u64,
/// A scratch folder of the tab's.
pub work: &'a Path,
}
/// `text` written as `format`; what it left out.
pub fn write(
format: Format,
text: &str,
beside: Option<&Path>,
target: &Target,
) -> io::Result<Page> {
let title = target
.path
.file_stem()
.map_or_else(String::new, |stem| stem.to_string_lossy().into_owned());
let page = match format {
Format::Page => {
let page = body(text, beside, &Pictures::Inline)?;
export(target, document(&title, &page.html).as_bytes())?;
page
}
Format::Linked => linked(text, beside, target, &title)?,
Format::Pdf => pdf(text, beside, target, &title)?,
Format::Text => {
export(target, plain(text)?.as_bytes())?;
Page::default()
}
};
log::info!("mark: exported {format:?}, revision {}", target.revision);
Ok(page)
}
fn export(target: &Target, bytes: &[u8]) -> io::Result<()> {
ely_files::export(target.path, target.session, bytes, target.revision).map(|_| ())
}
/// The page beside a new folder of its pictures.
fn linked(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::Result<Page> {
let folder = target.path.with_file_name(format!("{title}_files"));
let pictures = Pictures::Folder(folder.clone());
let mut placed = Placed::new(beside, &pictures);
let written = page_of(text, &mut placed)
.and_then(|page| export(target, document(title, &page.html).as_bytes()).map(|()| page));
// A failure takes only the folder it made.
if written.is_err()
&& placed.made
&& let Err(left) = std::fs::remove_dir_all(&folder)
{
log::error!("mark: the half-made picture folder stays: {left}");
}
written
}
/// The page set by MuPDF, in its own scratch.
fn pdf(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::Result<Page> {
static JOBS: AtomicU64 = AtomicU64::new(0);
let dir = target
.work
.join(format!("pdf-{}", JOBS.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&dir)?;
let set = (|| -> io::Result<Page> {
let page = body(text, beside, &Pictures::Pdf(dir.join("files")))?;
std::fs::write(dir.join("page.html"), document(title, &page.html))?;
let out = dir.join("out.pdf");
ely_pdf_engine::Worker::spawn()?.html_pdf(&dir, "page.html", &out)?;
export(target, &std::fs::read(&out)?)?;
Ok(page)
})();
let cleaned = std::fs::remove_dir_all(&dir);
let page = set?;
cleaned?;
Ok(page)
}
-486
View File
@@ -1,486 +0,0 @@
//! Markdown as HTML, as the drawing reads it.
use std::{
cmp::Reverse,
collections::{HashSet, VecDeque},
io,
ops::Range,
};
use ely_gpui_component::{
documents::{
AlertKind, Inline, MarkdownBlock, MarkdownReader, Style, alert, diagram_svg, is_toc,
},
typography::formula_svg,
};
use markdown::mdast::{AlignKind, Node};
use super::{Page, address::safe, attribute, escape, pictures::Placed};
/// Writes `tree`, read from `source`, into `page`.
pub(super) fn write(
tree: &Node,
source: &str,
placed: &mut Placed,
page: &mut Page,
) -> io::Result<()> {
let mut writer = Writer {
placed,
page,
source,
reader: MarkdownReader::joined(source, tree),
anchored: HashSet::new(),
};
writer.root(tree)
}
struct Writer<'a, 'b, 'c> {
placed: &'b mut Placed<'a>,
page: &'c mut Page,
source: &'c str,
reader: MarkdownReader<'c>,
/// Notes whose definition has its anchor.
anchored: HashSet<usize>,
}
/// What a read area leaves out, in reading order.
#[derive(Default)]
struct Atoms {
/// Each formula's line.
formulas: VecDeque<usize>,
/// Each footnote reference's number.
notes: VecDeque<usize>,
}
/// A stretch of read text's mark.
enum Mark<'a> {
Style(Style),
Link(&'a str),
}
impl Writer<'_, '_, '_> {
fn push(&mut self, text: &str) {
self.page.html.push_str(text);
}
/// Root blocks: alerts and `[toc]` live only here.
fn root(&mut self, tree: &Node) -> io::Result<()> {
for child in tree.children().into_iter().flatten() {
match child {
Node::Blockquote(quote) => match alert(quote) {
Some((kind, body)) => self.alert(kind, &body)?,
None => self.block(child, false)?,
},
_ if is_toc(child, self.source) => self.toc(),
_ => self.block(child, false)?,
}
}
Ok(())
}
fn blocks(&mut self, node: &Node, tight: bool) -> io::Result<()> {
for child in node.children().into_iter().flatten() {
self.block(child, tight)?;
}
Ok(())
}
fn block(&mut self, node: &Node, tight: bool) -> io::Result<()> {
match node {
Node::Paragraph(_) | Node::Heading(_) | Node::Table(_) | Node::Html(_) => {
self.area(node, tight)?
}
Node::Blockquote(_) => {
self.push("<blockquote>");
self.blocks(node, false)?;
self.push("</blockquote>");
}
Node::List(list) => {
let tag = if list.ordered { "ol" } else { "ul" };
match list.start.filter(|start| list.ordered && *start != 1) {
Some(start) => self.push(&format!("<ol start=\"{start}\">")),
None => self.push(&format!("<{tag}>")),
}
for item in &list.children {
let Node::ListItem(each) = item else { continue };
self.push("<li>");
if let Some(checked) = each.checked {
let mark = if checked { " checked" } else { "" };
self.push(&format!("<input type=\"checkbox\" disabled{mark}> "));
}
self.blocks(item, !list.spread && !each.spread)?;
self.push("</li>");
}
self.push(&format!("</{tag}>"));
}
Node::Code(code) if code.lang.as_deref() == Some("mermaid") => {
match diagram_svg(&code.value) {
Ok(svg) => self.push(&format!("<div class=\"diagram\">{}</div>", bare(&svg))),
Err(_) => {
self.page.diagrams.push(line_of(node));
self.code(Some("mermaid"), &code.value);
}
}
}
Node::Code(code) => self.code(code.lang.as_deref(), &code.value),
Node::Math(math) => self.math(line_of(node), &math.value, true),
Node::ThematicBreak(_) => self.push("<hr>"),
Node::FootnoteDefinition(note) => {
// A reference reaches its note's first definition.
let id = (self.reader.note(&note.identifier))
.filter(|number| self.anchored.insert(*number))
.map_or_else(String::new, |number| format!(" id=\"fn-{number}\""));
self.push(&format!(
"<div{id} style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[{}]</span><div>",
escape(&self.reader.label(note))
));
self.blocks(node, false)?;
self.push("</div></div>");
}
// Never written.
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) => {}
other => unreachable!("{other:?} is no block outside MDX"),
}
Ok(())
}
/// A paragraph, heading, table or HTML block, read as drawn.
fn area(&mut self, node: &Node, tight: bool) -> io::Result<()> {
let mut read = Vec::new();
self.reader.block(node, &mut read);
if let Node::Table(_) = node {
let [MarkdownBlock::Table { aligns, head, rows }] = read.as_slice() else {
unreachable!("a table reads as one table");
};
return self.table(node, aligns, head, rows);
}
// A heading's pictures follow it whole.
let split = !matches!(node, Node::Heading(_));
let stretches = self.stretches(node, split);
self.run(&read, node, tight, split, stretches)
}
/// Pieces in order; with `split`, each picture ends a stretch.
fn run(
&mut self,
pieces: &[MarkdownBlock],
node: &Node,
tight: bool,
split: bool,
mut stretches: VecDeque<Atoms>,
) -> io::Result<()> {
let mut written = false;
for piece in pieces {
match piece {
MarkdownBlock::Image { alt, url, link, .. } => {
self.picture(url, alt, link.as_deref())?;
if split {
retire(stretches.pop_front(), written);
written = false;
}
}
_ => {
let atoms = stretches.front_mut().expect("a stretch per text");
self.piece(piece, node, tight, atoms);
written = true;
}
}
}
retire(stretches.pop_front(), written);
assert!(stretches.is_empty(), "a stretch per picture");
Ok(())
}
/// Formulas and notes by stretch; pictures part them.
fn stretches(&self, node: &Node, split: bool) -> VecDeque<Atoms> {
let mut out = VecDeque::from([Atoms::default()]);
self.gather(node, split, &mut out);
out
}
fn gather(&self, node: &Node, split: bool, out: &mut VecDeque<Atoms>) {
match node {
Node::InlineMath(_) => {
(out.back_mut().expect("a stretch").formulas).push_back(line_of(node))
}
Node::FootnoteReference(call) => {
let number = self
.reader
.note(&call.identifier)
.expect("a reference names a note");
out.back_mut().expect("a stretch").notes.push_back(number);
}
Node::Image(_) | Node::ImageReference(_) if split => out.push_back(Atoms::default()),
_ => {}
}
for child in node.children().into_iter().flatten() {
self.gather(child, split, out);
}
}
fn table(
&mut self,
node: &Node,
aligns: &[AlignKind],
head: &[Vec<MarkdownBlock>],
rows: &[Vec<Vec<MarkdownBlock>>],
) -> io::Result<()> {
let written = node.children().expect("a table holds rows");
let read: Vec<&[Vec<MarkdownBlock>]> = std::iter::once(head)
.chain(rows.iter().map(Vec::as_slice))
.collect();
assert_eq!(read.len(), written.len(), "a read row per row");
self.push("<table>");
for (at, (row, cells)) in read.into_iter().zip(written).enumerate() {
let tag = if at == 0 { "th" } else { "td" };
let cells = cells.children().expect("a row holds cells");
assert_eq!(row.len(), cells.len(), "a read cell per cell");
self.push("<tr>");
for (column, (pieces, cell)) in row.iter().zip(cells).enumerate() {
let side = match aligns.get(column) {
Some(AlignKind::Left) => " style=\"text-align:left\"",
Some(AlignKind::Right) => " style=\"text-align:right\"",
Some(AlignKind::Center) => " style=\"text-align:center\"",
_ => "",
};
self.push(&format!("<{tag}{side}>"));
let stretches = self.stretches(cell, true);
self.run(pieces, cell, true, true, stretches)?;
self.push(&format!("</{tag}>"));
}
self.push("</tr>");
}
self.push("</table>");
Ok(())
}
fn piece(&mut self, piece: &MarkdownBlock, node: &Node, tight: bool, atoms: &mut Atoms) {
match piece {
MarkdownBlock::Paragraph(text) if tight => self.inline(text, atoms),
MarkdownBlock::Paragraph(text) => {
self.push("<p>");
self.inline(text, atoms);
self.push("</p>");
}
MarkdownBlock::Heading(depth, text) => {
let at = node.position().expect("a parsed node has its place");
self.push(&format!("<h{depth} id=\"h-{}\">", at.start.offset));
self.inline(text, atoms);
self.push(&format!("</h{depth}>"));
}
MarkdownBlock::Math(tex) => {
let line = atoms.formulas.pop_front().expect("a formula's line");
self.math(line, tex, true);
}
other => unreachable!("an area's text reads as text or math, not {other:?}"),
}
}
/// Read text; its marks open and close as a stack.
fn inline(&mut self, read: &Inline, atoms: &mut Atoms) {
let mut marks: Vec<(&Range<usize>, Mark)> = (read.styles.iter())
.filter(|(_, style)| *style != Style::Link)
.map(|(range, style)| (range, Mark::Style(*style)))
.chain(
read.links
.iter()
.map(|(range, url)| (range, Mark::Link(url))),
)
.collect();
// Outer first; a formula or note is its own text.
marks.sort_by_key(|(range, mark)| {
let whole = matches!(mark, Mark::Style(Style::Math | Style::Footnote));
(range.start, Reverse(range.end), whole)
});
let (mut open, mut at): (Vec<(usize, &str)>, usize) = (Vec::new(), 0);
let mut links = 0;
for (range, mark) in marks {
links -= self.close_to(read, range.start, &mut open, &mut at);
assert!(
range.start >= at && open.last().is_none_or(|(end, _)| range.end <= *end),
"read marks nest"
);
self.text(&read.text[at..range.start]);
at = range.start;
match mark {
Mark::Style(Style::Math) => {
let line = atoms.formulas.pop_front().expect("a formula's line");
self.math(line, &read.text[range.clone()], false);
at = range.end;
}
Mark::Style(Style::Footnote) => {
let number = atoms.notes.pop_front().expect("a note's number");
// Links never nest.
match links > 0 {
true => self.push(&format!("<sup>{number}</sup>")),
false => {
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"))
}
}
at = range.end;
}
Mark::Style(style) => {
let name = tag(style);
self.push(&format!("<{name}>"));
open.push((range.end, name));
}
Mark::Link(url) => {
match safe(url) {
true => self.push(&format!("<a href=\"{}\">", attribute(url))),
false => self.push("<a>"),
}
open.push((range.end, "a"));
links += 1;
}
}
}
self.close_to(read, read.text.len(), &mut open, &mut at);
self.text(&read.text[at..]);
}
/// Closes each mark ending by `to`; counts closed links.
fn close_to(
&mut self,
read: &Inline,
to: usize,
open: &mut Vec<(usize, &str)>,
at: &mut usize,
) -> usize {
let mut links = 0;
while let Some(&(end, name)) = open.last().filter(|(end, _)| *end <= to) {
self.text(&read.text[*at..end]);
self.push(&format!("</{name}>"));
*at = end;
open.pop();
links += usize::from(name == "a");
}
links
}
fn text(&mut self, text: &str) {
self.push(&escape(text).replace('\n', "<br>"));
}
fn code(&mut self, lang: Option<&str>, value: &str) {
let class = lang.map_or_else(String::new, |lang| {
format!(" class=\"language-{}\"", attribute(lang))
});
self.push(&format!("<pre><code{class}>{}</code></pre>", escape(value)));
}
fn math(&mut self, line: usize, value: &str, display: bool) {
match (formula_svg(value, display), display) {
(Ok(svg), true) => self.push(&format!("<div class=\"math\">{}</div>", bare(&svg))),
(Ok(svg), false) => self.push(&format!("<span class=\"math\">{}</span>", bare(&svg))),
(Err(error), _) => {
self.page.formulas.push((line, error.to_string()));
match display {
true => self.code(Some("math"), value),
false => self.push(&format!("<code>{}</code>", escape(value))),
}
}
}
}
/// A GitHub alert: its kind's title, then its blocks.
fn alert(&mut self, kind: AlertKind, body: &[Node]) -> io::Result<()> {
let (title, hue) = (kind.title(), hue(kind));
self.push(&format!(
"<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid {hue}\"><p style=\"font-weight:600;color:{hue}\">{title}</p>"
));
for node in body {
self.block(node, false)?;
}
self.push("</div>");
Ok(())
}
/// `[toc]`: each heading, linked to its place.
fn toc(&mut self) {
let items: String = (self.reader.headings.iter())
.map(|heading| {
format!(
"<li style=\"margin-left:{}em\"><a href=\"#h-{}\">{}</a></li>",
heading.depth - 1,
heading.at,
escape(&heading.title)
)
})
.collect();
self.push(&format!(
"<ul style=\"list-style:none;padding-left:0\">{items}</ul>"
));
}
fn picture(&mut self, url: &str, alt: &str, link: Option<&str>) -> io::Result<()> {
let src = self.placed.place(url)?;
if src.is_none() {
self.page.missing.push(if url.is_empty() {
alt.to_owned()
} else {
url.to_owned()
});
}
// A PDF shows what it left out as words.
let Some(src) = src.or_else(|| (!self.placed.pdf()).then(|| url.to_owned())) else {
self.push(&format!("[{}]", escape(alt)));
return Ok(());
};
let image = format!(
"<img src=\"{}\" alt=\"{}\">",
attribute(&src),
attribute(alt)
);
match link {
Some(link) if safe(link) => {
self.push(&format!("<a href=\"{}\">{image}</a>", attribute(link)))
}
Some(_) => self.push(&format!("<a>{image}</a>")),
None => self.push(&image),
}
Ok(())
}
}
/// A stretch done: all read, or blank and dropped.
fn retire(atoms: Option<Atoms>, written: bool) {
let atoms = atoms.expect("a stretch per picture");
assert!(
atoms.notes.is_empty() && (!written || atoms.formulas.is_empty()),
"every formula and note is read"
);
}
/// GitHub's color for an alert kind.
fn hue(kind: AlertKind) -> &'static str {
match kind {
AlertKind::Note => "#0969da",
AlertKind::Tip => "#1a7f37",
AlertKind::Important => "#8250df",
AlertKind::Warning => "#9a6700",
AlertKind::Caution => "#d1242f",
}
}
/// The tag a read style writes.
fn tag(style: Style) -> &'static str {
match style {
Style::Strong => "strong",
Style::Emphasis => "em",
Style::Strike => "del",
Style::Code => "code",
Style::Underline => "u",
Style::Mark => "mark",
Style::Link | Style::Math | Style::Footnote => unreachable!("{style:?} writes no tag"),
}
}
/// The line a node starts on, from one.
fn line_of(node: &Node) -> usize {
node.position().map_or(0, |at| at.start.line)
}
/// An SVG without its XML declaration.
fn bare(svg: &str) -> &str {
svg.find("<svg").map_or(svg, |at| &svg[at..])
}
-78
View File
@@ -1,78 +0,0 @@
//! Markdown as an HTML page or plain text (MARK-11).
mod address;
mod files;
mod html;
mod pictures;
mod plain;
#[cfg(test)]
mod tests;
use std::{io, path::Path};
use ely_gpui_component::documents::markdown_tree;
pub use files::{Format, Target, write};
pub use pictures::{Pictures, picture_file};
pub use plain::plain;
/// A page and what it could not carry.
#[derive(Debug, Default)]
pub struct Page {
pub html: String,
/// Pictures left out, as the document wrote them.
pub missing: Vec<String>,
/// Lines of diagrams kept as code.
pub diagrams: Vec<usize>,
/// Lines of formulas kept as code, and why.
pub formulas: Vec<(usize, String)>,
}
/// The page's look: light, as printed.
const STYLE: &str = "body{font-family:sans-serif;font-size:11pt;line-height:1.5;color:#1f2328;max-width:48rem;margin:2rem auto;padding:0 1rem}\
pre{background:#f6f8fa;padding:.75em;white-space:pre-wrap}\
code{font-family:monospace}\
table{border-collapse:collapse}th,td{border:1px solid #d0d7de;padding:.3em .6em}\
blockquote{margin-left:0;padding-left:1em;border-left:3px solid #d0d7de;color:#59636e}\
img,svg{max-width:100%}div.math{text-align:center}span.math svg{vertical-align:middle}";
/// `text`'s body as HTML; pictures go as `pictures` says.
pub fn body(text: &str, beside: Option<&Path>, pictures: &Pictures) -> io::Result<Page> {
page_of(text, &mut pictures::Placed::new(beside, pictures))
}
/// `text`'s body; `placed` keeps what it copied.
fn page_of(text: &str, placed: &mut pictures::Placed) -> io::Result<Page> {
let tree = markdown_tree(text);
let mut page = Page::default();
html::write(&tree, text, placed, &mut page)?;
log::info!(
"mark: a page of {} bytes, {} pictures copied, {} missing, {} diagrams and {} formulas as code",
page.html.len(),
placed.copied.len(),
page.missing.len(),
page.diagrams.len(),
page.formulas.len()
);
Ok(page)
}
/// A whole HTML document around `body`.
pub fn document(title: &str, body: &str) -> String {
format!(
"<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>{}</title><style>{STYLE}</style></head>\n<body>\n{body}\n</body></html>\n",
escape(title)
)
}
/// Text safe between tags.
fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Text safe inside a quoted attribute.
fn attribute(text: &str) -> String {
escape(text).replace('"', "&quot;")
}
-227
View File
@@ -1,227 +0,0 @@
//! Where an exported page's pictures go.
use std::{
fs::File,
io,
path::{Path, PathBuf},
};
use base64::Engine as _;
use super::address::scheme;
/// Where pictures go.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Pictures {
/// Inside the page, as data.
Inline,
/// Copied into this new folder beside the page.
Folder(PathBuf),
/// Copied into this folder, in formats MuPDF reads.
Pdf(PathBuf),
/// Addresses as the text writes them.
Written,
}
/// Picture formats MuPDF reads from files.
const READABLE: [&str; 8] = ["png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "svg"];
/// Inline pictures MuPDF reads.
const READABLE_DATA: [&str; 3] = [
"data:image/png;base64,",
"data:image/jpeg;base64,",
"data:image/gif;base64,",
];
pub(super) struct Placed<'a> {
beside: Option<&'a Path>,
pictures: &'a Pictures,
/// Pictures copied so far, and their names.
pub copied: Vec<(PathBuf, String)>,
/// This export made the picture folder.
pub made: bool,
}
/// A picture address's file; none for other schemes.
pub fn picture_file(url: &str, beside: Option<&Path>) -> Option<PathBuf> {
if url.to_lowercase().starts_with("file://") {
return Some(file_path(&url["file://".len()..]));
}
if scheme(url).is_some() {
return None;
}
let path = PathBuf::from(decoded(url));
match (path.is_absolute(), beside) {
(true, _) => Some(path),
(false, Some(beside)) => Some(beside.join(path)),
(false, None) => None,
}
}
impl<'a> Placed<'a> {
pub fn new(beside: Option<&'a Path>, pictures: &'a Pictures) -> Self {
Self {
beside,
pictures,
copied: Vec::new(),
made: false,
}
}
pub fn pdf(&self) -> bool {
matches!(self.pictures, Pictures::Pdf(_))
}
/// The `src` for `url`; none when it is not carried.
pub fn place(&mut self, url: &str) -> io::Result<Option<String>> {
if matches!(self.pictures, Pictures::Written) {
return Ok(Some(url.to_owned()));
}
let lower = url.to_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
return Ok((!self.pdf()).then(|| url.to_owned()));
}
if lower.starts_with("data:") {
let readable = match self.pdf() {
true => READABLE_DATA.iter().any(|kind| lower.starts_with(kind)),
false => lower.starts_with("data:image/"),
};
return Ok(readable.then(|| url.to_owned()));
}
match picture_file(url, self.beside) {
Some(path) => self.carried(&path),
None => Ok(None),
}
}
fn carried(&mut self, path: &Path) -> io::Result<Option<String>> {
let extension = path
.extension()
.map(|extension| extension.to_string_lossy().to_lowercase())
.unwrap_or_default();
let folder = match self.pictures {
Pictures::Inline => {
let Ok(bytes) = std::fs::read(path) else {
return Ok(None);
};
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
return Ok(Some(format!("data:{};base64,{data}", mime(&extension))));
}
Pictures::Pdf(_) if !READABLE.contains(&extension.as_str()) => return Ok(None),
Pictures::Folder(folder) | Pictures::Pdf(folder) => folder.clone(),
Pictures::Written => unreachable!("written addresses are not carried"),
};
let named = match self.copied.iter().find(|(from, _)| from == path) {
Some((_, name)) => name.clone(),
None => {
let Ok(mut source) = File::open(path) else {
return Ok(None);
};
if !source.metadata()?.is_file() {
return Ok(None);
}
let name = self.copy(&mut source, path, &folder)?;
self.copied.push((path.to_path_buf(), name.clone()));
name
}
};
let shelf = folder
.file_name()
.map_or_else(String::new, |name| name.to_string_lossy().into_owned());
Ok(Some(format!("{}/{}", encoded(&shelf), encoded(&named))))
}
/// Copies `source` under a name still free.
fn copy(&mut self, source: &mut File, path: &Path, folder: &Path) -> io::Result<String> {
if !self.made {
// A folder already there is someone's: refused.
std::fs::create_dir(folder).map_err(|error| match error.kind() {
io::ErrorKind::AlreadyExists => {
io::Error::new(error.kind(), folder.display().to_string())
}
_ => error,
})?;
self.made = true;
}
let file = path
.file_name()
.map_or_else(String::new, |name| name.to_string_lossy().into_owned());
let (stem, extension) = file.rsplit_once('.').unwrap_or((file.as_str(), ""));
for count in 1.. {
let name = match (count, extension) {
(1, _) => file.clone(),
(_, "") => format!("{stem}-{count}"),
_ => format!("{stem}-{count}.{extension}"),
};
// The file system judges which names are the same.
match File::create_new(folder.join(&name)) {
Ok(mut kept) => {
io::copy(source, &mut kept)?;
return Ok(name);
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
unreachable!("names never run out")
}
}
fn mime(extension: &str) -> &'static str {
match extension {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
"avif" => "image/avif",
_ => "application/octet-stream",
}
}
/// A `file:` address's path; a drive keeps no leading slash.
fn file_path(rest: &str) -> PathBuf {
let path = decoded(rest.strip_prefix("localhost").unwrap_or(rest));
let drive = path.len() > 2 && path.as_bytes()[2] == b':' && path.starts_with('/');
PathBuf::from(if drive { &path[1..] } else { &path })
}
/// `%XX` undone; bytes that are not UTF-8 stay as written.
fn decoded(url: &str) -> String {
let bytes = url.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut at = 0;
while at < bytes.len() {
let hex = bytes
.get(at + 1..at + 3)
.and_then(|pair| std::str::from_utf8(pair).ok());
match (
bytes[at],
hex.and_then(|pair| u8::from_str_radix(pair, 16).ok()),
) {
(b'%', Some(byte)) => {
out.push(byte);
at += 3;
}
(byte, _) => {
out.push(byte);
at += 1;
}
}
}
String::from_utf8(out).unwrap_or_else(|_| url.to_owned())
}
/// A name as one path segment of a URL.
fn encoded(name: &str) -> String {
name.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
-106
View File
@@ -1,106 +0,0 @@
//! Markdown as plain text: words, code and TeX, no marks.
use std::io;
use ely_gpui_component::documents::{
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_tree,
};
use markdown::mdast::Node;
/// `text` as plain text, a blank line between blocks.
pub fn plain(text: &str) -> io::Result<String> {
let tree = markdown_tree(text);
let reader = MarkdownReader::joined(text, &tree);
let mut blocks = Vec::new();
// Alerts and `[toc]` live only at the root.
for child in tree.children().into_iter().flatten() {
match child {
Node::Blockquote(quote) => match alert(quote) {
Some((kind, body)) => {
blocks.push(kind.title().to_string());
for node in &body {
block(&reader, node, &mut blocks);
}
}
None => block(&reader, child, &mut blocks),
},
_ if is_toc(child, text) => {
let titles: Vec<&str> = (reader.headings.iter())
.map(|heading| heading.title.as_str())
.collect();
if !titles.is_empty() {
blocks.push(titles.join("\n"));
}
}
_ => block(&reader, child, &mut blocks),
}
}
Ok(blocks.join("\n\n") + "\n")
}
fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec<String>) {
match node {
Node::Code(code) => blocks.push(code.value.clone()),
Node::Math(math) => blocks.push(math.value.clone()),
Node::List(list) => {
let items = list.children.iter().map(|item| {
let mut inner = Vec::new();
for child in item.children().into_iter().flatten() {
block(reader, child, &mut inner);
}
inner.join("\n")
});
blocks.push(items.collect::<Vec<_>>().join("\n"));
}
Node::Table(table) => {
let rows = table.children.iter().map(|row| {
let cells = row.children().into_iter().flatten();
let cells = cells.map(|cell| words(reader, cell));
cells.collect::<Vec<_>>().join("\t")
});
blocks.push(rows.collect::<Vec<_>>().join("\n"));
}
Node::FootnoteDefinition(note) => {
let mut body = Vec::new();
for child in &note.children {
block(reader, child, &mut body);
}
// The label the drawing shows beside it.
let label = format!("[{}]", reader.label(note));
blocks.push([label, body.join("\n\n")].join(" ").trim_end().to_string());
}
Node::Blockquote(_) => {
for child in node.children().into_iter().flatten() {
block(reader, child, blocks);
}
}
Node::Paragraph(_) => {
let mut read = Vec::new();
reader.block(node, &mut read);
match read.as_slice() {
[MarkdownBlock::Math(tex)] => blocks.push(tex.clone()),
_ => blocks.push(words(reader, node)),
}
}
Node::Heading(_) => blocks.push(words(reader, node)),
Node::Html(_) => {
let mut read = Vec::new();
reader.block(node, &mut read);
for piece in read {
match piece {
MarkdownBlock::Paragraph(text) => blocks.push(text.text),
other => unreachable!("an HTML block reads as text, not {other:?}"),
}
}
}
// Front matter, rules and definitions are not words.
_ => {}
}
}
/// An inline run's words, as the drawing reads them.
fn words(reader: &MarkdownReader, node: &Node) -> String {
reader
.inline(node.children().expect("an inline run holds inlines"))
.text
}
-474
View File
@@ -1,474 +0,0 @@
use std::path::Path;
use super::{Pictures, body, document, plain};
mod visual;
fn page(text: &str, beside: Option<&Path>, pictures: &Pictures) -> super::Page {
body(text, beside, pictures).expect("a page")
}
/// A 1×1 PNG, as base64.
const DOT: &str =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2wAAAAAElFTkSuQmCC";
#[test]
fn formulas_and_diagrams_draw_as_svg() {
let text =
"Inline $x^2$ here.\n\n$$\n\\sqrt{y}\n$$\n\n```mermaid\nflowchart LR\n A --> B\n```\n";
let page = page(text, None, &Pictures::Inline);
assert_eq!(page.html.matches("<svg").count(), 3, "{}", page.html);
assert!(
page.html.contains("<span class=\"math\"><svg")
&& page.html.contains("<div class=\"diagram\"><svg")
);
assert!(page.formulas.is_empty() && page.diagrams.is_empty());
}
#[test]
fn what_does_not_draw_stays_as_code_at_its_own_line() {
let text = "$$\n\\frac{\n$$\n\n$$\n\\frac{\n$$\n\nText $\\sqrt{$ more.\n\n```mermaid\nflowchart LR\n A -->\n```\n\n```mermaid\nflowchart LR\n A[\u{F0000}] --> B\n```\n";
let page = page(text, None, &Pictures::Inline);
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
assert_eq!(lines, [1, 5, 9]);
assert!(page.formulas.iter().all(|(_, why)| !why.is_empty()));
assert_eq!(page.diagrams, [11, 16]);
assert!(
page.html
.contains("<pre><code class=\"language-math\">\\frac{"),
"{}",
page.html
);
assert!(page.html.contains("<code>\\sqrt{</code>"), "{}", page.html);
assert!(
page.html
.contains("<pre><code class=\"language-mermaid\">flowchart LR"),
"{}",
page.html
);
}
#[test]
fn markdown_writes_as_html() {
let text = "# T\n\n- [x] done\n- b\n\n3. c\n\n| l | r |\n|:--|--:|\n| 1 | 2 |\n\n~~gone~~ [a](https://x.org \"t\") [r][ref] [bad](javascript:alert(1))\n\nNote[^1].\n\n[^1]: The note.\n\n[ref]: /docs\n";
let html = page(text, None, &Pictures::Inline).html;
for part in [
"<h1 id=\"h-0\">T</h1>",
"<li><input type=\"checkbox\" disabled checked> done</li>",
"<ol start=\"3\">",
"<th style=\"text-align:left\">l</th>",
"<td style=\"text-align:right\">2</td>",
"<del>gone</del>",
"<a href=\"https://x.org\">a</a>",
"<a href=\"/docs\">r</a>",
"<a>bad</a>",
"<sup><a href=\"#fn-1\">1</a></sup>",
"<div id=\"fn-1\" style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[1]</span><div><p>The note.</p></div></div>",
] {
assert!(html.contains(part), "{part} in {html}");
}
}
#[test]
fn front_matter_and_raw_html_do_not_go_live() {
let page = page(
"---\ntitle: Secret\n---\n\n<script>alert(1)</script>\n\nText.\n",
None,
&Pictures::Inline,
);
assert!(!page.html.contains("Secret"), "{}", page.html);
assert!(!page.html.contains("<script>"), "{}", page.html);
assert!(page.html.contains("<p>Text.</p>"));
}
#[test]
fn inline_pictures_are_data_and_addresses_stay_as_written() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("dot.png"), b"png bytes").expect("writes");
let text = format!(
"![a](dot.png) ![b](gone.png) ![c](https://example.com/a%2Fb.png?q=a%26b&x=1) ![d](data:image/png;base64,{DOT})\n"
);
let page = page(&text, Some(dir.path()), &Pictures::Inline);
assert!(
page.html
.contains("src=\"data:image/png;base64,cG5nIGJ5dGVz\""),
"{}",
page.html
);
assert!(
page.html
.contains("src=\"https://example.com/a%2Fb.png?q=a%26b&amp;x=1\""),
"{}",
page.html
);
assert!(
page.html
.contains(&format!("src=\"data:image/png;base64,{DOT}\"")),
"{}",
page.html
);
assert!(
page.html.contains("<img src=\"gone.png\" alt=\"b\">"),
"{}",
page.html
);
assert_eq!(page.missing, ["gone.png"]);
}
#[test]
fn folder_pictures_copy_once_under_names_of_their_own() {
let dir = tempfile::tempdir().expect("a dir");
for (folder, name) in [("one", "image.png"), ("two", "IMAGE.PNG")] {
std::fs::create_dir(dir.path().join(folder)).expect("makes");
std::fs::write(dir.path().join(folder).join(name), folder).expect("writes");
}
let shelf = dir.path().join("note#1_files");
let text = "![a](one/image.png) ![b](two/IMAGE.PNG) ![c](one/image.png)\n";
let page = page(text, Some(dir.path()), &Pictures::Folder(shelf.clone()));
let mut held: Vec<Vec<u8>> = std::fs::read_dir(&shelf)
.expect("the folder")
.map(|entry| std::fs::read(entry.expect("an entry").path()).expect("reads"))
.collect();
held.sort();
assert_eq!(
held,
[b"one".to_vec(), b"two".to_vec()],
"no picture overwrote another"
);
assert_eq!(
page.html
.matches("src=\"note%231_files/image.png\"")
.count(),
2,
"{}",
page.html
);
}
#[test]
fn a_folder_already_there_is_refused() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("dot.png"), b"p").expect("writes");
let shelf = dir.path().join("taken");
std::fs::create_dir(&shelf).expect("makes");
let error = body(
"![a](dot.png)\n",
Some(dir.path()),
&Pictures::Folder(shelf),
)
.expect_err("refused");
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
}
#[test]
fn a_pdf_leaves_out_what_mupdf_cannot_read_as_words() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("a.webp"), b"w").expect("writes");
let text = "![first](a.webp) ![second](https://example.com/c.png) ![third](gone.png)\n";
let page = page(
text,
Some(dir.path()),
&Pictures::Pdf(dir.path().join("files")),
);
assert_eq!(
page.missing,
["a.webp", "https://example.com/c.png", "gone.png"]
);
assert!(!page.html.contains("<img"), "{}", page.html);
assert!(
page.html.contains("[first]") && page.html.contains("[third]"),
"{}",
page.html
);
}
#[test]
fn other_schemes_and_untitled_pictures_are_missing() {
let page = page("![a](a.png) ![b](javascript:x)\n", None, &Pictures::Inline);
assert_eq!(page.missing, ["a.png", "javascript:x"]);
}
#[test]
fn a_file_address_reads_its_path() {
let dir = tempfile::tempdir().expect("a dir");
let picture = dir.path().join("d o t.png");
std::fs::write(&picture, b"png").expect("writes");
let path = picture
.to_string_lossy()
.replace('\\', "/")
.replace(' ', "%20");
let url = if path.starts_with('/') {
format!("file://{path}")
} else {
format!("file:///{path}")
};
let page = page(&format!("![a](<{url}>)\n"), None, &Pictures::Inline);
assert!(page.missing.is_empty(), "{:?}", page.missing);
assert!(
page.html.contains("data:image/png;base64,cG5n"),
"{}",
page.html
);
}
#[test]
fn a_document_wraps_the_body_and_escapes_its_title() {
let html = document("a<b & c", "<p>x</p>");
assert!(html.starts_with("<!doctype html>"));
assert!(html.contains("<title>a&lt;b &amp; c</title>") && html.contains("<p>x</p>"));
}
#[test]
fn plain_text_keeps_words_code_and_tex() {
let text = "---\ntitle: x\n---\n\n# Head *one*\n\n- a\n- b `c`\n\n| h | i |\n|---|---|\n| 1 | 2 |\n\n$$\n\\sqrt{x}\n$$\n\n> quoted $y$ ![a cat][cat]\n\n[cat]: pic.png\n";
assert_eq!(
plain(text).expect("text"),
"Head one\n\na\nb c\n\nh\ti\n1\t2\n\n\\sqrt{x}\n\nquoted y a cat\n"
);
}
mod files {
use std::path::Path;
use super::super::{Format, Target, write};
use super::DOT;
fn target<'a>(path: &'a Path, work: &'a Path) -> Target<'a> {
Target {
path,
session: None,
revision: 1,
work,
}
}
#[test]
fn a_page_carries_its_pictures_inside() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("dot.png"), b"png").expect("writes");
let out = dir.path().join("out.html");
write(
Format::Page,
"# Hi\n\n![d](dot.png)\n",
Some(dir.path()),
&target(&out, dir.path()),
)
.expect("writes");
let html = std::fs::read_to_string(&out).expect("reads");
assert!(html.starts_with("<!doctype html>") && html.contains("<title>out</title>"));
assert!(html.contains("data:image/png;base64,"), "{html}");
}
#[test]
fn a_linked_page_copies_into_a_new_folder_only() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("dot.png"), b"png").expect("writes");
let out = dir.path().join("out.html");
write(
Format::Linked,
"![d](dot.png)\n",
Some(dir.path()),
&target(&out, dir.path()),
)
.expect("writes");
assert_eq!(
std::fs::read(dir.path().join("out_files/dot.png")).expect("copied"),
b"png"
);
assert!(
std::fs::read_to_string(&out)
.expect("reads")
.contains("src=\"out_files/dot.png\"")
);
std::fs::remove_file(&out).expect("removes");
let error = write(
Format::Linked,
"![d](dot.png)\n",
Some(dir.path()),
&target(&out, dir.path()),
)
.expect_err("the folder is there");
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
assert!(error.to_string().contains("out_files"), "{error}");
assert!(!out.exists(), "nothing written");
assert!(
dir.path().join("out_files/dot.png").exists(),
"the folder found stays"
);
}
#[test]
fn a_linked_page_without_pictures_makes_no_folder() {
let dir = tempfile::tempdir().expect("a dir");
let out = dir.path().join("out.html");
write(
Format::Linked,
"Text.\n",
Some(dir.path()),
&target(&out, dir.path()),
)
.expect("writes");
assert!(!dir.path().join("out_files").exists());
}
#[test]
fn text_is_plain_and_the_open_file_is_never_written() {
let dir = tempfile::tempdir().expect("a dir");
let out = dir.path().join("doc.md");
std::fs::write(&out, "# Kept\n").expect("writes");
let mut aimed = target(&out, dir.path());
aimed.session = Some(&out);
write(Format::Text, "# Other\n", None, &aimed).expect_err("refused");
assert_eq!(std::fs::read_to_string(&out).expect("reads"), "# Kept\n");
let txt = dir.path().join("doc.txt");
write(
Format::Text,
"# Title *here*\n",
None,
&target(&txt, dir.path()),
)
.expect("writes");
assert_eq!(
std::fs::read_to_string(&txt).expect("reads"),
"Title here\n"
);
}
/// The text of a PDF's first page.
fn words(pdf: &Path) -> String {
let mut worker = ely_pdf_engine::Worker::spawn().expect("the worker");
let doc = worker.open(pdf).expect("opens").doc;
worker
.text(doc, 0)
.expect("text")
.iter()
.map(|ch| ch.c)
.collect()
}
#[test]
fn a_pdf_sets_the_page_and_leaves_no_scratch() {
let dir = tempfile::tempdir().expect("a dir");
let work = tempfile::tempdir().expect("work");
let out = dir.path().join("out.pdf");
let text = format!(
"# Heading 标题\n\nInline $x^2$.\n\n![dot](data:image/png;base64,{DOT}) ![lost](gone.png)\n\n```mermaid\nflowchart LR\n A --> B\n```\n"
);
let page = write(
Format::Pdf,
&text,
Some(dir.path()),
&target(&out, work.path()),
)
.expect("writes");
assert_eq!(page.missing, ["gone.png"]);
assert!(page.formulas.is_empty() && page.diagrams.is_empty());
assert_eq!(
std::fs::read_dir(work.path()).expect("work").count(),
0,
"the scratch is gone"
);
let words = words(&out);
assert!(
words.contains("Heading") && words.contains("标题") && words.contains("[lost]"),
"{words:?}"
);
}
#[test]
fn pdfs_at_once_keep_to_their_own_pages() {
let (dir, work) = (
tempfile::tempdir().expect("a dir"),
tempfile::tempdir().expect("work"),
);
let jobs: Vec<_> = (0..4)
.map(|n| {
let (out, work) = (
dir.path().join(format!("{n}.pdf")),
work.path().to_path_buf(),
);
std::thread::spawn(move || {
write(
Format::Pdf,
&format!("# Page{n}\n"),
None,
&target(&out, &work),
)
.expect("writes");
out
})
})
.collect();
for (n, job) in jobs.into_iter().enumerate() {
let out = job.join().expect("the job");
assert!(words(&out).contains(&format!("Page{n}")), "job {n}");
}
}
}
#[test]
fn a_disguised_script_link_does_not_follow() {
let text = "[a](< javascript:alert(1)>) [b](java&#x09;script:alert(1)) [c](java&#10;script:alert(1)) [d](< https://x.org>)\n";
let html = page(text, None, &Pictures::Inline).html;
assert_eq!(html.matches("<a>").count(), 3, "{html}");
assert!(html.contains("href=\" https://x.org\""), "{html}");
}
#[test]
fn a_failed_linked_page_takes_the_folder_it_made() {
let dir = tempfile::tempdir().expect("a dir");
std::fs::write(dir.path().join("dot.png"), b"png").expect("writes");
// A folder in the way: no page.
let out = dir.path().join("out.html");
std::fs::create_dir(&out).expect("makes");
let target = super::Target {
path: &out,
session: None,
revision: 1,
work: dir.path(),
};
super::write(
super::Format::Linked,
"![d](dot.png)\n",
Some(dir.path()),
&target,
)
.expect_err("fails");
assert!(
!dir.path().join("out_files").exists(),
"the folder it made is gone"
);
}
#[test]
fn a_formula_letter_no_font_has_stays_as_tex() {
let page = page(
"Before\n\n$\text{\u{F0000}}$ after.\n",
None,
&Pictures::Inline,
);
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
assert_eq!(lines, [3]);
assert!(
page.html.contains("<code>\text{\u{F0000}}</code>"),
"{}",
page.html
);
}
#[test]
fn a_large_document_exports_whole() {
// Long paragraphs: markdown-rs slows with the block count.
let words = "word ".repeat(200);
let mut text: String = (0..2_200)
.map(|n| format!("Paragraph {n}: {words}\n\n"))
.collect();
text.push_str("The last line.\n");
assert!(text.len() > ely_gpui_component::documents::LARGEST_READ);
assert!(plain(&text).expect("text").ends_with("The last line.\n"));
assert!(
page(&text, None, &Pictures::Inline)
.html
.ends_with("<p>The last line.</p>")
);
}
-253
View File
@@ -1,253 +0,0 @@
//! Exports read the file as the visual mode does (R4.4).
use super::{Pictures, page, plain};
use crate::output::{Page, html::write, markdown_tree, pictures::Placed};
fn html(text: &str) -> String {
page(text, None, &Pictures::Inline).html
}
fn text(text: &str) -> String {
plain(text).expect("plain text")
}
#[test]
fn shortcodes_turn_as_github_turns_them() {
let page = html("Go :smile: x:smile:\n\nx:smile: :smile:\n\na <code>:smile:</code> :smile:\n");
for part in [
"<p>Go 😄 x😄</p>",
"<p>x:smile: :smile:</p>",
"<p>a <code>:smile:</code> 😄</p>",
] {
assert!(page.contains(part), "{part} in {page}");
}
assert_eq!(
text("Go :smile:\n\nx:smile: :smile:\n"),
"Go 😄\n\nx:smile: :smile:\n"
);
}
#[test]
fn inline_tags_keep_what_they_mean_and_nothing_runs() {
let source = "a <u>u</u> <mark>m</mark> <kbd>k</kbd> <span>s</span> <script>x</script> \
<a href=\"javascript:x\">j</a> <a href=\"https://e.test\">e</a>\n";
let page = html(source);
for part in [
"<u>u</u>",
"<mark>m</mark>",
"<code>k</code>",
" s ",
"&lt;script&gt;x&lt;/script&gt;",
"<a>j</a>",
"<a href=\"https://e.test\">e</a>",
] {
assert!(page.contains(part), "{part} in {page}");
}
assert!(
!page.contains("<span") && !page.contains("<script"),
"{page}"
);
assert_eq!(
text("a <b>b</b> <script>x</script>\n"),
"a b <script>x</script>\n"
);
}
#[test]
fn an_html_block_shows_its_text() {
let source = "<div align=\"center\">\n <b>Bold</b> and :smile:\n</div>\n";
let page = html(source);
assert!(
page.contains("<p><strong>Bold</strong> and 😄</p>"),
"{page}"
);
assert!(!page.contains("<div"), "{page}");
assert_eq!(text(source), "Bold and 😄\n");
}
#[test]
fn an_alert_writes_its_kind_and_title() {
let page = html("> [!NOTE]\n> Read this.\n\n- > [!TIP]\n > Not here.\n");
let alert = "<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid #0969da\"><p style=\"font-weight:600;color:#0969da\">Note</p><p>Read this.</p></div>";
assert!(page.contains(alert), "{page}");
assert!(!page.contains("#1a7f37"), "only root quotes alert: {page}");
assert_eq!(text("> [!WARNING]\n> Hot.\n"), "Warning\n\nHot.\n");
}
#[test]
fn a_toc_lists_the_headings_and_links_them() {
let page = html("[toc]\n\n# A\n\n## B :smile:\n");
let toc = "<ul style=\"list-style:none;padding-left:0\"><li style=\"margin-left:0em\"><a href=\"#h-7\">A</a></li><li style=\"margin-left:1em\"><a href=\"#h-12\">B 😄</a></li></ul>";
assert!(page.contains(toc), "{page}");
assert!(page.contains("<h1 id=\"h-7\">A</h1>") && page.contains("<h2 id=\"h-12\">B 😄</h2>"));
assert_eq!(text("[toc]\n\n# A\n\n## B\n"), "A\nB\n\nA\n\nB\n");
}
#[test]
fn tags_crossing_containers_read_as_drawn() {
for (source, shown) in [
("*a <code>b* :smile:</code> :smile:\n", "a b :smile: 😄"),
("a <code>*b</code>* :smile:\n", "a b 😄"),
] {
assert_eq!(text(source), format!("{shown}\n"));
let page = html(source);
assert_eq!(page.matches("😄").count(), 1, "{page}");
assert_eq!(
page.matches(":smile:").count(),
shown.matches(":smile:").count(),
"{page}"
);
}
}
#[test]
fn each_cell_and_block_reads_on_its_own() {
let page = html("| x:smile: | :smile: |\n| --- | --- |\n| :smile: | :smile: |\n");
assert_eq!(page.matches("😄").count(), 3, "{page}");
let page = html("# a <code>x\n\n:smile:\n");
assert!(page.contains("<p>😄</p>"), "{page}");
}
#[test]
fn a_block_closes_the_tags_left_open_in_it() {
let page = html("a <b>bold\n\nnormal\n");
assert!(
page.contains("<p>a <strong>bold</strong></p><p>normal</p>"),
"{page}"
);
let page = html("a <a href=\"https://example.test\">link\n\nnormal\n");
assert!(page.ends_with("<p>normal</p>"), "{page}");
}
#[test]
fn every_toc_entry_has_its_heading() {
let page = html("[toc]\n\n# Main\n\n[^n]:\n ## Unused\n");
let targets: Vec<&str> = page
.split("href=\"#")
.skip(1)
.map(|rest| &rest[..rest.find('"').expect("a quote")])
.collect();
assert_eq!(targets.len(), 2, "{page}");
for target in targets {
assert!(
page.contains(&format!("id=\"{target}\"")),
"{target} in {page}"
);
}
}
#[test]
fn a_long_html_block_writes_in_linear_time() {
let took = |count: usize| {
let source = format!("<div>{}</div>\n", "<b>x</b> ".repeat(count));
let start = std::time::Instant::now();
html(&source);
start.elapsed()
};
let (small, large) = (took(8_000), took(32_000));
// Four times the input; squared would be sixteen.
assert!(large < small * 8, "{small:?} then {large:?}");
}
#[test]
fn notes_under_deep_marks_write_in_linear_time() {
let took = |count: usize| {
let source = format!(
"start {}{}
[^n]: note
",
"<b>".repeat(count),
"x[^n] ".repeat(count)
);
let tree = markdown_tree(&source);
let mut placed = Placed::new(None, &Pictures::Inline);
let start = std::time::Instant::now();
write(&tree, &source, &mut placed, &mut Page::default()).expect("written");
start.elapsed()
};
// Parsing grows squared upstream; untimed.
let (small, large) = (took(4_000), took(16_000));
assert!(large < small * 8, "{small:?} then {large:?}");
}
#[test]
fn a_blank_formula_is_dropped_with_its_text() {
for source in [
"$ $\n",
"$ $ ![p](p.png)\n",
"| $ $ | a |\n| - | - |\n| b | c |\n",
] {
html(source);
}
let page = page("$ $ ![p](p.png)\nnext $\\sqrt{$\n", None, &Pictures::Inline);
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
assert_eq!(lines, [2], "{}", page.html);
}
#[test]
fn a_note_in_a_link_keeps_the_link_whole() {
let page = html("a <a href=\"https://example.test\">b[^n] c</a> after\n\n[^n]: note\n");
assert!(
page.contains("<a href=\"https://example.test\">b<sup>1</sup> c</a>"),
"{page}"
);
}
#[test]
fn a_reference_reaches_its_own_note() {
let page = html("[^1]: unused\n\nCall[^used].\n\n[^used]: right target\n");
assert_eq!(page.matches("id=\"fn-1\"").count(), 1, "{page}");
let at = page.find("id=\"fn-1\"").expect("an anchor");
assert!(
page[at..].contains("right target") && !page[at..].contains("unused"),
"{page}"
);
}
#[test]
fn plain_notes_keep_the_labels_drawn_beside_them() {
assert_eq!(
text("A[^b] B[^a].\n\n[^a]: Alpha\n\n[^b]: Beta\n"),
"A\u{b9} B\u{b2}.\n\n[2] Alpha\n\n[1] Beta\n"
);
assert_eq!(text("x\n\n[^n]: note\n"), "x\n\n[n] note\n");
}
#[test]
fn a_copied_fragment_carries_its_look() {
let page = html("> [!NOTE]\n> Read this.\n\n[toc]\n\n# Title\n\nCall[^n].\n\n[^n]: note\n");
for part in [
"border-left:.25em solid #0969da",
"font-weight:600",
"list-style:none",
"display:flex",
] {
assert!(page.contains(part), "{part} in {page}");
}
assert!(
!page.contains("class="),
"the look needs no page style: {page}"
);
}
#[test]
fn a_soft_break_joins_in_exports_as_typora_exports_it() {
let page = html("你好\n测试 \nend\n");
assert!(page.contains("<p>你好 测试<br>end</p>"), "{page}");
assert_eq!(text("你好\n测试\n"), "你好 测试\n");
}
#[test]
fn tex_delimiters_export_as_formulas() {
let source = r"a \(x^2\) b
\[
y+1
\]
";
let page = page(source, None, &Pictures::Inline);
assert_eq!(page.html.matches("<svg").count(), 2, "{}", page.html);
assert!(page.formulas.is_empty(), "{:?}", page.formulas);
assert_eq!(text(source), "a x^2 b\n\ny+1\n");
}
+6 -99
View File
@@ -40,7 +40,7 @@ pub struct Extra {
/// The text as last loaded or saved: the merge base.
pub base: String,
pub policy: PolicyEngine,
/// The text change autosave last heard.
/// The page change autosave last heard.
pub heard: u64,
/// The policy revision an autosave writes.
pub writing: Option<u64>,
@@ -51,23 +51,8 @@ pub struct Extra {
/// The last recovery point's mark, and one in flight.
pub pointed: Option<String>,
pub pointing: bool,
/// When a point last landed, and the failure last shown.
pub pointed_at: Option<std::time::SystemTime>,
pub point_failed: Option<std::io::ErrorKind>,
/// A kept tab's mode, place and text came back once.
pub restored: bool,
/// Disk reads started; only the newest lands.
pub reads: u64,
/// The writing view reached its document.
pub looked: 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 {
@@ -82,11 +67,7 @@ impl Default for Extra {
slot: String::new(),
pointed: None,
pointing: false,
pointed_at: None,
point_failed: None,
restored: false,
reads: 0,
looked: false,
}
}
}
@@ -210,18 +191,13 @@ impl Desk {
/// Changed: a clean tab follows, a dirty one asks.
fn read_disk(&mut self, id: u64, path: PathBuf, cx: &mut Context<Self>) {
let Some(tab) = self.tab_mut(id) else {
return;
};
tab.extra.reads += 1;
let number = tab.extra.reads;
let reading = path.clone();
cx.spawn(async move |desk, cx| {
let read = cx
.background_executor()
.spawn(async move { read_still(&reading) })
.await;
desk.update(cx, |desk, cx| desk.disk_read(id, path, number, read, cx))
desk.update(cx, |desk, cx| desk.disk_read(id, path, read, cx))
.ok();
})
.detach();
@@ -231,37 +207,21 @@ impl Desk {
&mut self,
id: u64,
path: PathBuf,
number: u64,
read: io::Result<(Fingerprint, Source)>,
cx: &mut Context<Self>,
) {
let Some(tab) = self.tab(id).filter(|tab| tab.session.path() == Some(&path)) else {
return log::info!("mark: a read for a moved or closed tab was dropped");
};
if tab.extra.reads != number {
return log::info!("mark: read {number} of tab {id} lapsed");
}
let (fingerprint, disk) = match read {
Ok(read) => read,
Err(error) => return self.set_outside(id, Outside::Unread(error.to_string()), cx),
};
// The disk moved on since the read: look again.
if Fingerprint::of(&path).ok() != Some(fingerprint) {
log::info!("mark: tab {id}'s file changed while read");
return self.look(id, 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) {
// The same text: only the file underneath is new.
(true, _) => {
self.adopt_disk(id, &disk, fingerprint, 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),
(true, _) | (false, false) => self.take_disk(id, disk, fingerprint, cx),
(false, true) => {
let tab = self.tab_mut(id).expect("open");
tab.session.external_modified();
@@ -286,57 +246,15 @@ impl Desk {
disk: Source,
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 and its view take the file as saved.
pub(crate) 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) {
self.set_outside(id, Outside::Unread(error.to_string()), cx);
return None;
return self.set_outside(id, Outside::Unread(error.to_string()), cx);
}
tab.extra.base = disk.text.clone();
// The file holds the disk's text: it is saved.
let saved = crate::engine::mark(&disk.text);
tab.mark = saved.clone();
tab.extra.restart_autosave();
// Its bytes (BOM, line ends) are what saves keep now.
tab.doc.lock().expect("the text lock").source = disk.clone();
tab.extra.policy = PolicyEngine::new(Policy::MarkAutoSave, true);
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.
@@ -345,9 +263,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);
view.update(cx, |view, cx| view.saved(&saved, cx));
Some(view)
}
pub fn set_outside(&mut self, id: u64, outside: Outside, cx: &mut Context<Self>) {
@@ -403,13 +320,6 @@ impl Desk {
fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<RecommendedWatcher> {
let (sender, mut changes) = mpsc::unbounded();
let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
// Reads, ours included, are no change.
if event
.as_ref()
.is_ok_and(|event| ely_ui::watching::only_read(&event.kind))
{
return;
}
let _ = sender.unbounded_send(event.map(|_| ()).map_err(|error| error.to_string()));
})?;
for dir in dirs {
@@ -435,6 +345,3 @@ fn watch(dirs: &[PathBuf], cx: &mut Context<Desk>) -> notify::Result<Recommended
log::info!("mark: watching {} folders", dirs.len());
Ok(watcher)
}
#[cfg(test)]
pub(crate) mod tests;
-182
View File
@@ -1,182 +0,0 @@
use std::{
path::PathBuf,
time::{Duration, Instant},
};
use ely_files::Action;
use ely_office::prelude::*;
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::{Extra, GAP, Outside};
use crate::desk::{
Desk,
tests::{open, start},
};
#[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(_)
));
}
/// A saved file open: its desk, tab and path.
pub(crate) fn opened(
text: &str,
cx: &mut TestAppContext,
) -> (
Entity<Desk>,
&'static mut VisualTestContext,
u64,
PathBuf,
[tempfile::TempDir; 2],
) {
let (data, files) = (
tempfile::tempdir().expect("data"),
tempfile::tempdir().expect("files"),
);
let path = files.path().join("a.md");
std::fs::write(&path, text).expect("a file");
let (desk, cx) = start(cx, data.path());
open(&desk, &path, cx);
let id = desk.read_with(cx, |desk, _| desk.core.active.expect("a tab"));
(desk, cx, id, path, [data, files])
}
/// The desk looks at the file, as the watcher would.
pub(crate) fn look(desk: &Entity<Desk>, id: u64, cx: &mut VisualTestContext) {
desk.update(cx, |desk, cx| desk.look(id, cx));
cx.run_until_parked();
cx.executor().advance_clock(GAP * 2);
cx.run_until_parked();
}
pub(crate) fn shown(desk: &Entity<Desk>, cx: &mut VisualTestContext) -> String {
desk.read_with(cx, |desk, cx| {
let view = desk.active_view().expect("a tab");
view.read(cx).document().read(cx).text(cx)
})
}
#[gpui::test]
fn a_clean_tab_takes_the_disk_and_keeps_its_place(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("one\n\ntwo\n\nthree\n", cx);
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
let document = view.read_with(cx, |view, _| view.document().clone());
document.update(cx, |document, cx| document.set_caret(4, 2, cx));
std::fs::write(&path, "uno\n\ndos\n\ntres, longer\n").expect("an outside write");
look(&desk, id, cx);
assert_eq!(shown(&desk, cx), "uno\n\ndos\n\ntres, longer\n");
assert!(!view.read_with(cx, |view, _| view.modified()));
assert_eq!(
document.read_with(cx, |document, cx| document.caret(cx)),
(4, 2)
);
let focus = view.read_with(cx, |view, _| view.focus.clone());
cx.update(|window, cx| window.focus(&focus, cx));
cx.run_until_parked();
cx.simulate_keystrokes("secondary-z");
assert_eq!(
shown(&desk, cx),
"uno\n\ndos\n\ntres, longer\n",
"undo stops at the disk"
);
}
#[gpui::test]
fn a_deleted_file_keeps_its_text_and_says_so(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("kept\n", cx);
std::fs::remove_file(&path).expect("deleted");
look(&desk, id, cx);
let deleted = desk.read_with(cx, |desk, _| {
matches!(desk.tab(id).expect("open").extra.outside, Outside::Deleted)
});
assert!(deleted);
assert_eq!(shown(&desk, cx), "kept\n");
}
#[gpui::test]
fn a_renamed_file_is_followed(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("moved\n", cx);
let to = path.with_file_name("b.md");
std::fs::rename(&path, &to).expect("renamed");
look(&desk, id, cx);
let now = desk.read_with(cx, |desk, _| {
desk.tab(id)
.and_then(|tab| tab.session.path().map(PathBuf::from))
});
assert_eq!(
now.map(|path| path.file_name().map(ToOwned::to_owned)),
Some(Some("b.md".into()))
);
}
#[gpui::test]
fn a_path_that_cannot_be_read_says_why(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("text\n", cx);
std::fs::remove_file(&path).expect("removed");
std::fs::create_dir(&path).expect("a folder in its place");
look(&desk, id, cx);
let unread = desk.read_with(cx, |desk, _| {
matches!(
desk.tab(id).expect("open").extra.outside,
Outside::Unread(_)
)
});
assert!(unread);
}
#[gpui::test]
fn a_replace_that_keeps_the_time_still_reloads(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("old text\n", cx);
let modified = std::fs::metadata(&path)
.expect("meta")
.modified()
.expect("a time");
let next = path.with_extension("new");
std::fs::write(&next, "new text\n").expect("a new file");
let file = std::fs::File::options()
.write(true)
.open(&next)
.expect("open");
file.set_modified(modified).expect("the old time");
drop(file);
std::fs::rename(&next, &path).expect("a replace");
look(&desk, id, cx);
assert_eq!(shown(&desk, cx), "new text\n");
}
#[gpui::test]
fn a_stale_read_never_lands(cx: &mut TestAppContext) {
let (desk, cx, id, path, _dirs) = opened("first\n", cx);
std::fs::write(&path, "older\n").expect("an older write");
let older = (
ely_files::Fingerprint::of(&path).expect("its fingerprint"),
crate::text::Source::decode(b"older\n".to_vec()).expect("reads"),
);
std::fs::write(&path, "newest disk\n").expect("a newer write");
look(&desk, id, cx);
assert_eq!(shown(&desk, cx), "newest disk\n");
let reads = desk.read_with(cx, |desk, _| desk.tab(id).expect("open").extra.reads);
for number in [reads - 1, reads] {
let (path, older) = (path.clone(), (older.0, older.1.clone()));
desk.update(cx, |desk, cx| {
desk.disk_read(id, path, number, Ok(older), cx)
});
cx.run_until_parked();
assert_eq!(shown(&desk, cx), "newest disk\n", "read {number}");
}
}
+92
View File
@@ -0,0 +1,92 @@
//! The bundled page: its folder and its input.
use std::path::{Path, PathBuf};
use anyhow::{Result, bail};
use ely_web_content::WebView;
use gpui::App;
use serde_json::json;
/// The app's Resources, else this build's `engines/content`.
pub fn content() -> Result<PathBuf> {
let bundled = std::env::current_exe()?
.parent()
.and_then(|dir| Some(dir.parent()?.join("Resources/content")));
let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark");
for dir in bundled.into_iter().chain([built]) {
if dir.join("index.html").is_file() {
log::info!("mark: page from {}", dir.display());
return Ok(dir);
}
}
bail!("ElyMark's page is not built; run scripts/build-mark-content.sh")
}
/// How the page shows a document (MARK-02).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Visual,
Source,
Preview,
}
impl Mode {
pub const ALL: [Mode; 3] = [Mode::Visual, Mode::Source, Mode::Preview];
pub fn name(self) -> &'static str {
match self {
Mode::Visual => "visual",
Mode::Source => "source",
Mode::Preview => "preview",
}
}
pub fn parse(name: &str) -> Option<Mode> {
Mode::ALL.into_iter().find(|mode| mode.name() == name)
}
}
/// Shows `markdown` as `mode` after load `load`, maybe placed.
pub fn open(
view: &WebView,
markdown: &str,
(mode, load, place): (Mode, u64, Option<crate::view::Place>),
folder: &Path,
cx: &App,
) {
let text = |key| ely_ui::i18n::text(cx, key, &[]).to_string();
let texts = json!({
"searchLanguage": text("mark.search_language"),
"noResult": text("mark.no_result"),
"copy": text("mark.copy"),
"preview": text("mark.preview"),
"loading": text("mark.loading"),
"edit": text("mark.edit"),
"hide": text("mark.hide"),
});
// Relative links and images resolve in the document's folder.
let base = format!("{}/", ely_web_content::file_url(folder));
view.run_js(&format!(
"ely.open({}, '{}', {texts}, {load}, {}, {})",
json!(markdown),
mode.name(),
json!(base),
json!(place)
));
}
/// The same text in another mode; pending edits go first.
pub fn switch(view: &WebView, mode: Mode) {
view.run_js(&format!("ely.mode('{}')", mode.name()));
}
/// The folder for relative links and images.
pub fn folder(view: &WebView, folder: &Path) {
let base = format!("{}/", ely_web_content::file_url(folder));
view.run_js(&format!("ely.base({})", json!(base)));
}
/// Calls `ely.<name>(argument)` in the page.
pub fn call(view: &WebView, name: &str, argument: &serde_json::Value) {
view.run_js(&format!("ely.{name}({argument})"));
}
+229
View File
@@ -0,0 +1,229 @@
//! Rendered Markdown as files: HTML pages and their images (MARK-11).
use std::path::{Path, PathBuf};
use base64::Engine as _;
/// A whole HTML page: the body, its title, its stylesheet.
pub fn page(title: &str, style: &str, body: &str) -> String {
format!(
"<!doctype html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n{style}\n</head>\n<body>\n{body}\n</body>\n</html>\n",
escape(title)
)
}
fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// `%XX` decoded, as links to files are written.
fn decoded(text: &str) -> String {
let bytes = text.as_bytes();
let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0);
while at < bytes.len() {
let hex = bytes
.get(at + 1..at + 3)
.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok());
match (bytes[at], hex) {
(b'%', Some(byte)) => (out.push(byte), at += 3),
(byte, _) => (out.push(byte), at += 1),
};
}
String::from_utf8_lossy(&out).into_owned()
}
/// One path segment for a URL: reserved bytes percent-escaped.
pub fn segment(name: &str) -> String {
let mut out = String::new();
for byte in name.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
/// A file image's path; web links stay as they are.
fn local(src: &str, folder: &Path) -> Option<PathBuf> {
if src.starts_with("data:") || src.starts_with('#') {
return None;
}
if let Some(path) = src.strip_prefix("file://") {
return Some(PathBuf::from(decoded(path)));
}
if src.contains("://") {
return None;
}
let path = PathBuf::from(decoded(src.split(['?', '#']).next().unwrap_or(src)));
Some(if path.is_absolute() {
path
} else {
folder.join(path)
})
}
fn kind(path: &Path) -> &'static str {
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default();
match extension.to_lowercase().as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"bmp" => "image/bmp",
"avif" => "image/avif",
_ => "application/octet-stream",
}
}
/// The page's HTML; image `src` values replaced by numbered tokens.
#[derive(Clone, Debug, Default)]
pub struct Marked {
pub html: String,
/// The original `src` values, decoded, in order.
pub images: Vec<String>,
pub token: String,
}
impl Marked {
/// From the page's answer; anything missing is an error.
pub fn read(rendered: &serde_json::Value) -> std::io::Result<Marked> {
let bad = || std::io::Error::other("the page's render lacks its parts");
let html = rendered["html"].as_str().ok_or_else(bad)?.to_owned();
let token = rendered["token"].as_str().ok_or_else(bad)?.to_owned();
let images = rendered["images"].as_array().ok_or_else(bad)?;
let images = images
.iter()
.map(|src| src.as_str().map(str::to_owned).ok_or_else(bad));
let images = images.collect::<std::io::Result<_>>()?;
Ok(Marked {
html,
images,
token,
})
}
/// Each mark swapped for `each` of its file; unread listed.
fn rewrite(
&self,
folder: &Path,
mut each: impl FnMut(&Path) -> Option<String>,
) -> (String, Vec<String>) {
let (mut html, mut missing) = (self.html.clone(), Vec::new());
for (n, src) in self.images.iter().enumerate() {
let new = match local(src, folder) {
None => src.clone(),
Some(path) => each(&path).unwrap_or_else(|| {
missing.push(src.clone());
src.clone()
}),
};
// Quoted, so mark 1 never matches inside mark 10.
let mark = format!("\"{}{n}\"", self.token);
html = html.replacen(&mark, &format!("\"{}\"", escape(&new)), 1);
}
(html, missing)
}
/// Images as data URLs: one file carries all (self-contained).
pub fn inline(&self, folder: &Path) -> (String, Vec<String>) {
self.rewrite(folder, |path| {
let bytes = std::fs::read(path).ok()?;
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
Some(format!("data:{};base64,{data}", kind(path)))
})
}
/// Images linked into the `assets` folder; and the copies.
pub fn linked(
&self,
folder: &Path,
assets: &str,
) -> (String, Vec<(PathBuf, String)>, Vec<String>) {
let mut copies: Vec<(PathBuf, String)> = Vec::new();
let (html, missing) = self.rewrite(folder, |path| {
if !path.is_file() {
return None;
}
if let Some((_, name)) = copies.iter().find(|(from, _)| from == path) {
return Some(format!("{}/{}", segment(assets), segment(name)));
}
let file = path.file_name()?.to_string_lossy().into_owned();
// Same names from different folders stay apart.
let name = format!("{}-{file}", copies.len() + 1);
let url = format!("{}/{}", segment(assets), segment(&name));
copies.push((path.to_path_buf(), name));
Some(url)
});
(html, copies, missing)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// HTML as the page marks it.
fn marked(images: &[&str]) -> Marked {
let token = "ely-image-t-".to_owned();
let html = (0..images.len())
.map(|n| format!("<img alt=\"a > b src='x'\" src=\"{token}{n}\">"))
.collect();
Marked {
html,
images: images.iter().map(|src| src.to_string()).collect(),
token,
}
}
#[test]
fn images_inline_and_links_stay() {
let dir = tempfile::tempdir().expect("a temp dir");
std::fs::create_dir(dir.path().join("assets")).expect("assets");
std::fs::write(dir.path().join("assets/a b.png"), b"png").expect("an image");
let page = marked(&["assets/a%20b.png", "https://e.x/y.png", "gone.png"]);
let (out, missing) = page.inline(dir.path());
assert!(out.contains(r#"src="data:image/png;base64,cG5n""#), "{out}");
assert!(out.contains(r#"src="https://e.x/y.png""#));
assert!(
out.contains(r#"alt="a > b src='x'""#),
"alt text stays: {out}"
);
assert_eq!(missing, vec!["gone.png".to_string()]);
}
#[test]
fn linked_images_copy_once_under_encoded_names() {
let dir = tempfile::tempdir().expect("a temp dir");
std::fs::write(dir.path().join("a#b.png"), b"1").expect("an image");
let mut page = marked(&["a%23b.png"; 11]);
page.images[10] = "a%23b.png".into();
let (out, copies, missing) = page.linked(dir.path(), "report#1_files");
assert_eq!(
out.matches("src=\"report%231_files/1-a%23b.png\"").count(),
11,
"{out}"
);
assert_eq!(copies.len(), 1);
assert!(missing.is_empty());
}
#[test]
fn a_page_lacking_its_parts_is_an_error() {
assert!(Marked::read(&serde_json::json!({ "html": "<p>" })).is_err());
}
#[test]
fn titles_escape() {
assert!(page("a<b>&", "", "").contains("<title>a&lt;b&gt;&amp;</title>"));
}
}
+18 -38
View File
@@ -13,14 +13,9 @@ use crate::folder::{Folder, markdown_under};
impl Desk {
/// A folder lists beside; a file opens in a tab.
pub fn open_path(&mut self, path: PathBuf, cx: &mut Context<Self>) {
if path.is_dir() {
return self.open_folder(path, cx);
}
// A shown file only takes focus; the list still learns.
let shown = self.core.sessions.is_open(&path).unwrap_or(false);
self.open_file(path.clone(), cx);
if shown {
self.remember(&path, cx);
match path.is_dir() {
true => self.open_folder(path, cx),
false => self.open_file(path, cx),
}
}
@@ -33,7 +28,7 @@ impl Desk {
let files = cx
.background_executor()
.spawn(async move {
let real = dunce::canonicalize(&listed)?;
let real = std::fs::canonicalize(&listed)?;
Ok::<_, std::io::Error>((markdown_under(&real)?, real))
})
.await;
@@ -70,7 +65,7 @@ impl Desk {
/// Puts `path` first in the recent list.
pub fn remember(&mut self, path: &std::path::Path, cx: &mut Context<Self>) {
// One spelling per file: its real path.
let remembered = dunce::canonicalize(path)
let remembered = std::fs::canonicalize(path)
.and_then(|path| ely_files::recent::remember(&self.recent_file, &path));
match remembered {
Ok(list) => self.recent = list,
@@ -87,7 +82,7 @@ impl Desk {
/// Tab `id` shows `path`; the recent list follows.
pub fn track_recent(&mut self, id: u64, path: &std::path::Path, cx: &mut Context<Self>) {
let Ok(real) = dunce::canonicalize(path) else {
let Ok(real) = std::fs::canonicalize(path) else {
return log::warn!("mark: {} is gone; the recent list keeps it", path.display());
};
if self.remembered.get(&id) == Some(&real) {
@@ -113,62 +108,47 @@ impl Desk {
}
}
/// Empties the recent list.
pub fn clear_recent(&mut self, cx: &mut Context<Self>) {
match ely_files::recent::clear(&self.recent_file) {
Ok(()) => {
log::info!("mark: the recent list is cleared");
self.recent.clear();
cx.notify();
}
Err(error) => {
log::error!("mark: the recent list did not clear: {error}");
let said = text(cx, "office.error.engine", &[("error", &error.to_string())]);
self.toast(said, Severity::Danger, cx);
}
}
}
/// Line and column `at` in a tab or file.
/// `place` in `tab`, else in `file` once open.
pub fn open_at(
&mut self,
tab: Option<u64>,
file: PathBuf,
at: (usize, usize),
place: serde_json::Value,
window: &mut Window,
cx: &mut Context<Self>,
) {
let held = tab.or_else(|| self.tab_of(&file));
if let Some(id) = held.filter(|id| self.tab(*id).is_some()) {
self.core.active = Some(id);
if let Some(view) = self.tab(id).map(|open| open.view.clone()) {
let view = self.tab(id).map(|open| open.view.clone());
if let Some(view) = view {
view.update(cx, |view, cx| view.reveal(place, cx));
// After the list's own key handling, which keeps focus.
window.defer(cx, move |window, cx| {
view.update(cx, |view, cx| view.reveal(at, window, cx))
});
let focus = view.read(cx).focus.clone();
window.defer(cx, move |window, cx| window.focus(&focus, cx));
}
return cx.notify();
}
// A file gone is reported by the open itself.
if let Ok(real) = dunce::canonicalize(&file) {
self.going = Some((real, at));
if let Ok(real) = std::fs::canonicalize(&file) {
self.going = Some((real, place));
}
self.open_file(file, cx);
}
/// The tab showing `path`, by real path.
pub fn tab_of(&self, path: &std::path::Path) -> Option<u64> {
let real = dunce::canonicalize(path).ok()?;
let real = std::fs::canonicalize(path).ok()?;
let held = |tab: &&ely_office::Tab<crate::outside::Extra, crate::engine::Mark>| {
tab.session
.path()
.and_then(|open| dunce::canonicalize(open).ok())
.and_then(|open| std::fs::canonicalize(open).ok())
== Some(real.clone())
};
self.core.tabs.iter().find(held).map(|tab| tab.id)
}
/// Tab `id`'s text now, unsaved edits included.
/// Tab `id`'s text as the page last sent it.
pub fn text_of(&self, id: u64) -> Option<String> {
Some(
self.tab(id)?
+49 -117
View File
@@ -14,14 +14,15 @@ use serde::{Deserialize, Serialize};
use crate::desk::Desk;
use crate::engine::mark;
use crate::view::{Mode, Place};
use crate::page::Mode;
use crate::view::Place;
/// A kept tab: file, mode, place, recovery folder.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Kept {
path: Option<PathBuf>,
name: String,
mode: Mode,
mode: String,
place: Option<Place>,
slot: String,
}
@@ -36,8 +37,6 @@ struct Waiting {
kept: Kept,
real: Option<PathBuf>,
text: Option<(String, SystemTime)>,
/// The untitled tab made for it, once made.
tab: Option<u64>,
}
/// The session's files and what still waits to restore.
@@ -46,8 +45,6 @@ pub struct Keeping {
crash: Option<Crash>,
waiting: Vec<Waiting>,
written: Option<String>,
/// The kind of session write failure last shown.
unwritten: Option<io::ErrorKind>,
/// A quit began: the kept list stays as then.
pub leaving: bool,
/// The list as kept when the quit began.
@@ -91,7 +88,7 @@ impl Keeping {
Marker::Clean | Marker::Discarded => None,
};
if let Some(path) = &kept.path {
match dunce::canonicalize(path) {
match std::fs::canonicalize(path) {
// The file reopens as it is on disk.
Ok(real) => waiting.push(Waiting {
kept: Kept {
@@ -100,7 +97,6 @@ impl Keeping {
},
real: Some(real),
text: None,
tab: None,
}),
Err(error) => {
log::warn!("mark: a kept file does not open: {error}");
@@ -115,7 +111,6 @@ impl Keeping {
kept,
real: None,
text,
tab: None,
});
}
}
@@ -131,46 +126,19 @@ impl Keeping {
crash: Some(crash),
waiting,
written: None,
unwritten: None,
leaving: false,
frozen: Vec::new(),
};
Ok((keeping, paths, missing))
}
/// The untitled tabs to make: their place here and name.
pub fn untitled(&self) -> Vec<(usize, String)> {
let waiting = self.waiting.iter().enumerate();
let untitled =
waiting.filter(|(_, waiting)| waiting.kept.path.is_none() && waiting.tab.is_none());
untitled
.map(|(at, waiting)| (at, waiting.kept.name.clone()))
.collect()
}
/// Entry `at` belongs to untitled tab `tab`.
pub fn made(&mut self, at: usize, tab: u64) {
self.waiting[at].tab = Some(tab);
}
/// What tab `id` takes: its file or its copy.
fn take(&mut self, real: Option<&PathBuf>, id: u64) -> Option<Waiting> {
let at = self
/// The untitled tabs to make, with their names.
pub fn untitled(&self) -> Vec<String> {
let untitled = self
.waiting
.iter()
.position(|waiting| match (&waiting.real, real) {
(Some(kept), Some(open)) => kept == open,
(None, None) => waiting.tab == Some(id),
_ => false,
})?;
Some(self.waiting.remove(at))
}
/// An end that never asked: the lock and points stay.
pub fn hold(&mut self) {
if self.crash.take().is_some() {
log::warn!("mark: the session ends unasked; its points stay");
}
.filter(|waiting| waiting.kept.path.is_none());
untitled.map(|waiting| waiting.kept.name.clone()).collect()
}
/// A clean end: no crash next time, no stale points.
@@ -221,20 +189,27 @@ impl Desk {
let real = tab
.session
.path()
.and_then(|path| dunce::canonicalize(path).ok());
let Some(Waiting {
kept, text: back, ..
}) = self.keeping.take(real.as_ref(), id)
.and_then(|path| std::fs::canonicalize(path).ok());
let untitled = tab.session.path().is_none();
let waiting = &mut self.keeping.waiting;
let Some(at) = waiting
.iter()
.position(|waiting| match (&waiting.real, &real) {
(Some(kept), Some(open)) => kept == open,
(None, None) => untitled,
_ => false,
})
else {
return;
};
let mode = kept.mode;
let Waiting {
kept, text: back, ..
} = waiting.remove(at);
let mode = Mode::parse(&kept.mode).unwrap_or(Mode::Visual);
log::info!("mark: restores tab {id} as {}", mode.name());
let tab = self.tab_mut(id).expect("open");
// A recovered copy keeps writing to the same points.
tab.extra.slot = kept.slot.clone();
// Its point stands until a newer lands.
tab.extra.pointed_at = back.as_ref().map(|(_, at)| *at);
let view = tab.view.clone();
if let Some((_, at)) = &back {
let at = humantime(*at);
@@ -246,18 +221,7 @@ impl Desk {
self.toast(said, ely_gpui_component::primitives::Severity::Info, cx);
}
let back = back.map(|(back, _)| back);
// Modes take focus: in the window, after this update.
cx.spawn(async move |desk, cx| {
let shown = ely_office::in_window(&desk, cx, |_, window, cx| {
view.update(cx, |view, cx| {
view.reopen(mode, kept.place, back, window, cx)
})
});
if let Err(error) = shown {
log::warn!("mark: tab {id} did not restore: {error:#}");
}
})
.detach();
view.update(cx, |view, cx| view.reopen(mode, kept.place, back, cx));
}
/// Every ten seconds: unsaved text to points, tabs to disk.
@@ -275,8 +239,8 @@ impl Desk {
kept.push(Kept {
path: tab.session.path().map(Path::to_path_buf),
name: tab.name.to_string(),
mode: view.mode,
place: Some(view.place(cx)),
mode: view.mode.name().to_owned(),
place: view.place,
slot: tab.extra.slot.clone(),
});
let current = tab.doc.lock().expect("the text lock").current.clone();
@@ -293,12 +257,11 @@ impl Desk {
.spawn(async move {
let now = SystemTime::now();
let limits = ely_platform::storage::limits(ely_platform::AppId::Mark)?;
let mut recovery = Recovery::new(folder)
.with_limits(limits.keep, limits.megabytes * 1024 * 1024);
let mut recovery = Recovery::new(folder).with_limits(limits.keep, limits.megabytes * 1024 * 1024);
recovery.write_once(current.as_bytes(), None, now)
})
.await;
desk.update(cx, |desk, cx| desk.pointed(id, point, wrote, &slot, cx))
desk.update(cx, |desk, _| desk.pointed(id, point, wrote, &slot))
.ok();
})
.detach();
@@ -318,18 +281,11 @@ impl Desk {
}
if !self.keeping.leaving {
self.keeping.frozen = kept.clone();
self.write_session(Saved { tabs: kept }, cx);
self.write_session(Saved { tabs: kept });
}
}
fn pointed(
&mut self,
id: u64,
point: String,
wrote: io::Result<Option<PathBuf>>,
slot: &Path,
cx: &mut Context<Self>,
) {
fn pointed(&mut self, id: u64, point: String, wrote: io::Result<Option<PathBuf>>, slot: &Path) {
// The tab closed while it wrote: the point goes too.
let Some(tab) = self.tab_mut(id) else {
if let Err(error) = clear(slot) {
@@ -338,55 +294,21 @@ impl Desk {
return;
};
tab.extra.pointing = false;
let error = match wrote {
Ok(_) => {
tab.extra.pointed = Some(point);
tab.extra.pointed_at = Some(SystemTime::now());
tab.extra.point_failed = None;
return;
}
Err(error) => error,
};
log::error!("mark: a recovery point for tab {id} failed: {error}");
// Each kind of failure shows once; the next tick retries.
if tab.extra.point_failed == Some(error.kind()) {
return;
match wrote {
Ok(_) => tab.extra.pointed = Some(point),
Err(error) => log::error!("mark: a recovery point for tab {id} failed: {error}"),
}
tab.extra.point_failed = Some(error.kind());
let error = error.to_string();
let at = match tab.extra.pointed_at {
Some(at) => humantime(at),
None => text(cx, "office.stopped.never", &[]).to_string(),
};
let name = tab.name.to_string();
let said = text(
cx,
"mark.recovery.failed",
&[("name", &name), ("error", &error), ("at", &at)],
);
self.toast_unlogged(said, ely_gpui_component::primitives::Severity::Danger, cx);
}
fn write_session(&mut self, saved: Saved, cx: &mut Context<Self>) {
fn write_session(&mut self, saved: Saved) {
let json = serde_json::to_string_pretty(&saved).expect("the session serializes");
if self.keeping.written.as_ref() == Some(&json) {
return;
}
let file = self.keeping.dir.join("session.json");
let error = match ely_files::save_atomic(&file, json.as_bytes(), 0) {
Ok(_) => {
self.keeping.written = Some(json);
self.keeping.unwritten = None;
return;
}
Err(error) => error,
};
log::error!("mark: the session did not save: {error}");
// Each kind of failure shows once; the next tick retries.
if self.keeping.unwritten != Some(error.kind()) {
self.keeping.unwritten = Some(error.kind());
let said = text(cx, "mark.session.failed", &[("error", &error.to_string())]);
self.toast(said, ely_gpui_component::primitives::Severity::Danger, cx);
match ely_files::save_atomic(&file, json.as_bytes(), 0) {
Ok(_) => self.keeping.written = Some(json),
Err(error) => log::error!("mark: the session did not save: {error}"),
}
}
@@ -434,7 +356,7 @@ impl Desk {
}
}
let tabs = self.keeping.frozen.clone();
self.write_session(Saved { tabs }, cx);
self.write_session(Saved { tabs });
}
/// A quit or close begins: the tabs now stay kept.
@@ -460,4 +382,14 @@ fn humantime(at: SystemTime) -> String {
}
#[cfg(test)]
mod tests;
mod tests {
#[test]
fn a_place_without_a_column_reads_zero() {
let kept: super::Kept = serde_json::from_str(
r#"{"path":null,"name":"a.md","mode":"source","place":{"line":3,"scroll":0},"slot":"s"}"#,
)
.expect("an older place reads");
let place = kept.place.expect("a place");
assert_eq!((place.line, place.column), (3, 0));
}
}
Loaded 100 of 1264 files, more files were not shown because too many files have changed in this diff. Show more