1 Commits
Author SHA1 Message Date
雷电芽衣 8be14052ae Relicense Ely Office's own code from AGPL-3.0 to MIT
release / macos (push) Canceled after 0s
release / windows (push) Canceled after 0s
release / linux (push) Canceled after 0s
Applied on top of the released v0.1.5 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:33 -04:00
287 changed files with 709 additions and 20609 deletions

No files matched your search

@@ -1,21 +1,17 @@
# A tag builds, packages and uploads all three platforms.
# Tags only: no pull request ever reaches the self-hosted runners.
name: release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
macos:
# A GUI-session agent: daemons see no login keychain.
runs-on: [self-hosted, macos-signing]
runs-on: macos-arm64
timeout-minutes: 720
steps:
- name: Build, package and upload for macOS
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_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
@@ -24,45 +20,42 @@ jobs:
ELY_NOTARY_PROFILE: ${{ vars.ELY_NOTARY_PROFILE }}
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
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
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]
runs-on: windows
timeout-minutes: 360
steps:
- name: Build, package and upload for Windows
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_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
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
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
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]
runs-on: windows
needs: windows
timeout-minutes: 360
steps:
- name: Build, package and upload for Linux
shell: bash
env:
ELY_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ELY_RELEASE_TOKEN: ${{ secrets.GITEA_TOKEN }}
WSLENV: ELY_RELEASE_TOKEN
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
[ -d "$dir/.git" ] || git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git "$dir"
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 }}"
-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
+1 -2
View File
@@ -18,5 +18,4 @@ node_modules/
/assets/papirus_icons-master.zip
/Gitea.md
/.work/
/TyporaRef
/oauth.env
/TyporaRef
+8 -5
View File
@@ -1,12 +1,15 @@
# Contributing
## Opening a pull request on GitHub
## Opening a pull request on git.zacharyzhang.com
1. Fork https://github.com/ELY-Open-Source/ElyOffice and clone your fork:
Forking is disabled on this server. Open pull requests with AGit instead:
1. Register an account at https://git.zacharyzhang.com
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
```bash
git clone https://github.com/<your-account>/ElyOffice.git
git clone https://git.zacharyzhang.com/ZacharyZhang-NY/ElyOffice.git
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
```
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.
3. To update the pull request, push again with the same `topic`.
Generated
+35 -637
View File
File diff suppressed because it is too large. Load diff
+2 -18
View File
@@ -5,7 +5,7 @@ members = ["apps/*", "crates/*"]
exclude = ["engines", "reference"]
[workspace.package]
version = "0.1.9"
version = "0.1.5"
edition = "2024"
rust-version = "1.95"
license = "MIT"
@@ -19,7 +19,7 @@ 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://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "90bd37b5504809eed42f0a0a1e3f7e20ac6973ff" }
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"] }
@@ -39,15 +39,10 @@ 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"] }
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"] }
@@ -58,21 +53,10 @@ 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" }
+13 -11
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 与本地附件 |
| ElyAgent | 独立的 Agent 配置与会话管理 | 嵌入的 Kigi CLI Rust 代码 | 配置、会话记录、运行状态 |
LOK、MuPDF 和 GPUI 是已确定的方向。邮件协议 crate、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能。它不是其他七个应用的聊天侧栏,也不是整个套件的控制中枢。[S19]
@@ -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 正文和富文本撰写区不用 CEF 与 Electron,渲染方式随 ElyMail 任务另定;不能把正文 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 | 写信 | 支持收件人、抄送、密送、回复、回复全部、转发、签名、附件和纯文本/富文本撰写 |
@@ -632,7 +630,7 @@ C/C++ 异常、原生错误与资源释放在 FFI 边界处理,不能跨 Rust
### 17.3 内容渲染
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文按块的子集由 GPUI 绘制,写信用 Markdown 文档组件(§12.1)。
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文和富文本撰写的渲染方式随 ElyMail 任务另定,定前不写成已有。
Ely 主题 token 用于内容区。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示。
@@ -768,6 +766,7 @@ 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] |
| OCR、字体、词典和媒体组件 | 记录每个资源的来源、版本和再分发条件,不从系统目录随意拷贝受限资源 |
@@ -842,7 +841,7 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
| LOK 三端嵌入 | Doc/Table/Show 的编辑、保存、中文输入与窗口操作录像及日志 | 修复适配或更新设计决策;不能只交付查看器 |
| LOK 高级功能 | 母版、放映、图表、修订、输入法、打印和无障碍的接口映射及用例 | 补齐桥接或维护必要上游补丁,不跳回上游原生 UI |
| MuPDF 编辑 | 字体、对象修改、表单、删除及保存的样本结果 | 修复对象编辑与输出;不得改为视觉覆盖冒充编辑 |
| 邮件协议 crate | 三端 TLS、OAuth、IMAP 与 POP3 同步、SMTP 发送和 MIME 测试 | 修复或换 crate;换后端须显式修订选型 |
| MailCore 2 | 三端可重复构建、TLS、OAuth、同步和 MIME 测试 | 处理依赖及适配问题;替换后端必须显式修订选型 |
| Markdown 原生编辑 | 可视、源码、预览的输入、焦点、缩放、输入法和辅助技术测试 | 未通过不能以 CEF、WebView 或 Electron 代替 |
| Markdown 原文保留 | 无编辑保存、模式切换、局部修改和外部冲突的字节级差异 | 修复映射与保存;未知语法回到原文模式,不删除 |
| 邮箱服务注册 | 正式 OAuth 配置、适用审核状态与用户流程 | 不将未获许可的内置登录描述为可用 |
@@ -896,8 +895,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`
@@ -936,5 +935,8 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
[S23] Git 官方 COPYING 文件。
`https://raw.githubusercontent.com/git/git/master/COPYING`
[S24] MailCore 2 官方 LICENSE 文件。
`https://raw.githubusercontent.com/MailCore/mailcore2/master/LICENSE`
[S25] Typora 官方快捷键说明;对照的程序为 Typora 1.14.10 Windows 安装包(本机 `TyporaRef`,不入库)。
`https://support.typora.io/Shortcut-Keys/`
+16 -36
View File
@@ -14,7 +14,7 @@
| 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 与 Electron) |
| Tree | Git CLI,参数数组启动 |
| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析与导出 HTML,RaTeX 公式,mermaid-rs-renderer 图;PDF 经 `ely-pdf-worker` 由 MuPDF 排版 |
| Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 |
@@ -23,7 +23,7 @@
```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(邮件协议、同步与本地存储)
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端);ely-mail-engine 随其任务建
reference/ 上游源码,只读,不属于本项目,不审查
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
@@ -33,14 +33,14 @@ assets/ icons/ 是八个应用的图标母版(SVG)与渲染出的 icns
dist/ 打包产物,不入库
docs/ 兼容清单、无障碍、帮助、许可、性能记录;engineering-risks.md、qa.md 等内部笔记只在本机,不入库
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 90bd37b,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
| 需要 | 用 |
| --- | --- |
@@ -80,7 +80,7 @@ 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`:以不断行的空格占出公式的宽(整个公式作一个词折行),排好的公式画在其上,按下处读回公式的原文 |
| gpui 文本里不能嵌图片:行内公式无从排版 | 组件库的 `MarkdownRenderer` 把行内公式显示为 TeX 原文(着色),块公式才排版 |
| `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 |
@@ -123,7 +123,7 @@ scripts/fetch-references.sh 取上游源码到 reference/
scripts/fetch-tessdata.sh 取 OCR 语言数据到 engines/tessdata(钉提交,核对散列)
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 轮次 路径 审查一条任务(herdr 右侧分栏,codex gpt-6-astra,effort max,fast)
scripts/apply-lo-patches.sh engines/lo-src 找出源码已到补丁序列第几个,补上其余(构建脚本调用)
tests/scripts/lo-patches.sh 补丁序列从中途接着打、再打一次为空操作
tests/scripts/media-targets.sh 媒体播放接口在 Linux、Windows 目标可编译(缺目标时 SKIP)
@@ -132,9 +132,8 @@ tests/scripts/linux-backends.sh Linux 构建带 Wayland 与 X11;Hyprland 上
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 的检出把符号链接写成文件,不出源码包)
packaging/source.sh dist 本提交的源码包与随包的 copyleft 上游源码(打过补丁的 MuPDF、Git),文件名带提交号;工作区有未提交改动即拒绝(macOS 的 release.sh 与 CI 的 Windows、Linux 调用)
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 按窗口截取)
@@ -142,14 +141,12 @@ tests/scripts/linux-distros.sh deb 在 Ubuntu 26.04、rpm 在 Fedora 44 容器
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/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,构建出的包不声明任何文件类型
@@ -172,15 +169,13 @@ 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
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(静态资源,另有 `site/worker` 只答 `/release.json`:Gitea API 的最新版本与全部发布的下载总数(不计更新清单 `.json`),缓存 5 分钟;下载链接指向 `releases/latest`),地址 https://ely-office.zhangyanghaha0407.workers.dev
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
cargo check --workspace
@@ -200,19 +195,20 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 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-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:右侧属性栏宽取 `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-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)接入应用主题,不留引擎默认色。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`。
@@ -274,9 +270,9 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 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-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` 分装引擎;每个平台都上传本提交的源码包(`packaging/source.sh`);更新清单只含 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 启动带黑色命令行窗口、“打开方式”里无图标、点 X 不退出、表格全黑无网格线):四处根因。命令行窗口:应用是控制台子系统;发行构建改为 `windows_subsystem = "windows"`(开发构建照旧有控制台看日志),子进程(两个 worker、Git、`cmd start`)一律经 `ely_ipc::windowless` 以 `CREATE_NO_WINDOW` 启动。图标:exe 里只有 gpui 的清单资源;各应用共用构建脚本 `packaging/windows/icon.rs`,把 `assets/icons/<应用>.ico` 编成资源 1(gpui 的窗口图标也取资源 1)。点 X 不退出:窗口其实弹了“保存更改”询问。`ely.xcd` 深色方案的 `CalcNotesBackground` 与浅色不同,Calc 在配色变化时改写批注样式并记一步撤销(`ScDetectiveFunc::UpdateAllComments`),未动的表格切到深色即算已修改;两套方案的批注底色改为同一值(LibreOffice 默认的浅黄),切换主题不再改文档,三端同此(`sheet.rs` 的 `a_theme_switch_is_no_edit`)。全黑:Win32 的虚拟设备没有 `SetSizeUsingBuffer`(基类默认返回假,断言在发行构建里不在),给客户缓冲区的设备停在 1×1 像素,Calc 图块只画了一个像素,放映图层同空;补丁 `0044-lok-win32-paints-then-copies.patch` 让 Windows 走 macOS 的路:按全尺寸画进设备,再拷进客户缓冲区(`doc_paintTile` 与 `SlideshowLayerRenderer`)。Writer 自设设备尺寸,原本不受影响。`tests/scripts/lo-patches.sh` 的临时库也设 `core.autocrlf false`(原先在 Windows 上恒失败)。
- 2026-10-11(用户报:Windows 上标题栏的最小化、最大化、关闭三个按钮都按不动):gpui 回答系统的命中测试(`WM_NCHITTEST`)时按绘制次序取第一个含指针的窗口控制区,标题栏的拖动区先于其中的按钮画出,按钮上一律答“标题栏”,系统把按下当作拖动窗口的开始,点击到不了按钮。修复在 `vendor/gpui-pre`(原样一个提交,其后是修复):按命中测试的次序取最上层的控制区,且只看未被遮挡的部分(同 `is_hovered`,盖在标题栏上的对话框挡住其下的按钮)。实测(发行构建,DPI 感知的探针移动真实指针):自右向左依次答关闭、最大化、最小化、拖动;按下最小化即最小化,最大化即最大化,关闭即退出、worker 随之结束;v0.1.3 的安装版同一探针处处答拖动。该回调只有 Windows 平台调用。
- 2026-10-11:Linux 标题栏按钮(核查 Windows 按钮问题时查出)。macOS 的红绿灯由系统画,不受影响;Linux 为客户端装饰,按钮由组件库画在标题栏的拖动区里,拖动区对任何按下都准备移动窗口,按住按钮时指针动一像素即开始移动窗口,合成器接管指针,松开到不了按钮,点击落空。组件库 3eb4d34:`drag_region` 只在按下未被里面的按钮认领(按钮以 `prevent_default` 认领)时才准备移动;`shell/title_tests.rs` 核对晃动的按下点得动 Linux 的关闭、标题栏空处照样移动窗口(去掉修复时前者失败)。未在真 Linux 桌面上点过。
- 2026-10-11(用户报:ElyTree 改动列表里文件名长时盖住行尾的打开按钮):组件库 bd01470:`ChangesList` 行里的文件名原为不缩的盒子,长名越过行宽压在按钮下;现可缩、以省略号结尾,先于按钮让位;`git/tests/narrow.rs` 核对 260 宽时文件名止于按钮之前(原布局时失败)。
@@ -301,7 +297,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 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-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`。强化运行时下 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`)只切到该标签,也加入列表。
@@ -310,19 +306,3 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 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。
-36
View File
@@ -332,39 +332,3 @@ fn an_outline_click_goes_where_its_row_was_drawn() {
});
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");
}
-12
View File
@@ -11,20 +11,8 @@ build = "../../packaging/windows/icon.rs"
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 -18
View File
@@ -1,25 +1,8 @@
// 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()
}
}
}
+3 -2
View File
@@ -10,7 +10,7 @@ mod tests;
use std::{io, path::Path};
use ely_gpui_component::documents::markdown_tree;
use ely_gpui_component::documents::markdown_options;
pub use files::{Format, Target, write};
pub use pictures::{Pictures, picture_file};
@@ -43,7 +43,8 @@ pub fn body(text: &str, beside: Option<&Path>, pictures: &Pictures) -> io::Resul
/// `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 tree = markdown::to_mdast(text, &markdown_options())
.map_err(|error| io::Error::other(error.to_string()))?;
let mut page = Page::default();
html::write(&tree, text, placed, &mut page)?;
log::info!(
+4 -11
View File
@@ -3,13 +3,14 @@
use std::io;
use ely_gpui_component::documents::{
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_tree,
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_options,
};
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 tree = markdown::to_mdast(text, &markdown_options())
.map_err(|error| io::Error::other(error.to_string()))?;
let reader = MarkdownReader::joined(text, &tree);
let mut blocks = Vec::new();
// Alerts and `[toc]` live only at the root.
@@ -74,15 +75,7 @@ fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec<String>) {
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::Paragraph(_) | Node::Heading(_) => blocks.push(words(reader, node)),
Node::Html(_) => {
let mut read = Vec::new();
reader.block(node, &mut read);
+2 -16
View File
@@ -1,7 +1,7 @@
//! 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};
use crate::output::{Page, html::write, markdown_options, pictures::Placed};
fn html(text: &str) -> String {
page(text, None, &Pictures::Inline).html
@@ -160,7 +160,7 @@ fn notes_under_deep_marks_write_in_linear_time() {
"<b>".repeat(count),
"x[^n] ".repeat(count)
);
let tree = markdown_tree(&source);
let tree = markdown::to_mdast(&source, &markdown_options()).expect("parsed");
let mut placed = Placed::new(None, &Pictures::Inline);
let start = std::time::Instant::now();
write(&tree, &source, &mut placed, &mut Page::default()).expect("written");
@@ -237,17 +237,3 @@ fn a_soft_break_joins_in_exports_as_typora_exports_it() {
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");
}
+3 -3
View File
@@ -36,7 +36,7 @@ fn fraction(key: &TileKey, size: (f32, f32)) -> [f32; 4] {
impl PdfView {
/// The pages in view and the tiles each needs.
pub fn showing(&self, sf: f32) -> Vec<Showing> {
pub fn showing(&self, sf: f32, dark: bool) -> Vec<Showing> {
let laid = self.laid_out();
let (room_w, room_h) = (f32::from(self.room.size.width), f32::from(self.room.size.height));
let (left, top) = (-f32::from(self.scroll.offset().x), -f32::from(self.scroll.offset().y));
@@ -50,7 +50,7 @@ impl PdfView {
let keys = ys
.flat_map(|ty| xs.clone().map(move |tx| (tx, ty)))
.map(|(tx, ty)| {
let key = TileKey { page: place.page, scale, rotate: self.rotate, tx, ty };
let key = TileKey { page: place.page, scale, rotate: self.rotate, dark, tx, ty };
(key, [tx * TILE, ty * TILE, TILE.min(dw - tx * TILE), TILE.min(dh - ty * TILE)])
})
.collect();
@@ -123,7 +123,7 @@ mod tests {
#[test]
fn a_tile_keeps_its_place_as_a_fraction_of_its_page() {
let key = TileKey { page: 0, scale: 2000, rotate: 0, tx: 1, ty: 0 };
let key = TileKey { page: 0, scale: 2000, rotate: 0, dark: false, tx: 1, ty: 0 };
// A 612 by 792 page at 2x: 1224 by 1584.
assert_eq!(fraction(&key, (612.0, 792.0)), [512.0 / 1224.0, 0.0, 512.0 / 1224.0, 512.0 / 1584.0]);
let turned = TileKey { rotate: 90, ..key };
+9 -5
View File
@@ -16,13 +16,14 @@ use crate::view::{PdfView, Reported};
/// A tile's side in device pixels.
pub const TILE: i32 = 512;
/// A page tile at one scale and turn.
/// A page tile at one scale, turn and theme.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct TileKey {
pub page: usize,
/// Device pixels a point, times a thousand.
pub scale: u32,
pub rotate: i32,
pub dark: bool,
pub tx: i32,
pub ty: i32,
}
@@ -122,14 +123,17 @@ impl PdfView {
/// Pages in view and their tiles; missing ones asked.
pub fn page_view(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let sf = window.scale_factor();
let (sunken, paper) = (cx.theme().colors.sunken, gpui::white());
let (sf, dark) = (window.scale_factor(), cx.theme().is_dark());
let (sunken, paper) = {
let colors = &cx.theme().colors;
(colors.sunken, if dark { colors.surface } else { colors.paper })
};
let laid = self.laid_out();
if let Some((x, y)) = self.go_to.take() {
self.scroll.set_offset(point(px(-x), px(-y)));
}
let top = -f32::from(self.scroll.offset().y);
let showing = self.showing(sf);
let showing = self.showing(sf, dark);
// The current page: first showing more than a sliver.
if let Some(first) = showing.iter().map(|s| s.place).find(|p| p.y + p.h > top + GAP * 3.0) {
let paired = showing.iter().any(|s| s.place.page == self.current) && first.page / 2 == self.current / 2;
@@ -262,7 +266,7 @@ impl PdfView {
self.painting = true;
let (doc, scale, version) = (self.doc, key.scale as f32 / 1000.0, self.version);
self.off(
move |worker| worker.render(doc, key.page, (scale, key.rotate), rect),
move |worker| worker.render(doc, key.page, (scale, key.rotate), rect, key.dark),
move |view, painted, cx| {
view.painting = false;
if view.version != version {
+13 -13
View File
@@ -100,11 +100,11 @@ impl PdfView {
}
/// Asks for the first missing picture in `rows`.
fn ask_thumb(&mut self, rows: Range<usize>, sf: f32, cx: &mut Context<Self>) {
fn ask_thumb(&mut self, rows: Range<usize>, (sf, dark): (f32, bool), cx: &mut Context<Self>) {
if self.thumbing {
return;
}
let due = |page: &usize| !self.thumbs.contains_key(page) || self.thumbs_stale.contains(page);
let due = |page: &usize| !self.thumbs.contains_key(&(*page, dark)) || self.thumbs_stale.contains(&(*page, dark));
let Some(page) = rows.clone().find(|page| due(page) && !self.thumbs_failed.contains(page)) else {
return;
};
@@ -114,7 +114,7 @@ impl PdfView {
let (doc, version) = (self.doc, self.version);
self.thumbing = true;
self.off(
move |worker| worker.render(doc, page, (scale, 0), tile),
move |worker| worker.render(doc, page, (scale, 0), tile, dark),
move |view, painted, cx| {
view.thumbing = false;
if view.version != version {
@@ -128,8 +128,8 @@ impl PdfView {
});
match painted.map(|bgra| crate::pages::picture((tile[2] as u32, tile[3] as u32), bgra)) {
Ok(Some(image)) => {
view.thumbs_stale.remove(&page);
if let Some(old) = view.thumbs.insert(page, image) {
view.thumbs_stale.remove(&(page, dark));
if let Some(old) = view.thumbs.insert((page, dark), image) {
view.retired.push(old);
}
}
@@ -149,18 +149,18 @@ impl PdfView {
impl PdfView {
/// The picture a row draws; an old one until repainted.
pub fn picture(&self, page: usize) -> Option<Arc<RenderImage>> {
self.thumbs.get(&page).cloned()
pub fn picture(&self, page: usize, dark: bool) -> Option<Arc<RenderImage>> {
self.thumbs.get(&(page, dark)).cloned()
}
}
/// One row: the page number and its picture.
fn row(view: &Entity<PdfView>, page: usize, cx: &App) -> gpui::AnyElement {
fn row(view: &Entity<PdfView>, page: usize, dark: bool, cx: &App) -> gpui::AnyElement {
let read = view.read(cx);
let colors = &cx.theme().colors;
let (w, h) = read.sizes[page];
let picked = read.picked.contains(&page);
let picture = read.picture(page);
let picture = read.picture(page, dark);
let count = read.sizes.len();
let label = Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]);
let (clicked, pressed, dropped) = (view.clone(), view.clone(), view.clone());
@@ -210,7 +210,7 @@ fn row(view: &Entity<PdfView>, page: usize, cx: &App) -> gpui::AnyElement {
.h(px(height))
.border_1()
.border_color(if picked { colors.focus } else { colors.border })
.bg(gpui::white())
.bg(if dark { colors.surface } else { colors.paper })
.when_some(picture, |frame, picture| frame.child(img(picture).size_full())),
)
.child(div().text_xs().text_color(colors.fg_subtle).child((page + 1).to_string()))
@@ -258,12 +258,12 @@ impl Render for Side {
Pane::Notes => crate::notelist::list(&view, cx),
Pane::Files => crate::attach::list(&view, cx),
Pane::Pages => {
let sf = window.scale_factor();
let shown = (window.scale_factor(), cx.theme().is_dark());
let count = view.read(cx).sizes.len();
let rows = view.clone();
let list = uniform_list("pdf-thumbs", count, move |range, _, cx| {
rows.update(cx, |view, cx| view.ask_thumb(range.clone(), sf, cx));
range.map(|page| row(&rows, page, cx)).collect()
rows.update(cx, |view, cx| view.ask_thumb(range.clone(), shown, cx));
range.map(|page| row(&rows, page, shown.1, cx)).collect()
})
.size_full();
div()
+9 -8
View File
@@ -188,13 +188,13 @@ fn tiles_beside_the_view_are_neither_asked_nor_kept() {
/// Flags any notify leaving a shown page with nothing drawn.
fn never_bare(cx: &mut VisualTestContext, view: &Entity<PdfView>) -> (gpui::Subscription, std::rc::Rc<std::cell::Cell<bool>>) {
let sf = cx.update(|window, _| window.scale_factor());
let (sf, dark) = cx.update(|window, app| (window.scale_factor(), ely_gpui_component::theme::ActiveTheme::theme(app).is_dark()));
let bare = std::rc::Rc::new(std::cell::Cell::new(false));
let seen = bare.clone();
let watch = cx.update(|_, app| {
app.observe(view, move |view, app| {
let read = view.read(app);
if read.showing(sf).iter().any(|page| read.pieces(page).is_empty()) {
if read.showing(sf, dark).iter().any(|page| read.pieces(page).is_empty()) {
seen.set(true);
}
})
@@ -248,10 +248,10 @@ fn a_new_scale_keeps_the_old_tiles_until_new_ones_land() {
cx.simulate_resize(gpui::size(px(700.), px(800.)));
// The new size lays out before any new tile lands.
cx.run_until_parked();
let sf = cx.update(|window, _| window.scale_factor());
let (sf, dark) = cx.update(|window, app| (window.scale_factor(), ely_gpui_component::theme::ActiveTheme::theme(app).is_dark()));
let now = cx.update(|_, app| {
let read = view.read(app);
read.showing(sf).iter().map(|page| read.pieces(page).len()).collect::<Vec<_>>()
read.showing(sf, dark).iter().map(|page| read.pieces(page).len()).collect::<Vec<_>>()
});
assert!(now.iter().all(|count| *count > 0), "drawn right after the resize: {now:?}");
settle(cx, |app| view.read(app).laid_out().scale != scale && repainted(view.read(app)));
@@ -315,7 +315,7 @@ fn old_tiles_are_painted_and_freed_with_their_tab() {
let shown = cx.update(|window, app| {
let read = view.read(app);
let room = read.room;
read.showing(window.scale_factor()).iter().map(|s| {
read.showing(window.scale_factor(), false).iter().map(|s| {
let (left, top) = (-f32::from(read.scroll.offset().x), -f32::from(read.scroll.offset().y));
let w = (s.place.x + s.place.w).min(left + f32::from(room.size.width)) - s.place.x.max(left);
let h = (s.place.y + s.place.h).min(top + f32::from(room.size.height)) - s.place.y.max(top);
@@ -333,8 +333,9 @@ fn an_edit_keeps_page_pictures_until_they_repaint() {
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["Hello"])])], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).picture(0).is_some() && repainted(view.read(app)));
let old = cx.update(|_, app| view.read(app).picture(0).expect("a picture"));
let dark = cx.update(|_, app| ely_gpui_component::theme::ActiveTheme::theme(app).is_dark());
settle(cx, |app| view.read(app).picture(0, dark).is_some() && repainted(view.read(app)));
let old = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
// A picture on its way holds back the repaint.
cx.update(|_, app| view.update(app, |view, _| view.thumbing = true));
cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![note()], cx)));
@@ -348,7 +349,7 @@ fn an_edit_keeps_page_pictures_until_they_repaint() {
cx.notify()
}));
settle(cx, |app| view.read(app).thumbs_stale.is_empty());
let new = cx.update(|_, app| view.read(app).picture(0).expect("a picture"));
let new = cx.update(|_, app| view.read(app).picture(0, dark).expect("a picture"));
assert!(!std::sync::Arc::ptr_eq(&new, &old), "the new picture replaces it");
super::frames(cx);
assert!(cx.update(|window, _| window.has_image_atlas_entry(&new)), "and is drawn");
+1 -1
View File
@@ -158,7 +158,7 @@ fn scanned(dir: &std::path::Path) -> std::path::PathBuf {
let doc = worker.open(&source).expect("opens").doc;
let s: f32 = 200.0 / 72.0;
let (w, h) = ((612.0 * s).ceil() as i32, (792.0 * s).ceil() as i32);
let bgra = worker.render(doc, 0, (s, 0), [0, 0, w, h]).expect("draws").bgra;
let bgra = worker.render(doc, 0, (s, 0), [0, 0, w, h], false).expect("draws").bgra;
let gray: Vec<u8> = bgra.chunks_exact(4).map(|p| ((u16::from(p[0]) + u16::from(p[1]) + u16::from(p[2])) / 3) as u8).collect();
let path = dir.join("scan.pdf");
std::fs::write(&path, scan((612.0, 792.0), (w as u32, h as u32), &gray)).expect("writes");
+1 -19
View File
@@ -1,6 +1,6 @@
//! Password prompts, zoomed pairs, jumps across (C11.1).
use ely_office::{Dialog, Dialogs as _, Opening as _, Tabs as _};
use ely_office::{Dialog, Dialogs as _, Tabs as _};
use gpui::{Entity, Modifiers, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, point, px};
use super::{file, frames, settle, shown, windowed};
@@ -144,21 +144,3 @@ fn ctrl_and_a_wheel_notch_step_the_zoom_once() {
frames(cx);
assert_eq!(read(cx).0, Zoom::Scale(crate::keys::stepped(1.0, false)));
}
#[test]
fn a_dark_app_shows_the_page_as_the_file_paints_it() {
use ely_gpui_component::theme::{Mode, Theme};
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[], true);
cx.update(|_, app| Theme::set_mode_now(Mode::Dark, app));
let path = file(dir.path(), "one.pdf", &[(612.0, 792.0, &["alpha"])]);
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.open_file(path, cx)));
settle(cx, |app| viewer.read(app).active_view().is_some());
let view = shown(cx, &viewer);
let corner = |app: &gpui::App| view.read(app).tiles.iter().find(|(key, _)| key.tx == 0 && key.ty == 0).map(|(_, image)| image.clone());
settle(cx, |app| corner(app).is_some());
let tile = cx.update(|_, app| corner(app)).expect("the first tile");
let bytes = tile.as_bytes(0).expect("its pixels");
assert_eq!(&bytes[..3], &[255, 255, 255], "the paper stays white");
}
+3 -3
View File
@@ -87,11 +87,11 @@ pub struct PdfView {
pub saved: String,
/// Saves so far: a read from before one is stale.
pub saves: u64,
/// Page pictures for the side panel, by page.
pub thumbs: HashMap<usize, Arc<RenderImage>>,
/// Page pictures for the side panel, by page and darkness.
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
pub thumbs_failed: HashSet<usize>,
/// Pictures from before an edit; shown until repainted.
pub thumbs_stale: HashSet<usize>,
pub thumbs_stale: HashSet<(usize, bool)>,
/// A picture is on its way from the worker.
pub thumbing: bool,
/// Pages picked in the side panel, and the anchor.
+4 -4
View File
@@ -100,12 +100,12 @@ impl Host for Deck {
view.turn_parts();
});
}
// Pictures before the scheme landed are repainted.
let lit = view.read(cx).lit();
// Pictures follow the kernel's light or dark scheme.
let dark = view.read(cx).dark();
if let Some(tab) = self.tab_mut(id)
&& tab.extra.lit != lit
&& tab.extra.dark != dark
{
tab.extra.lit = lit;
tab.extra.dark = dark;
self.repaint_all(id, cx);
}
self.offer_menu(id, view, cx);
+2 -2
View File
@@ -50,8 +50,8 @@ pub struct Extra {
pub painting: bool,
/// The parts are masters.
pub masters: bool,
/// The pictures were painted with Ely's scheme.
pub lit: bool,
/// The scheme the pictures were painted with.
pub dark: Option<bool>,
/// The view was fitted to the slide once.
pub fitted: bool,
}
+1 -1
View File
@@ -267,7 +267,7 @@ impl Deck {
let range = output.pages();
let (w, h) = tab.extra.size;
let size = (PICTURE, (f64::from(PICTURE) * h as f64 / w.max(1) as f64).round() as u32);
let (copy, light) = (tab.work.join("output.odp"), ely_lok::SCHEME);
let (copy, light) = (tab.work.join("output.odp"), ely_lok::scheme(false));
let (shown, opened) = (folder.display().to_string(), folder.clone());
log::info!("show: writes slide pictures");
self.read_in(
+2 -2
View File
@@ -252,7 +252,7 @@ impl Deck {
// A show starts from black; effects play on the page.
let behind = match &preview.playing {
Playing::Transition { .. } => gpui::black(),
Playing::Effects { .. } => ely_lok::paper(),
Playing::Effects { .. } => ely_lok::paper(cx),
};
let content = match &preview.playing {
Playing::Effects {
@@ -266,7 +266,7 @@ impl Deck {
} else {
(t / preview.length) as f32
};
let paper = ely_lok::paper();
let paper = ely_lok::paper(cx);
// Exits are not read; the old slide shows everything.
let from = (from.as_deref())
.map(|from| compose::slide(from, |_| true, frame, paper).into_any_element());
+1 -1
View File
@@ -159,7 +159,7 @@ impl Render for Presenter {
let big = stage::fit(show.slide, (room.width * 0.6, room.height * 0.8));
let small = stage::fit(show.slide, (room.width * 0.3, room.height * 0.35));
let next = match show.upcoming() {
Some(part) => stage::still(show, part, small),
Some(part) => stage::still(show, part, small, cx),
None => div().w(small.0).h(small.1).into_any_element(),
};
let notes = show.part().map(|part| show.slides[part].notes.clone());
+3 -3
View File
@@ -27,7 +27,7 @@ pub fn stage(show: &Show, (w, h): (Pixels, Pixels), cx: &App) -> AnyElement {
let Some(stack) = show.stack(part) else {
return black((w, h));
};
let paper = ely_lok::paper();
let paper = ely_lok::paper(cx);
let t = show.entered.elapsed().as_secs_f64();
let slide = &show.slides[part];
let shown = match &show.leaving {
@@ -76,9 +76,9 @@ pub fn stage(show: &Show, (w, h): (Pixels, Pixels), cx: &App) -> AnyElement {
}
/// A slide as it first shows, still.
pub fn still(show: &Show, part: usize, size: (Pixels, Pixels)) -> AnyElement {
pub fn still(show: &Show, part: usize, size: (Pixels, Pixels), cx: &App) -> AnyElement {
match show.stack(part) {
Some(stack) => compose::slide(stack, |visible| visible, size, ely_lok::paper())
Some(stack) => compose::slide(stack, |visible| visible, size, ely_lok::paper(cx))
.into_any_element(),
None => black(size),
}
+7 -4
View File
@@ -59,14 +59,16 @@ pub fn discard(worker: Arc<Mutex<Worker>>, doc: Doc, copy: PathBuf, cx: &App) {
fn copy(
worker: &mut Worker,
doc: &Doc,
copy: &std::path::Path,
(copy, scheme): (&std::path::Path, Option<&'static str>),
) -> io::Result<(usize, Doc, Vec<ShowSlide>)> {
let current = doc.sheets(worker)?.current;
let url = ely_lok::file_url(copy);
doc.save_as(worker, &url, "odp")?;
let shown = worker.load(&url)?;
let read = |worker: &mut Worker| -> io::Result<Vec<ShowSlide>> {
worker.run(&shown, "view.theme", &[ely_lok::SCHEME])?;
if let Some(scheme) = scheme {
worker.run(&shown, "view.theme", &[scheme])?;
}
ely_lok::show::slide_show(&worker.values(&shown, ely_lok::show::SLIDE_SHOW)?)
};
match read(worker) {
@@ -159,13 +161,14 @@ impl Deck {
self.show_starting = false;
return;
};
let scheme = tab.view.read(cx).dark().map(ely_lok::scheme);
let path = tab.work.join(format!("show-{}.odp", self.shows));
self.shows += 1;
let into = path.clone();
let worker = self.core.worker.clone();
self.read_in(
id,
move |worker, doc| copy(worker, doc, &into),
move |worker, doc| copy(worker, doc, (&into, scheme)),
move |deck, read, _, cx| {
deck.show_starting = false;
let (current, doc, slides) = match read {
@@ -379,7 +382,7 @@ mod tests {
let deck = deck.read(app);
let tab = deck.active_tab().expect("a tab");
let mut worker = deck.core.worker.lock().expect("the worker lock");
let (_, doc, slides) = super::copy(&mut worker, &tab.doc, &path).expect("a copy");
let (_, doc, slides) = super::copy(&mut worker, &tab.doc, (&path, None)).expect("a copy");
super::Starting { tab: tab.id, slides, first: 0, doc, copy: path.clone(), audience: 99, presenter: false }
});
update(&mut cx, &deck, |deck, cx| {
+1 -1
View File
@@ -363,7 +363,7 @@ impl Book {
Ok(Some(Clip::Html { html, .. })) => Some(html),
Ok(_) => None,
Err(error) => {
let text = Book::text(cx, "table.data.clipboard", &[("error", &format!("{error:#}"))]);
let text = Book::text(cx, "table.data.clipboard", &[("error", &error.to_string())]);
return self.toast(text, Severity::Danger, cx);
}
};
-5
View File
@@ -65,11 +65,6 @@ impl Book {
.bg(sunken)
.on_mouse_move(cx.listener(Self::on_drag_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_drag_end))
.on_mouse_up_out(MouseButton::Left, cx.listener(|book, _, _, cx| {
if book.drag.take().is_some() {
cx.notify();
}
}))
.child(div().flex().child(corner).child(column_strip))
.child(
div().flex_1().min_h_0().flex().child(row_strip).child(
+3 -6
View File
@@ -9,8 +9,8 @@ use ely_gpui_component::{
use ely_lok::{LokView, sheet};
use ely_office::prelude::*;
use gpui::{
AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Pixels,
Point, Styled as _, div, px,
AnyElement, Context, Entity, IntoElement, ParentElement as _, Pixels, Point, Styled as _, div,
px,
};
use crate::book::Book;
@@ -112,7 +112,6 @@ impl Book {
.absolute()
.left(corner.x)
.top(corner.y)
.block_mouse_except_scroll()
.child(
IconButton::new("table-choices-open", IconName::ChevronDown)
.variant(ButtonVariant::Ghost)
@@ -122,8 +121,7 @@ impl Book {
book.offer_choices(id, (column, row), cx)
})),
)
.h(px(size))
.min_h(cx.theme().control_height(ControlSize::Sm))
.h(px(size.max(16.0)))
.into_any_element(),
);
}
@@ -139,7 +137,6 @@ impl Book {
.absolute()
.left(below.x)
.top(below.y)
.block_mouse_except_scroll()
.max_w(theme.tooltip_max_width())
.px_2()
.py_1()
+8 -15
View File
@@ -109,12 +109,18 @@ impl Book {
let Some(tab) = self.active_tab() else {
return div().into_any_element();
};
let Some(tabs) = strip(tab.id, &tab.extra.sheets) else {
let Some(choices) = visible(&tab.extra.sheets) else {
return div().into_any_element();
};
let this = cx.entity();
let picked = this.clone();
let tabs = tabs.on_change(move |value, _, cx| {
let tabs = SheetTabs::new(
"table-sheets",
choices,
tab.extra.sheets.current.to_string(),
)
.placement(TabPlacement::Bottom)
.on_change(move |value, _, cx| {
let index: usize = value.parse().expect("sheet tabs are indexes");
picked.update(cx, |book, cx| book.show_sheet(index, cx));
})
@@ -304,15 +310,6 @@ impl Book {
}
}
/// Workbook `id`'s sheet tabs; each workbook keeps its own scroll.
fn strip(id: u64, sheets: &ely_lok::Sheets) -> Option<SheetTabs> {
let choices = visible(sheets)?;
Some(
SheetTabs::new(("table-sheets", id), choices, sheets.current.to_string())
.placement(TabPlacement::Bottom),
)
}
/// Visible sheets as tabs, once the shown one is known.
fn visible(sheets: &ely_lok::Sheets) -> Option<Vec<Choice>> {
let shown = sheets.list.get(sheets.current)?;
@@ -330,10 +327,6 @@ fn visible(sheets: &ely_lok::Sheets) -> Option<Vec<Choice>> {
)
}
#[cfg(test)]
#[path = "tests/strip.rs"]
mod strip_tests;
#[cfg(test)]
mod tests {
use super::*;
-1
View File
@@ -394,7 +394,6 @@ fn a_delete_stays_with_its_workbook() {
}
mod analysis;
mod aids;
mod data;
mod menu;
mod names;
-126
View File
@@ -1,126 +0,0 @@
use super::data::{cells, open, run};
use super::*;
use ely_gpui_component::theme::{ActiveTheme as _, ControlSize};
use ely_lok::sheet::Validation;
use gpui::{Modifiers, MouseButton, ParentElement as _, Styled as _, VisualTestContext, point, px};
struct Grid(Entity<Book>);
impl gpui::Render for Grid {
fn render(
&mut self,
window: &mut gpui::Window,
cx: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
let grid = self.0.update(cx, |book, cx| book.grid(window, cx));
gpui::div()
.relative()
.size_full()
.flex()
.flex_col()
.child(grid)
}
}
fn frames(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.update(|window, _| window.refresh());
cx.run_until_parked();
}
}
#[test]
fn validation_help_and_arrow_do_not_select_the_cells_behind_them() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some((_window, book)) = book(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
cx.update(ely_gpui_component::init_for_tests);
open(&mut cx, &book);
run(&mut cx, &book, "cell.goto", &["E2"]);
let rule = Validation {
kind: "list".into(),
operator: "equal".into(),
list: vec!["Red".into(), "Green".into()],
dropdown: true,
show_input: true,
input_message: "Pick a color".into(),
error_style: "stop".into(),
..Validation::default()
};
run(&mut cx, &book, "rules.validation", &[&rule.to_json()]);
settle(&cx, |app| {
book.read(app)
.active_tab()
.and_then(|tab| tab.extra.rule.as_ref())
.is_some_and(|(at, rule)| at.1 == "E2" && rule.input_message == "Pick a color")
});
let window = cx.add_window(|_, _| Grid(book.clone()));
let mut shown = VisualTestContext::from_window(window.into(), &cx);
frames(&mut shown);
let (view, help, arrow, handle) = shown.update(|window, app| {
let view = book.read(app).active_tab().expect("a tab").view.clone();
let read = view.read(app);
let (x, y, w, h) = read.cell().cursor.expect("the cell cursor");
let help = read.origin() + read.place((x, y + h)) + point(px(8.0), px(8.0));
// The arrow's lower corner hangs below a default row.
let tall = app
.theme()
.control_height(ControlSize::Sm)
.to_pixels(window.rem_size());
let corner = point(tall - px(1.0), tall - px(1.0));
let arrow = read.origin() + read.place((x + w, y)) + corner;
let handle = read.origin() + read.place((x + w, y + h)) - point(px(1.0), px(1.0));
(view.clone(), help, arrow, handle)
});
let sent = shown.update(|_, app| view.read(app).sent());
shown.simulate_mouse_down(handle, MouseButton::Left, Modifiers::none());
assert!(
shown.update(|_, app| matches!(book.read(app).drag, Some(crate::grid::Drag::Fill { .. })))
);
shown.simulate_mouse_move(help, MouseButton::Left, Modifiers::none());
shown.simulate_mouse_up(help, MouseButton::Left, Modifiers::none());
assert!(
shown.update(|_, app| book.read(app).drag.is_none()),
"releasing over help must end the drag"
);
shown.simulate_click(help, Modifiers::none());
assert_eq!(
shown.update(|_, app| view.read(app).sent()),
sent,
"help never sends a grid click"
);
shown.simulate_mouse_down(arrow, MouseButton::Left, Modifiers::none());
assert_eq!(
shown.update(|_, app| view.read(app).sent()),
sent,
"the arrow press stays outside the grid"
);
shown.simulate_mouse_up(arrow, MouseButton::Left, Modifiers::none());
settle(&shown, |app| book.read(app).choices.is_some());
frames(&mut shown);
shown.update(|_, app| {
assert_eq!(view.read(app).cell().address, "E2");
assert_eq!(
view.read(app).sent(),
sent,
"the arrow release also stays outside the grid"
);
assert_eq!(
book.read(app).choices.as_ref().expect("the menu").values,
["Red", "Green"]
);
});
let red = shown
.debug_bounds("menu-row-Red")
.expect("the Red menu item");
shown.simulate_click(red.center(), Modifiers::none());
// An empty F2 adds nothing after E2's tab.
assert_eq!(
cells(&mut cx, &book, "E2:F2"),
"Red",
"the value lands in E2 and F2 stays empty"
);
}
+5 -3
View File
@@ -43,9 +43,11 @@ pub(super) fn cells(cx: &mut TestAppContext, book: &Entity<Book>, range: &str) -
worker
.run(&tab.doc, "cell.goto", &[range])
.expect("the range");
let text = tab.doc.selection_text(&mut worker).expect("the text");
// The Windows kernel ends rows with CRLF.
text.trim().lines().collect::<Vec<_>>().join("\n")
tab.doc
.selection_text(&mut worker)
.expect("the text")
.trim()
.to_string()
})
}
+3 -5
View File
@@ -64,9 +64,7 @@ fn a_failed_print_says_printing_failed() {
.expect("the window");
let gone = dir.path().join("gone.pdf");
update(&mut cx, &book, |_, cx| ely_office::print_later(gone, cx));
// The file is read on its own thread.
settle(&cx, |app| {
let prefix = ely_office::text(app, "office.print.failed", &[("error", "")]);
root.read(app).toasts(app).iter().any(|(text, severity)| *severity == Some(Severity::Danger) && text.starts_with(prefix.as_ref()))
});
cx.run_until_parked();
let (toasts, prefix) = cx.update(|app| (root.read(app).toasts(app), ely_office::text(app, "office.print.failed", &[("error", "")])));
assert!(toasts.iter().any(|(text, severity)| *severity == Some(Severity::Danger) && text.starts_with(prefix.as_ref())), "{toasts:?}");
}
+3 -5
View File
@@ -113,17 +113,15 @@ fn exports_write_the_shown_sheet_as_text() {
run(&mut cx, &book, "cell.goto", &["B1"]);
run(&mut cx, &book, "cell.enter", &["a;b"]);
let id = cx.update(|app| book.read(app).core.tabs[0].id);
// The kernel ends rows the system's way.
let end = if cfg!(windows) { "\r\n" } else { "\n" };
for (format, name, text) in [
(Format::Csv, "out.csv", "3,a;b"),
(Format::Tsv, "out.tsv", "3\ta;b"),
(Format::Csv, "out.csv", "3,a;b\n"),
(Format::Tsv, "out.tsv", "3\ta;b\n"),
] {
let path = dir.path().join(name);
update(&mut cx, &book, |book, cx| book.export_to(id, path.clone(), format, cx));
settle(&cx, |_| path.exists());
quiet(&cx, &book);
assert_eq!(std::fs::read_to_string(&path).expect("reads"), format!("{text}{end}"));
assert_eq!(std::fs::read_to_string(&path).expect("reads"), text);
}
let pdf = dir.path().join("out.pdf");
update(&mut cx, &book, |book, cx| book.export_to(id, pdf.clone(), Format::Pdf, cx));
-96
View File
@@ -1,96 +0,0 @@
//! Each workbook's strip shows its own chosen sheet.
use ely_lok::{Sheet, Sheets};
use gpui::{
Bounds, Context, IntoElement, Modifiers, ParentElement as _, Pixels, Render, RenderOnce as _,
ScrollDelta, ScrollHandle, ScrollWheelEvent, Styled as _, TestAppContext, TouchPhase,
VisualTestContext, Window, div, point, px,
};
use super::strip;
struct Books {
book: u64,
sheets: Sheets,
scroll: Option<ScrollHandle>,
}
impl Render for Books {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let tabs = strip(self.book, &self.sheets)
.expect("tabs")
.render(window, cx)
.into_any_element();
let id = gpui::ElementId::from(("table-sheets", self.book));
let handle = window.use_keyed_state((id, "scroll"), cx, |_, _| ScrollHandle::new());
self.scroll = Some(handle.read(cx).clone());
div()
.w(px(640.))
.child(div().flex().items_center().px_2().child(tabs))
}
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..5 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
/// The strip's viewport and the chosen tab in it.
fn place(
view: &gpui::Entity<Books>,
cx: &mut VisualTestContext,
) -> (Bounds<Pixels>, Bounds<Pixels>) {
view.read_with(cx, |view, _| {
let scroll = view.scroll.as_ref().expect("drawn");
let mut chosen = scroll
.bounds_for_item(view.sheets.current)
.expect("chosen tab");
chosen.origin += scroll.offset();
(scroll.bounds(), chosen)
})
}
fn shown(view: &gpui::Entity<Books>, cx: &mut VisualTestContext) -> bool {
let (viewport, chosen) = place(view, cx);
chosen.left() >= viewport.left() && chosen.right() <= viewport.right()
}
#[gpui::test]
fn a_switched_workbook_shows_its_chosen_sheet(cx: &mut TestAppContext) {
cx.update(ely_gpui_component::init_for_tests);
let sheets = Sheets {
current: 29,
list: (0..30)
.map(|n| Sheet {
name: format!("Sheet {n}"),
visible: true,
})
.collect(),
};
let (view, cx) = cx.add_window_view(|_, _| Books {
book: 1,
sheets,
scroll: None,
});
settle(cx);
assert!(shown(&view, cx), "the first draw shows the chosen sheet");
for book in [2, 1, 2] {
cx.simulate_event(ScrollWheelEvent {
position: point(px(60.), px(16.)),
delta: ScrollDelta::Pixels(point(px(0.), px(50_000.))),
modifiers: Modifiers::none(),
touch_phase: TouchPhase::Moved,
});
settle(cx);
assert!(!shown(&view, cx), "the wheel moves the chosen sheet away");
view.update(cx, |view, cx| {
view.book = book;
cx.notify();
});
settle(cx);
assert!(shown(&view, cx), "workbook {book} shows its chosen sheet");
}
}
+6 -20
View File
@@ -76,26 +76,12 @@ impl Doc {
Unpack::new(&payload).str()
}
/// Completes `name` if offered; a key takes any, else goes in (TABLE-03).
pub fn complete_function(
&self,
worker: &mut Worker,
name: Option<&str>,
key: Option<(u32, i32)>,
) -> io::Result<bool> {
let pack = Pack::new().u64(worker.doc_id(self)?);
let pack = match name {
Some(name) => pack.u64(1).str(name),
None => pack.u64(0),
};
// Key code 0: no key, as for a click.
let (char_code, key_code) = key.unwrap_or((0, 0));
let request = pack
.i64(i64::from(char_code))
.i64(i64::from(key_code))
.bytes();
let reply = worker.request(tag::COMPLETE_FUNCTION, &request)?;
Ok(Unpack::new(&reply).u64()? == 1)
/// Completes the function name being typed (TABLE-03).
pub fn complete_function(&self, worker: &mut Worker, name: &str) -> io::Result<()> {
log::info!("lok: completes a function");
let request = Pack::new().u64(worker.doc_id(self)?).str(name).bytes();
worker.request(tag::COMPLETE_FUNCTION, &request)?;
Ok(())
}
/// Shows sheet `part`, from 0.
-1
View File
@@ -29,7 +29,6 @@ mod sheet;
pub(crate) mod show;
mod slides;
mod tools;
mod word;
mod workbook;
/// The pinned LibreOffice commit the build must report.
-27
View File
@@ -152,30 +152,3 @@ fn one_cell_in_another_block_filters_that_block() {
let entries: Vec<String> = sheet::filter_values(&json).expect("parses").entries.into_iter().map(|e| e.text).collect();
assert_eq!(entries, ["9", "10"], "an AutoFilter from D1 is D's");
}
/// A text-only column: no cell looks like a header.
const WORDS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" office:version="1.3" office:mimetype="application/vnd.oasis.opendocument.spreadsheet">
<office:body><office:spreadsheet><table:table table:name="S">
<table:table-row><table:table-cell><text:p>Name</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>a</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document>"#;
#[test]
fn a_whole_column_without_a_clear_header_filters_from_its_first_row() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_book(WORDS);
worker.run(&doc, "cell.goto", &["A:A"]).expect("marks the column");
worker.run(&doc, "data.autofilter", &[]).expect("filters without asking");
doc.paint_tile(&mut worker, (0, 0, 3840, 3840), (256, 256))
.expect("the buttons paint");
// The whole column holds blanks too.
assert_eq!(entries(&mut worker, &doc), ["", "a", "b"], "the first row heads");
}
+2 -2
View File
@@ -50,8 +50,8 @@ fn typing_a_formula_offers_functions_usage_and_marks() {
sum.is_some_and(|f| f.signature.contains(", ")),
"the signature takes the Excel separator: {sum:?}"
);
let taken = doc.complete_function(&mut worker, Some("SUMIF"), None);
assert!(taken.expect("the completion"), "SUMIF is on offer");
doc.complete_function(&mut worker, "SUMIF")
.expect("the completion");
let done = poll_until(&mut worker, &doc, &offered, SETTLE, |seen| {
seen.cell.formula == "=SUMIF()"
});
+18 -6
View File
@@ -350,7 +350,7 @@ fn brightness(tile: &Tile) -> u32 {
}
#[test]
fn ely_scheme_paints_the_page_white() {
fn the_page_follows_a_dark_theme_and_back() {
let Some(install) = install() else {
eprintln!("SKIP: no pinned LOK build");
return;
@@ -363,11 +363,23 @@ fn ely_scheme_paints_the_page_white() {
let doc = worker
.load(&crate::file_url(&source))
.expect("the document loads");
poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false);
worker.run(&doc, "view.theme", &["COLOR_SCHEME_ELY_LIGHT"]).expect("the scheme");
let tile = doc.paint_tile(&mut worker, (1440, 1440, 3840, 3840), (64, 64)).expect("a tile");
let shade = brightness(&tile);
assert!(shade > 200, "{name}: the page is light, {shade}");
let area = (1440, 1440, 3840, 3840);
let seen = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false);
let light = brightness(&doc.paint_tile(&mut worker, area, (64, 64)).expect("a tile"));
let paint = |worker: &mut Worker, scheme: &str, seen: &Events| {
worker.run(&doc, "view.theme", &[scheme]).expect("a theme");
let repainted = poll_until(worker, &doc, seen, SETTLE, |seen| seen.all);
assert!(repainted.all, "{name}: {scheme} repaints the view");
let tile = doc.paint_tile(worker, area, (64, 64)).expect("a tile");
(brightness(&tile), repainted)
};
let (dark, seen) = paint(&mut worker, "COLOR_SCHEME_ELY_DARK", &seen);
let (back, _) = paint(&mut worker, "COLOR_SCHEME_ELY_LIGHT", &seen);
eprintln!("THEME {name} light {light} dark {dark} back {back}");
assert!(
light > 200 && dark < 100 && back > 200,
"{name}: {light} {dark} {back}"
);
worker.close_doc(doc).expect("the document closes");
}
}
+7 -5
View File
@@ -182,16 +182,18 @@ pub(super) fn open_book(text: &str) -> (tempfile::TempDir, Worker, Doc) {
}
#[test]
fn the_scheme_is_no_edit() {
fn a_theme_switch_is_no_edit() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_book(FODS);
worker.run(&doc, "view.theme", &["COLOR_SCHEME_ELY_LIGHT"]).expect("the scheme runs");
let undo = worker.values(&doc, ".uno:Undo").expect("the undo stack");
let undo: serde_json::Value = serde_json::from_str(&undo).expect("undo JSON");
assert_eq!(undo["actions"], "", "the scheme left undo steps");
for scheme in ["COLOR_SCHEME_ELY_DARK", "COLOR_SCHEME_ELY_LIGHT"] {
worker.run(&doc, "view.theme", &[scheme]).expect("the theme runs");
let undo = worker.values(&doc, ".uno:Undo").expect("the undo stack");
let undo: serde_json::Value = serde_json::from_str(&undo).expect("undo JSON");
assert_eq!(undo["actions"], "", "{scheme} left undo steps");
}
}
#[test]
-18
View File
@@ -103,24 +103,6 @@ fn size_layout_master_and_theme_change_in_one_step() {
);
}
/// Issue #2: more masters than slides, a slide hidden.
#[test]
fn the_master_view_lists_its_masters() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (_dir, mut worker, doc) = open_deck();
worker.run(&doc, "slide.hide", &[]).expect("hides One");
worker.run(&doc, "master.edit", &[]).expect("the master view");
worker.run(&doc, "master.insert", &[]).expect("a master");
worker.run(&doc, "master.insert", &[]).expect("another");
let masters = design(&mut worker, &doc).masters;
assert_eq!(masters.len(), 3, "more masters than slides");
let shown: Vec<(String, bool)> = masters.into_iter().map(|name| (name, true)).collect();
assert_eq!(slides(&mut worker, &doc), shown, "masters, all shown");
}
#[test]
fn a_master_edit_shows_on_its_slides() {
if install().is_none() {
-121
View File
@@ -1,121 +0,0 @@
//! Gitea #9: Word's EQ fields and lowered inline objects.
use super::input::saved;
use super::*;
/// `tests/fixtures/office/word.docx` open in a fresh worker.
fn word() -> (tempfile::TempDir, Worker, Doc) {
let dir = tempfile::tempdir().expect("a temp dir");
let install = install().expect("a pinned LOK build");
let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker");
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/office/word.docx");
let doc = worker.load(&crate::file_url(&fixture)).expect("the document loads");
(dir, worker, doc)
}
/// The text position of the span around the picture's frame.
fn picture_position(fodt: &str) -> String {
let named = fodt.find("draw:name=\"red\"").expect("the picture");
let frame = fodt[..named].rfind("<draw:frame").expect("the picture's frame");
let open = fodt[..frame].rfind("<text:span").expect("a span around the picture");
let style = fodt[open..].split('"').nth(1).expect("the span's style");
let named = format!("style:name=\"{style}\"");
let at = fodt.find(&named).expect("the span's style is written");
let props = &fodt[at..at + fodt[at..].find("</style:style>").expect("the style ends")];
let position = props.find("style:text-position=\"").expect("the span is raised or lowered");
props[position + 21..].split('"').next().expect("a position").to_owned()
}
#[test]
fn eq_fields_open_as_formulas() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = word();
let fodt = saved(&dir, &mut worker, &doc, "word.fodt");
assert!(
fodt.contains("{{a}_{&quot;10&quot;}} over {{a}_{&quot;3&quot;}}"),
"the fraction keeps its italics and subscripts"
);
assert!(fodt.contains("nroot{&quot;3&quot;}{&quot;x&quot;}"), "the radical");
assert!(fodt.contains("sum from{&quot;1&quot;} to{&quot;5&quot;}"), "an uppercase \\SU is a sum");
assert!(
fodt.contains("matrix{{&quot;a&quot;} # {&quot;b&quot;} ## {&quot;c&quot;} # {&quot;d&quot;}}"),
"an uppercase \\CO2 makes two columns"
);
assert!(fodt.contains("eq \\f(a)</text:span>"), "an unreadable field shows its code");
assert!(fodt.contains("eq \\b\\lc\\★(x)"), "an unknown bracket shows its code");
let short = formulas(&fodt).into_iter().find(|math| math.contains("# {}}")).expect("the short array");
assert!(short.contains(">c<"), "a short last row keeps its operand");
assert_eq!(fodt.matches('∑').count(), 2, "\\SU and \\fc\\∑ both draw a sum");
assert!(fodt.contains('⌊') && fodt.contains('⌋'), "floor brackets draw");
assert!(!fodt.contains("MERGEFORMAT"), "a general switch is no formula text");
assert!(fodt.contains("{&quot;m&quot;} over {&quot;n&quot;}<"), "nothing after the fraction");
assert_eq!(subscripts(&fodt, ">p<"), 1, "a style change keeps one subscript");
assert!(fodt.contains("&quot;u&quot; stack{{&quot;v&quot;}}"), "\\ai only spaces lines");
assert!(fodt.contains("&quot;w&quot; stack{{&quot;k&quot;}}"), "\\di only spaces lines");
assert!(fodt.contains("_{{&quot;g&quot;}} &quot;+h&quot;"), "a lowercase \\s\\do keeps its tail");
assert!(fodt.contains("_{{{&quot;r&quot;} over {&quot;s&quot;}}}"), "and its nested fraction");
assert!(fodt.contains(">lowered</text:span>"), "a plain \\s\\do stays lowered text");
}
#[test]
fn eq_formulas_survive_docx() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = word();
let docx = dir.path().join("again.docx");
doc.save_as(&mut worker, &crate::file_url(&docx), "docx").expect("the DOCX saves");
let back = worker.load(&crate::file_url(&docx)).expect("the DOCX reopens");
let fodt = saved(&dir, &mut worker, &back, "again.fodt");
assert!(
fodt.contains("{{a} rsub {&quot;10&quot;}} over {{a} rsub {&quot;3&quot;}}"),
"the italic letters stay italic"
);
assert!(!fodt.contains("mathvariant=\"normal\">a<"), "no upright a");
assert_eq!(subscripts(&fodt, ">p<"), 1, "one subscript still");
assert!(fodt.contains("{&quot;&quot;} rsub {&quot;g&quot;} &quot;+h&quot;"), "the tail survives");
assert!(!fodt.contains("&lt;?&gt;"), "no placeholder for a missing base");
}
/// Subscripts in the formula holding `mark`.
fn subscripts(fodt: &str, mark: &str) -> usize {
formulas(fodt).into_iter().find(|math| math.contains(mark)).expect("the formula").matches("<msub>").count()
}
/// Each embedded formula's MathML.
fn formulas(fodt: &str) -> Vec<&str> {
fodt.split("<math xmlns").skip(1).map(|math| &math[..math.find("</math>").expect("closed")]).collect()
}
#[test]
fn a_lowered_picture_moves_with_its_run_and_keeps_it() {
if install().is_none() {
eprintln!("SKIP: no pinned LOK build");
return;
}
let (dir, mut worker, doc) = word();
let fodt = saved(&dir, &mut worker, &doc, "word.fodt");
// 11 points down, against the 11 point anchor.
assert_eq!(picture_position(&fodt), "-100% 100%");
let docx = dir.path().join("again.docx");
doc.save_as(&mut worker, &crate::file_url(&docx), "docx").expect("the DOCX saves");
let back = worker.load(&crate::file_url(&docx)).expect("the DOCX reopens");
let again = saved(&dir, &mut worker, &back, "again.fodt");
assert_eq!(picture_position(&again), "-100% 100%", "the position survives DOCX");
// The square's red sits below the line's text.
let tile = back.paint_tile(&mut worker, (1440, 1440, 3840, 1920), (256, 128)).expect("a tile");
let rows = |hit: fn(&[u8]) -> bool| {
let mut lit: Vec<usize> = tile.pixels.chunks_exact(4).enumerate().filter(|(_, px)| hit(px)).map(|(at, _)| at / 256).collect();
lit.dedup();
let first = *lit.first().expect("some pixels");
let line = lit.iter().zip(first..).take_while(|(row, want)| **row <= *want).last().expect("a row");
(first, *line.0)
};
let red = rows(|px| px[2] > 200 && px[1] < 60 && px[0] < 60);
let ink = rows(|px| px[0] < 80 && px[1] < 80 && px[2] < 80);
assert!(red.1 > ink.1 + 4, "the picture hangs below the text: red {red:?}, text {ink:?}");
}
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::ffi::Document;
mod cell;
mod parse;
pub use cell::{Cell, Function, Mark, Request, offered};
pub use cell::{Cell, Function, Mark, Request};
use cell::{functions_of, marks_of, request_of, usage_of};
pub use parse::A11y;
use parse::{
-13
View File
@@ -5,19 +5,6 @@ use serde_json::Value;
use super::Rect;
use crate::ipc::{Pack, Unpack};
/// The function names `id` offers now.
pub fn offered(id: u64) -> Vec<String> {
let slots = super::slots().lock().expect("the events lock");
slots.get(&id).map_or_else(Vec::new, |slot| {
slot.batch
.cell
.functions
.iter()
.map(|function| function.name.clone())
.collect()
})
}
/// What a spreadsheet reports about the current cell.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Cell {
+1 -1
View File
@@ -21,7 +21,7 @@ pub use client::{
#[cfg(feature = "view")]
pub use view::{
CONTEXT, Copy, Cut, Geom, Layout, LokError, LokView, Paste, Reported, ZOOM_STEPS, notch,
SCHEME, Zoomed, init, layout, paper, read_off,
Zoomed, init, layout, paper, read_off, scheme,
};
#[cfg(feature = "view")]
mod view;
+4 -7
View File
@@ -32,7 +32,7 @@ mod scroll;
mod sheet;
pub use input::{CONTEXT, Copy, Cut, Paste, init};
pub use off::read_off;
pub use render::{SCHEME, paper};
pub use render::{paper, scheme};
mod turn;
pub use turn::notch;
@@ -123,9 +123,8 @@ pub struct LokView {
pointer: gpui::CursorStyle,
selection: Vec<Rect>,
cell: crate::Cell,
completion: formula::Completion,
/// Whether the kernel was told Ely's scheme.
lit: bool,
/// Whether the kernel was last told to paint dark.
dark: Option<bool>,
/// The zoom follows the viewport so a page fits.
fit: bool,
/// Set when the wheel and Up, Down turn parts.
@@ -219,8 +218,7 @@ impl LokView {
pointer: gpui::CursorStyle::Arrow,
selection: Vec::new(),
cell: crate::Cell::default(),
completion: formula::Completion::default(),
lit: false,
dark: None,
fit: false,
turning: None,
part: 0,
@@ -394,7 +392,6 @@ impl LokView {
self.picked = 0;
}
self.cell = batch.cell;
self.completion.reported(batch.revision);
self.graphic = batch.graphic;
self.read_borders(batch.table, batch.revision);
self.review = batch.review;
+1 -1
View File
@@ -82,7 +82,7 @@ pub(super) fn mark_of(html: &str) -> Option<&str> {
/// Pastes the system clipboard.
pub(super) fn paste(worker: &mut Worker, doc: &Doc) -> io::Result<()> {
let Some(clip) = clipboard::read().map_err(|error| io::Error::other(format!("{error:#}")))? else {
let Some(clip) = clipboard::read().map_err(io::Error::other)? else {
return Ok(());
};
let (mime, data) = flavor_of(clip);
+17 -37
View File
@@ -5,17 +5,11 @@ use ely_gpui_component::{
primitives::raise,
};
use gpui::{
AnyElement, Bounds, Context, Hsla, InteractiveElement, IntoElement, Keystroke, Modifiers,
ParentElement, Pixels, anchored, div, rgb,
AnyElement, Bounds, Context, Hsla, IntoElement, Keystroke, Modifiers, ParentElement, Pixels,
anchored, rgb,
};
use super::{
LokView,
input::{Input, vcl_key},
};
mod completion;
pub(super) use completion::Completion;
use super::{LokView, input::Input};
impl LokView {
/// The sheet shown, which reference marks follow.
@@ -54,7 +48,7 @@ impl LokView {
/// Up and Down walk the offered functions.
pub(super) fn walk(&mut self, keystroke: &Keystroke, cx: &mut Context<Self>) -> bool {
let count = self.cell.functions.len();
if count == 0 || self.completion.pending() || keystroke.modifiers != Modifiers::none() {
if count == 0 || keystroke.modifiers != Modifiers::none() {
return false;
}
self.picked = match keystroke.key.as_str() {
@@ -66,46 +60,32 @@ impl LokView {
true
}
/// Enter or Tab: the kernel's function then, else the key.
pub(super) fn complete_or_press(&mut self, keystroke: &Keystroke) {
let key = vcl_key(keystroke).expect("Enter and Tab are vcl keys");
let name = (!self.completion.pending())
.then(|| self.offered().into_iter().nth(self.picked))
.flatten()
.map(|item| item.label.to_string());
self.take(name, Some(key));
}
/// A clicked function, if the kernel still offers it then.
fn complete_function(&mut self, name: &str) {
if !self.completion.pending() {
self.take(Some(name.to_string()), None);
}
}
/// Queues the choice in order with typing; the worker decides.
fn take(&mut self, name: Option<String>, key: Option<(u32, i32)>) {
self.send(Input::Complete { name, key });
self.completion.queued(self.sent);
self.picked = 0;
/// Takes the chosen function, in order with typing.
pub(super) fn complete(&mut self) -> bool {
let Some(item) = self.offered().into_iter().nth(self.picked) else {
return false;
};
log::info!("lok: completes {}", item.label);
self.send(Input::Complete(item.label.to_string()));
true
}
/// The offered functions, else the call's usage, under the cell.
pub(super) fn hints(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
if self.completion.pending() {
return None;
}
let cell = self.screen_rect(self.cell.cursor?);
let hint = if self.cell.functions.is_empty() {
SignatureHelp::new("lok-view-usage", [usage(&self.cell.usage)?]).into_any_element()
} else {
let this = cx.entity();
let offered = self.offered();
let labels: Vec<_> = offered.iter().map(|item| item.label.clone()).collect();
CompletionMenu::new("lok-view-functions", offered)
.selected(self.picked)
.on_pick(move |item, _, cx| {
let at = labels.iter().position(|label| *label == item.label);
this.update(cx, |view, cx| {
view.complete_function(&item.label);
view.picked = at.expect("a picked item is offered");
view.complete();
cx.notify();
})
})
@@ -114,7 +94,7 @@ impl LokView {
let place = anchored()
.position(cell.bottom_left())
.snap_to_window()
.child(div().block_mouse_except_scroll().child(hint));
.child(hint);
Some(raise("lok-view-hints", place).into_any_element())
}
}
@@ -1,116 +0,0 @@
#[derive(Default)]
pub(crate) struct Completion {
pending: Option<(u64, Option<u64>)>,
reported: u64,
}
impl Completion {
pub(crate) fn pending(&self) -> bool {
self.pending.is_some()
}
pub(crate) fn queued(&mut self, seq: u64) {
self.pending = Some((seq, None));
}
pub(crate) fn reported(&mut self, revision: u64) {
self.reported = revision;
self.finish();
}
pub(crate) fn landed(&mut self, seq: u64, revision: Option<u64>) {
if self.pending.is_some_and(|(waiting, _)| waiting == seq) {
self.pending = revision.map(|revision| (seq, Some(revision)));
self.finish();
}
}
fn finish(&mut self) {
// Inline callbacks and serialized output preserve completion revisions.
if self
.pending
.is_some_and(|(_, revision)| revision.is_some_and(|revision| self.reported >= revision))
{
self.pending = None;
}
}
}
#[cfg(test)]
mod tests {
use super::Completion;
#[test]
fn an_old_snapshot_cannot_reopen_an_accepted_menu() {
let mut completion = Completion::default();
completion.reported(10);
completion.queued(7);
completion.reported(10);
assert!(completion.pending());
completion.landed(7, Some(11));
assert!(completion.pending());
completion.reported(10);
assert!(completion.pending());
completion.reported(11);
assert!(!completion.pending());
}
#[test]
fn a_snapshot_before_the_reply_waits_for_the_reply() {
let mut completion = Completion::default();
completion.queued(7);
completion.reported(11);
assert!(completion.pending());
completion.landed(7, Some(11));
assert!(!completion.pending());
}
#[test]
fn a_reply_before_the_snapshot_waits_for_the_snapshot() {
let mut completion = Completion::default();
completion.queued(7);
completion.landed(7, Some(11));
assert!(completion.pending());
completion.reported(11);
assert!(!completion.pending());
}
#[test]
fn another_input_cannot_acknowledge_the_completion() {
let mut completion = Completion::default();
completion.queued(7);
completion.reported(20);
completion.landed(6, Some(20));
assert!(completion.pending());
completion.landed(8, None);
assert!(completion.pending());
completion.landed(7, Some(19));
assert!(!completion.pending());
}
#[test]
fn a_failed_completion_allows_retry() {
let mut completion = Completion::default();
completion.queued(7);
completion.landed(7, None);
assert!(!completion.pending());
completion.queued(8);
assert!(completion.pending());
}
#[test]
fn the_next_completion_requires_its_own_snapshot() {
let mut completion = Completion::default();
completion.reported(11);
completion.queued(7);
completion.landed(7, Some(11));
assert!(!completion.pending());
completion.queued(8);
completion.landed(8, Some(12));
assert!(completion.pending());
completion.reported(11);
assert!(completion.pending());
completion.reported(12);
assert!(!completion.pending());
}
}
+8 -28
View File
@@ -45,11 +45,8 @@ pub(super) enum Input {
Paste,
/// Values or formulas alone: `paste.values` or `paste.formulas`.
PasteOnly(&'static str),
/// A function the kernel offers then, else Enter or Tab.
Complete {
name: Option<String>,
key: Option<(u32, i32)>,
},
/// A function the kernel offered, taken.
Complete(String),
/// The kernel's color scheme; not an edit.
Theme(&'static str),
/// Commands in order, from one read.
@@ -75,8 +72,6 @@ pub(super) enum Input {
pub(super) enum Back {
Copied(String),
Shown(usize),
/// Whether a function was taken.
Completed(bool),
}
impl Input {
@@ -97,7 +92,7 @@ impl Input {
| Input::Paste
| Input::PasteOnly(_)
| Input::Drag { .. }
| Input::Complete { .. } => true,
| Input::Complete(_) => true,
}
}
}
@@ -206,10 +201,7 @@ fn apply(worker: &mut Worker, doc: &Doc, input: Input) -> io::Result<Option<Back
Input::Drag { from, to } => worker.drag_graphic(doc, from, to),
Input::Visible(area, scale) => doc.visible_area(worker, area, scale),
Input::Part(part) => doc.show_sheet(worker, part),
Input::Complete { name, key } => {
let taken = doc.complete_function(worker, name.as_deref(), key)?;
return Ok(Some(Back::Completed(taken)));
}
Input::Complete(name) => doc.complete_function(worker, &name),
Input::Theme(scheme) => worker.run(doc, "view.theme", &[scheme]),
Input::Text { commit, text } => worker.post_text(doc, commit, &text),
Input::Copy { cut } => {
@@ -278,10 +270,6 @@ impl LokView {
/// Posts a non-text key; text waits for the IME.
pub(super) fn key_down(&mut self, keystroke: &Keystroke) -> bool {
if keystroke.key == "enter" && keystroke.modifiers == Modifiers::none() {
self.complete_or_press(keystroke);
return true;
}
let Some((char_code, key_code)) = vcl_key(keystroke) else {
return false;
};
@@ -319,14 +307,10 @@ impl LokView {
_: &mut Window,
cx: &mut Context<Self>,
) {
let completing = self.completion.pending();
if self.walk(&event.keystroke, cx)
|| self.turn_key(&event.keystroke)
|| self.key_down(&event.keystroke)
{
if !completing && self.completion.pending() {
cx.notify();
}
cx.stop_propagation();
}
}
@@ -441,14 +425,10 @@ impl LokView {
self.send(Input::Paste);
}
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, cx: &mut Context<Self>) {
self.next_field();
cx.notify();
}
/// Tab takes the offered function, else moves on.
pub(super) fn next_field(&mut self) {
self.complete_or_press(&Keystroke::parse("tab").expect("a keystroke"));
pub(super) fn on_next_field(&mut self, _: &NextField, _: &mut Window, _: &mut Context<Self>) {
if !self.complete() {
self.key_down(&Keystroke::parse("tab").expect("a keystroke"));
}
}
pub(super) fn on_previous_field(
+1 -4
View File
@@ -107,9 +107,6 @@ impl LokView {
}
self.edited += u64::from(edits);
let (outcome, now) = outcome;
// A plain key took nothing; the menu may return.
let taken = matches!(outcome, Ok(Some(Back::Completed(true))));
self.completion.landed(seq, if taken { now } else { None });
if edits {
self.clock.landed(now);
}
@@ -121,7 +118,7 @@ impl LokView {
self.part = part;
self.invalidate(cx);
}
Ok(Some(Back::Shown(_) | Back::Completed(_))) => {}
Ok(Some(Back::Shown(_))) => {}
Ok(None) => {}
Err(error) => {
log::error!("lok: input failed ({:?})", error.kind());
+3 -3
View File
@@ -6,9 +6,9 @@ use super::LokView;
use crate::Rect;
impl LokView {
/// Whether the kernel was told Ely's scheme.
pub fn lit(&self) -> bool {
self.lit
/// Whether the kernel paints dark; None before the first frame.
pub fn dark(&self) -> Option<bool> {
self.dark
}
/// The kernel's state for `uno`, as LOK reports it (LOK-05).
+26 -16
View File
@@ -15,21 +15,33 @@ use gpui::{Pixels, RenderImage, point};
use super::{CONTEXT, LokView, TILE_TWIPS, TWIPS_PER_PIXEL, handles, input};
use crate::Rect;
/// Ely's kernel color scheme, from `ely.xcd`.
pub const SCHEME: &str = "COLOR_SCHEME_ELY_LIGHT";
/// Ely's kernel color schemes, from `ely.xcd`.
const DARK: &str = "COLOR_SCHEME_ELY_DARK";
const LIGHT: &str = "COLOR_SCHEME_ELY_LIGHT";
/// A page's colour, the scheme's `DocColor`.
pub fn paper() -> gpui::Hsla {
gpui::white()
/// The kernel scheme for a light or dark app.
pub fn scheme(dark: bool) -> &'static str {
if dark { DARK } else { LIGHT }
}
/// A page's colour; the kernel's Ely schemes share it.
pub fn paper(cx: &gpui::App) -> gpui::Hsla {
let theme = cx.theme();
if theme.is_dark() {
theme.colors.surface
} else {
theme.colors.paper
}
}
impl LokView {
/// Content keeps the file's colours in any app theme.
pub(super) fn paint_light(&mut self) {
if !self.lit {
self.lit = true;
log::info!("lok: paints with {SCHEME}");
self.send(input::Input::Theme(SCHEME));
/// Paints with the app's light or dark scheme.
pub(super) fn follow_theme(&mut self, dark: bool) {
if self.dark != Some(dark) {
self.dark = Some(dark);
let scheme = scheme(dark);
log::info!("lok: paints with {scheme}");
self.send(input::Input::Theme(scheme));
}
}
}
@@ -41,14 +53,12 @@ impl Render for LokView {
self.keep_scroll(|view| view.geom.dpi = dpi);
self.retire_tiles();
}
self.paint_light();
self.follow_theme(cx.theme().is_dark());
let this = cx.entity().clone();
let focus = self.focus.clone();
let colors = &cx.theme().colors;
let paper = super::paper();
// Opaque overlays hide glyphs already painted in the tiles.
let selection_color = colors.selection.alpha(colors.selection.a.min(0.3));
let caret_color = colors.focus;
let paper = super::paper(cx);
let (selection_color, caret_color) = (colors.selection, colors.focus);
let canvas_color = colors.sunken;
let caret_width = cx.theme().caret_width().to_pixels(window.rem_size());
self.handle_size = cx.theme().handle_hit().to_pixels(window.rem_size());
-1
View File
@@ -4,7 +4,6 @@ mod borders;
mod clip;
mod commands;
mod fit;
mod formula;
mod input;
mod large;
mod show_borders;
-272
View File
@@ -1,272 +0,0 @@
use super::*;
use gpui::{Keystroke, TestAppContext};
fn settle(cx: &TestAppContext, done: impl Fn(&App) -> bool) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
if !cx.executor().tick() {
cx.executor()
.advance_clock(std::time::Duration::from_millis(20));
}
if cx.update(|app| done(app)) {
return true;
}
}
false
}
#[test]
fn enter_completes_the_selected_function_without_leaving_the_cell() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let (view, _) = cx.update(|app| {
live_view_of(
app,
&install,
"book.fods",
crate::client::tests::input::FODS,
)
});
let (_, window) = cx.add_window_view(|_, _| Host(view.clone()));
window.run_until_parked();
window.update(|window, app| {
let focus = view.read(app).focus_handle().clone();
focus.focus(window, app);
});
assert!(settle(window, |app| !view
.read(app)
.cell
.address
.is_empty()));
let address = window.update(|_, app| view.read(app).cell.address.clone());
view.update(window, |view, _| view.commit(None, "=lo"));
assert!(settle(window, |app| {
let functions = &view.read(app).cell.functions;
functions.iter().any(|item| item.name == "LOOKUP")
&& functions.iter().any(|item| item.name == "LOG")
}));
// Keep two valid offers to exercise keyboard selection.
view.update(window, |view, cx| {
view.cell
.functions
.retain(|item| item.name == "LOG" || item.name == "LOOKUP");
cx.notify();
});
window.simulate_keystrokes("down enter");
assert!(
settle(window, |app| {
let view = view.read(app);
view.settled() && view.cell.formula == "=LOOKUP()" && view.cell.functions.is_empty()
}),
"Enter inserts the selected function"
);
window.update(|_, app| {
assert_eq!(
view.read(app).cell.address,
address,
"completion stays in the cell"
);
view.update(app, |view, _| {
view.commit(None, "1,1,1");
});
});
assert!(settle(window, |app| {
let view = view.read(app);
view.settled() && view.cell.formula == "=LOOKUP(1,1,1)"
}));
window.simulate_keystrokes("enter");
assert!(
settle(window, |app| {
let view = view.read(app);
view.settled() && view.cell.address != address
}),
"Enter without candidates commits and moves to the next cell"
);
}
#[test]
fn accepting_a_candidate_consumes_it_before_the_worker_answers() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let cx = TestAppContext::single();
let (view, worker) = cx.update(|app| {
live_view_of(
app,
&install,
"book.fods",
crate::client::tests::input::FODS,
)
});
cx.update(|app| view.update(app, |view, _| view.commit(None, "=look")));
assert!(settle(&cx, |app| view
.read(app)
.cell
.functions
.iter()
.any(|item| item.name == "LOOKUP")));
let held = worker.lock().expect("the worker lock");
cx.update(|app| {
view.update(app, |view, cx| {
let old = Events {
revision: view.doc.rev,
cell: view.cell.clone(),
..Events::default()
};
let before = view.sent();
assert!(view.key_down(&Keystroke::parse("enter").expect("Enter")));
view.apply_batch(old, None, cx);
assert!(
view.completion.pending(),
"an old event must not reoffer an accepted candidate"
);
assert_eq!(view.sent(), before + 1);
assert!(view.key_down(&Keystroke::parse("enter").expect("Enter")));
assert_eq!(
view.sent(),
before + 2,
"the second Enter is queued normally"
);
})
});
drop(held);
assert!(
settle(&cx, |app| {
let view = view.read(app);
view.settled() && view.cell.address == "A2"
}),
"the second Enter commits and moves down"
);
}
#[test]
fn enter_or_tab_after_queued_typing_follows_the_kernel_not_the_menu() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
for (key, to) in [("enter", "A2"), ("tab", "B1")] {
let cx = TestAppContext::single();
let (view, worker) = cx.update(|app| {
live_view_of(
app,
&install,
"book.fods",
crate::client::tests::input::FODS,
)
});
cx.update(|app| view.update(app, |view, _| view.commit(None, "=sum")));
assert!(settle(&cx, |app| view
.read(app)
.cell
.functions
.iter()
.any(|item| item.name == "SUM")));
// The worker waits; the menu still shows SUM.
let held = worker.lock().expect("the worker lock");
cx.update(|app| {
view.update(app, |view, _| {
for text in ["(", "1", ")"] {
view.commit(None, text);
}
match key {
"enter" => assert!(view.key_down(&Keystroke::parse(key).expect("a key"))),
_ => view.next_field(),
}
})
});
drop(held);
assert!(
settle(&cx, |app| {
let view = view.read(app);
view.settled() && view.cell.address == to
}),
"{key} commits =sum(1) and moves to {to}"
);
}
}
struct Host(Entity<LokView>);
impl gpui::Render for Host {
fn render(
&mut self,
_: &mut gpui::Window,
_: &mut gpui::Context<Self>,
) -> impl gpui::IntoElement {
self.0.clone()
}
}
#[test]
fn clicking_a_function_does_not_send_mouse_input_to_the_sheet() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let (view, _) = cx.update(|app| {
live_view_of(
app,
&install,
"book.fods",
crate::client::tests::input::FODS,
)
});
let (_, window) = cx.add_window_view(|_, _| Host(view.clone()));
window.run_until_parked();
view.update(window, |view, _| view.commit(None, "=look"));
assert!(settle(window, |app| {
let view = view.read(app);
view.settled() && view.cell.functions.len() == 1 && view.cell.functions[0].name == "LOOKUP"
}));
window.run_until_parked();
let (on, address, sent) = window.update(|_, app| {
let view = view.read(app);
let cell = view.screen_rect(view.cell.cursor.expect("the cell cursor"));
(
cell.bottom_left() + point(px(12.0), px(12.0)),
view.cell.address.clone(),
view.sent(),
)
});
window.simulate_mouse_down(on, gpui::MouseButton::Left, Default::default());
window.update(|_, app| {
let view = view.read(app);
assert_eq!(view.sent(), sent, "the sheet never receives the press");
assert!(
!view.dragging,
"the candidate press never starts a sheet drag"
);
});
// Change candidates without repainting the already pressed row.
view.update(window, |view, _| {
view.cell.functions.insert(
0,
crate::Function {
name: "LOG".into(),
signature: String::new(),
description: String::new(),
},
);
});
window.simulate_mouse_up(on, gpui::MouseButton::Left, Default::default());
assert!(
settle(window, |app| {
let view = view.read(app);
view.settled() && view.cell.formula == "=LOOKUP()"
}),
"the candidate receives the click"
);
window.update(|_, app| {
let view = view.read(app);
assert_eq!(view.cell.address, address, "the active cell stays put");
assert_eq!(view.sent(), sent + 1, "only completion reaches the sheet");
});
}
-6
View File
@@ -247,12 +247,6 @@ fn the_clipboard_moves_html_tables_and_images() {
text.contains(&"left".to_string()) && text.contains(&"right".to_string()),
"{text:?}"
);
// Plain text from another app, with no HTML beside it.
clipboard::write(&Clip::Text("outside words".into())).expect("the text writes");
cx.update(|app| view.update(app, |view, _| view.send(crate::view::input::Input::Paste)));
flush(&cx);
let text = saved(&view, &mut cx);
assert!(text.iter().any(|line| line.contains("outside words")), "{text:?}");
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]))
+22 -135
View File
@@ -24,150 +24,37 @@ fn area() -> Bounds<Pixels> {
}
}
/// A user profile whose dark scheme paints the page black.
const DARK_PROFILE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<item oor:path="/org.openoffice.Office.UI/ColorScheme/ColorSchemes"><node oor:name="TEST_DARK" oor:op="replace"><node oor:name="DocColor"><prop oor:name="Color" oor:op="fuse"><value>0</value></prop></node></node></item>
</oor:items>
"#;
/// Shows a view in a window.
struct Host(Entity<LokView>);
impl gpui::Render for Host {
fn render(&mut self, _: &mut gpui::Window, _: &mut gpui::Context<Self>) -> impl gpui::IntoElement {
self.0.clone()
}
}
#[test]
fn a_dark_app_paints_the_page_white() {
fn the_view_paints_the_app_theme_and_back() {
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
cx.update(|app| {
ely_gpui_component::init_for_tests(app);
ely_gpui_component::theme::Theme::set_mode_now(ely_gpui_component::theme::Mode::Dark, app);
});
// A dark profile, as an old Ely left.
let dir = tempfile::tempdir().expect("a temp dir");
let profile = dir.path().join("profile");
std::fs::create_dir_all(profile.join("user")).expect("a profile");
std::fs::write(profile.join("user/registrymodifications.xcu"), DARK_PROFILE).expect("the scheme writes");
let source = dir.path().join("note.fodt");
std::fs::write(&source, crate::client::tests::FODT).expect("the fixture writes");
let mut started = crate::Worker::spawn(&install, &profile).expect("the worker");
let doc = started.load(&crate::file_url(&source)).expect("the document loads");
let laid = super::super::layout(&mut started, &doc).expect("the layout reads");
let worker = Arc::new(Mutex::new(started));
let shade = || {
let mut worker = worker.lock().expect("the worker");
let tile = doc.paint_tile(&mut worker, (1440, 1440, 3840, 3840), (64, 64)).expect("a tile");
let sum: u64 = tile.pixels.chunks_exact(4).map(|px| u64::from(px[0]) + u64::from(px[1]) + u64::from(px[2])).sum();
sum / (64 * 64 * 3)
};
worker.lock().expect("the worker").run(&doc, "view.theme", &["TEST_DARK"]).expect("the dark scheme");
let dark = shade();
assert!(dark < 100, "the kernel starts dark: {dark}");
let view = cx.update(|app| LokView::open(Arc::clone(&worker), doc.clone(), laid, app));
let shown = view.clone();
cx.add_window(move |_, _| Host(shown));
assert!(
settle(&cx, |app| view.read(app).lit() && view.read(app).settled()),
"the scheme goes out"
);
assert_eq!(paper(), gpui::white(), "the page mask is the scheme's white");
let light = shade();
assert!(light > 240, "the view paints the page white: {light}");
}
#[test]
fn selection_keeps_document_text_readable_across_themes() {
use ely_gpui_component::{
devtools::{contrast, over},
theme::{ActiveTheme, Mode, Palette, Theme},
};
let Some(install) = crate::client::tests::install() else {
eprintln!("SKIP: no pinned LOK build");
return;
};
let mut cx = TestAppContext::single();
cx.update(ely_gpui_component::init_for_tests);
let cx = TestAppContext::single();
let (view, worker) = cx.update(|app| live_view(app, &install));
let doc = cx.update(|app| view.read(app).doc.clone());
let shown = view.clone();
let window = cx.add_window(move |_, _| Host(shown));
worker
.lock()
.expect("the worker")
.run(&doc, "edit.select_all", &[])
.expect("select all");
let mut shades = Vec::new();
for dark in [true, false] {
cx.update(|app| view.update(app, |view, _| view.follow_theme(dark)));
assert!(
settle(&cx, |app| view.read(app).settled()),
"the theme goes out"
);
let mut worker = worker.lock().expect("the worker");
let tile = doc
.paint_tile(&mut worker, (1440, 1440, 3840, 3840), (64, 64))
.expect("a tile");
let sum: u64 = tile
.pixels
.chunks_exact(4)
.map(|px| u64::from(px[0]) + u64::from(px[1]) + u64::from(px[2]))
.sum();
shades.push(sum / (64 * 64 * 3));
}
assert!(
settle(&cx, |app| !view.read(app).selection.is_empty()),
"the selection arrives"
shades[0] < 100 && shades[1] > 200,
"dark then light: {shades:?}"
);
let mut themes = vec![
("dark".to_string(), Mode::Dark, None),
("light".to_string(), Mode::Light, None),
];
for (name, mode, color) in [
("omarchy dark", Mode::Dark, 0x292e42),
("omarchy light", Mode::Light, 0xccd0da),
] {
let mut palette = match mode {
Mode::Dark => Palette::dark(false),
Mode::Light => Palette::light(false),
};
palette.selection = gpui::rgb(color).into();
themes.push((name.to_string(), mode, Some(palette)));
}
let mut translucent = Palette::dark(false);
translucent.selection = gpui::rgb(0x292e42).into();
translucent.selection.a = 0.15;
themes.push(("translucent".to_string(), Mode::Dark, Some(translucent)));
for (name, mode, palette) in themes {
cx.update(|app| {
Theme::set_palette(mode, palette, app);
Theme::set_mode_now(mode, app);
});
cx.update_window(window.into(), |_, window, app| {
window.draw(app).clear(app);
let source = app.theme().colors.selection;
let selected: Vec<_> = window
.painted_quads()
.into_iter()
.filter_map(|quad| quad.background.as_solid())
.filter(|color| color.alpha(1.0) == source.alpha(1.0))
.collect();
assert!(!selected.is_empty(), "{name}: the selection is painted");
for color in selected {
for (text, background) in [(gpui::black(), paper()), (gpui::white(), gpui::black())]
{
let ratio = contrast(over(color, text), over(color, background));
assert!(
ratio >= 4.5,
"{name}: selected text stays readable: {ratio}"
);
}
assert!(
color.a > 0.0 && color.a <= source.a,
"{name}: the theme's opacity is respected"
);
if source.a < 0.3 {
assert_eq!(
color.a, source.a,
"{name}: a translucent theme stays unchanged"
);
}
}
})
.expect("the window lives");
}
}
#[test]
+7 -2
View File
@@ -7,7 +7,6 @@ use std::{
use crate::ffi::{Document, Lok};
use crate::ipc::{self, Pack, Unpack, tag};
mod complete;
mod events;
#[cfg(unix)]
mod fatal;
@@ -265,7 +264,13 @@ fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) {
.and_then(|id| with_doc(id, |doc| doc.selection_text()))
.and_then(|text| text)
.map(|text| Pack::new().str(&text).bytes()),
tag::COMPLETE_FUNCTION => complete::complete(unpack),
tag::COMPLETE_FUNCTION => unpack
.u64()
.and_then(|id| {
let name = unpack.str()?;
with_doc(id, |doc| doc.complete_function(&name)).and_then(|done| done)
})
.map(|()| Vec::new()),
tag::SET_AUTHOR => unpack
.str()
.and_then(|name| lok.set_option("author", &name))
-43
View File
@@ -1,43 +0,0 @@
//! A function the kernel offers, taken in input order (TABLE-03).
use std::io;
use super::with_doc;
use crate::ipc::{Pack, Unpack};
/// Takes an offered function, else posts Enter or Tab; 1 if taken.
pub(super) fn complete(unpack: &mut Unpack) -> io::Result<Vec<u8>> {
let id = unpack.u64()?;
let name = match unpack.u64()? {
0 => None,
_ => Some(unpack.str()?),
};
let (char_code, key_code) = (unpack.i64()?, unpack.i64()?);
// No key: a click; else Enter (1280) or Tab (1282).
if !matches!(key_code, 0 | 1280 | 1282) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"a completion takes Enter or Tab",
));
}
// Earlier input's callbacks already landed here.
let offered = crate::events::offered(id);
let take = match name {
Some(name) if offered.contains(&name) => Some(name),
// As the menu orders them: shortest first.
_ if key_code != 0 => offered.into_iter().min_by_key(String::len),
_ => None,
};
if let Some(name) = take {
log::info!("lok-worker: completes a function");
with_doc(id, |doc| doc.complete_function(&name))??;
return Ok(Pack::new().u64(1).bytes());
}
if key_code != 0 {
with_doc(id, |doc| {
doc.post_key(0, char_code as i32, key_code as i32);
doc.post_key(1, char_code as i32, key_code as i32);
})?;
}
Ok(Pack::new().u64(0).bytes())
}
-46
View File
@@ -1,46 +0,0 @@
[package]
name = "ely-mail-engine"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
base64.workspace = true
form_urlencoded = "1"
getrandom.workspace = true
html5ever.workspace = true
imap-proto.workspace = true
jiff.workspace = true
log.workspace = true
mail-builder.workspace = true
mail-parser.workspace = true
mail-send.workspace = true
nom.workspace = true
rustls.workspace = true
rustls-platform-verifier.workspace = true
rusqlite.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
smtp-proto = "0.2"
tokio.workspace = true
tokio-rustls.workspace = true
ureq.workspace = true
[dependencies.keyring]
version = "3"
features = ["apple-native", "windows-native"]
[target.'cfg(target_os = "linux")'.dependencies.keyring]
version = "3"
features = ["linux-native-sync-persistent"]
[dev-dependencies]
ely-platform.workspace = true
rcgen.workspace = true
tempfile.workspace = true
[lints]
workspace = true
-39
View File
@@ -1,39 +0,0 @@
use std::{env, fs, io::ErrorKind, path::Path};
const KEYS: [&str; 3] = [
"ELY_GOOGLE_CLIENT_ID",
"ELY_GOOGLE_CLIENT_SECRET",
"ELY_MICROSOFT_CLIENT_ID",
];
/// Client IDs: CI secrets, else the local oauth.env.
fn main() {
let file = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../oauth.env");
println!("cargo:rerun-if-changed={}", file.display());
for key in KEYS {
println!("cargo:rerun-if-env-changed={key}");
}
let text = match fs::read_to_string(&file) {
Ok(text) => text,
Err(err) if err.kind() == ErrorKind::NotFound => return,
Err(err) => panic!("oauth.env unreadable: {err}"),
};
for (number, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
panic!("oauth.env line {}: expected KEY=value", number + 1);
};
let key = key.trim();
assert!(
KEYS.contains(&key),
"oauth.env line {}: unknown key {key}",
number + 1
);
if env::var_os(key).is_none() {
println!("cargo:rustc-env={key}={}", value.trim());
}
}
}
-126
View File
@@ -1,126 +0,0 @@
//! An account's servers and how it signs in (MAIL-01, 02).
use serde::{Deserialize, Serialize};
use crate::{Error, Imap, Login, Pop, Result, Secret, Server, Smtp, TlsConnector, oauth};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Protocol {
Imap,
/// `keep` leaves each message on the server (§12.3).
Pop {
keep: bool,
},
}
/// A password, or OAuth with one provider.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Auth {
Password,
Google,
Microsoft,
}
impl Auth {
/// The OAuth server; None for a password.
pub fn provider(self) -> Option<Result<oauth::Provider>> {
match self {
Auth::Password => None,
Auth::Google => Some(oauth::google()),
Auth::Microsoft => Some(oauth::microsoft()),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
pub address: String,
pub user: String,
pub auth: Auth,
pub protocol: Protocol,
pub incoming: Server,
pub outgoing: Server,
}
/// What each server answered.
pub struct Checked {
pub incoming: Result<()>,
pub outgoing: Result<()>,
}
impl Checked {
pub fn passed(&self) -> bool {
self.incoming.is_ok() && self.outgoing.is_ok()
}
}
impl Account {
/// The vault name of its password or refresh token.
pub fn secret_name(&self) -> String {
format!("account:{}", self.address)
}
/// Refuses what no server could carry.
pub fn valid(&self) -> Result<()> {
crate::smtp::mailbox(&self.address)?;
if self.user.is_empty() || self.user.contains(['\r', '\n']) {
return Err(Error::Invalid("a user name on one line is needed".into()));
}
for server in [&self.incoming, &self.outgoing] {
if server.host.is_empty() || server.host.contains(char::is_whitespace) {
return Err(Error::Invalid(format!("a server host: {:?}", server.host)));
}
if server.port == 0 {
return Err(Error::Invalid("port 0".into()));
}
}
Ok(())
}
pub fn login(&self, secret: Secret) -> Login {
Login {
user: self.user.clone(),
secret,
}
}
/// Logs in to both servers at once, then leaves.
pub async fn check(&self, secret: Secret, tls: &TlsConnector) -> Checked {
let login = self.login(secret);
let (incoming, outgoing) = tokio::join!(
self.check_incoming(&login, tls),
check_outgoing(&self.outgoing, &login, tls)
);
log::info!(
"mail: check: incoming {}, outgoing {}",
outcome(&incoming),
outcome(&outgoing)
);
Checked { incoming, outgoing }
}
async fn check_incoming(&self, login: &Login, tls: &TlsConnector) -> Result<()> {
match self.protocol {
Protocol::Imap => {
Imap::connect(&self.incoming, login, tls)
.await?
.logout()
.await
}
Protocol::Pop { .. } => Pop::connect(&self.incoming, login, tls).await?.quit().await,
}
}
}
async fn check_outgoing(server: &Server, login: &Login, tls: &TlsConnector) -> Result<()> {
Smtp::connect(server, login, tls).await?.quit().await
}
fn outcome(result: &Result<()>) -> &'static str {
match result {
Ok(()) => "passed",
Err(_) => "failed",
}
}
-74
View File
@@ -1,74 +0,0 @@
use std::{fmt, io};
pub type Result<T> = std::result::Result<T, Error>;
/// Why a mail server conversation failed; server words kept.
#[derive(Debug)]
pub enum Error {
/// No TCP connection: name lookup, refusal, reset.
Connect(io::Error),
/// TLS handshake or certificate check failed.
Tls(String),
/// The server refused the credentials.
Rejected(String),
/// The server refused a command after login.
Server(String),
/// The server said something the client cannot read.
Protocol(String),
/// The server did not answer in time.
Timeout(&'static str),
/// An address or login the protocol cannot carry.
Invalid(String),
/// A message that cannot be read or written.
Mime(String),
/// The local store failed: database, disk or damage.
Store(String),
/// An account with this address is already kept.
Taken(String),
/// The store refuses writes; the reason says why.
ReadOnly(String),
/// The system credential store failed.
Vault(String),
/// This build has no client ID for the provider.
Unavailable(&'static str),
/// Sign-in did not finish; the reason says why.
SignIn(String),
/// The provider revoked the refresh token.
SignInAgain(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Connect(err) => write!(f, "cannot connect: {err}"),
Self::Tls(text) => write!(f, "TLS failed: {text}"),
Self::Rejected(text) => write!(f, "login refused: {text}"),
Self::Server(text) => write!(f, "server refused: {text}"),
Self::Protocol(text) => write!(f, "unreadable reply: {text}"),
Self::Timeout(step) => write!(f, "no answer while {step}"),
Self::Invalid(text) => write!(f, "cannot be sent: {text}"),
Self::Mime(text) => write!(f, "unreadable message: {text}"),
Self::Store(text) => write!(f, "local store failed: {text}"),
Self::Taken(address) => write!(f, "{address} is already added"),
Self::ReadOnly(text) => write!(f, "local store is read-only: {text}"),
Self::Vault(text) => write!(f, "credential store failed: {text}"),
Self::Unavailable(name) => write!(f, "{name} sign-in is not in this build"),
Self::SignIn(text) => write!(f, "sign-in failed: {text}"),
Self::SignInAgain(text) => write!(f, "sign in again: {text}"),
}
}
}
impl std::error::Error for Error {}
impl From<rusqlite::Error> for Error {
fn from(err: rusqlite::Error) -> Self {
Self::Store(err.to_string())
}
}
impl From<serde_json::Error> for Error {
fn from(err: serde_json::Error) -> Self {
Self::Store(format!("a stored field: {err}"))
}
}
-458
View File
@@ -1,458 +0,0 @@
use std::collections::HashMap;
use imap_proto::{AttributeValue, Capability, MailboxDatum, NameAttribute, Response, ResponseCode};
use tokio_rustls::TlsConnector;
use crate::{
Error, Result,
net::{self, Login, Secret, Security, Server, Stream, within},
};
mod conn;
mod parts;
use conn::{Arg, Conn};
pub use parts::Node;
/// A logged-in IMAP session over TLS.
pub struct Imap {
conn: Conn<Stream>,
}
/// What a folder is for (RFC 6154; INBOX by name).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
Inbox,
Sent,
Drafts,
Trash,
Junk,
Archive,
All,
Flagged,
}
impl Role {
pub fn name(self) -> &'static str {
match self {
Role::Inbox => "inbox",
Role::Sent => "sent",
Role::Drafts => "drafts",
Role::Trash => "trash",
Role::Junk => "junk",
Role::Archive => "archive",
Role::All => "all",
Role::Flagged => "flagged",
}
}
pub fn from_name(name: &str) -> Option<Self> {
[
Role::Inbox,
Role::Sent,
Role::Drafts,
Role::Trash,
Role::Junk,
Role::Archive,
Role::All,
Role::Flagged,
]
.into_iter()
.find(|role| role.name() == name)
}
}
/// One listed folder.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Folder {
pub name: String,
pub role: Option<Role>,
/// False for `\Noselect` and `\NonExistent`.
pub selectable: bool,
}
/// A folder opened read-only.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Opened {
pub uidvalidity: u32,
pub exists: u32,
}
/// One fetched message: header, text or whole.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Got {
pub uid: u32,
pub flags: Vec<String>,
pub bytes: Vec<u8>,
}
impl Imap {
pub async fn connect(server: &Server, login: &Login, tls: &TlsConnector) -> Result<Self> {
let tcp = net::tcp(server).await?;
let stream = match server.security {
Security::Tls => net::tls(tcp, server, tls).await?,
Security::StartTls => {
let mut plain = Conn::new(tcp);
within("waiting for the greeting", plain.greeting()).await??;
within("starting TLS", plain.run("STARTTLS", &[], |_, _| {}))
.await??
.map_err(Error::Server)?;
net::tls(plain.wire.into_inner()?, server, tls).await?
}
};
let mut conn = Conn::new(stream);
if server.security == Security::Tls {
within("waiting for the greeting", conn.greeting()).await??;
}
let outcome = match &login.secret {
Secret::Password(password) => {
let args = [Arg::Text(&login.user), Arg::Text(password)];
within("logging in", conn.run("LOGIN", &args, |_, _| {})).await??
}
Secret::Token(token) => {
within("logging in", conn.xoauth2(&login.xoauth2(token))).await??
}
};
outcome.map_err(Error::Rejected)?;
log::info!("mail: IMAP login on {}", server.host);
Ok(Self { conn })
}
/// Every folder the server lists, with its role.
pub async fn folders(&mut self) -> Result<Vec<Folder>> {
let can = self.capabilities().await?;
let has = |name: &str| can.iter().any(|it| it.eq_ignore_ascii_case(name));
// RFC 6154 §2: roles may come only on request.
let extended = has("LIST-EXTENDED") && has("SPECIAL-USE");
let mut folders = Vec::new();
let all = [
Arg::Text(""),
Arg::Atom("*"),
Arg::Atom("RETURN (SPECIAL-USE)"),
];
let args = if extended { &all[..] } else { &all[..2] };
within(
"listing mailboxes",
self.conn.run("LIST", args, |response, raw| {
if let Response::MailboxData(MailboxDatum::List(list)) = response {
let name = mailbox_name(&list.name, raw);
folders.push(folder(name, &list.name_attributes));
}
}),
)
.await??
.map_err(Error::Server)?;
Ok(folders)
}
async fn capabilities(&mut self) -> Result<Vec<String>> {
let mut can = Vec::new();
within(
"reading capabilities",
self.conn.run("CAPABILITY", &[], |response, _| {
if let Response::Capabilities(list) = response {
can.extend(list.iter().filter_map(|it| match it {
Capability::Atom(atom) => Some(atom.to_string()),
_ => None,
}));
}
}),
)
.await??
.map_err(Error::Server)?;
Ok(can)
}
pub async fn examine(&mut self, mailbox: &str) -> Result<Opened> {
let (mut uidvalidity, mut exists) = (None, None);
within(
"opening the mailbox",
self.conn.run(
"EXAMINE",
&[Arg::Text(mailbox)],
|response, _| match response {
Response::Data { outcome, .. } => {
if let Some(ResponseCode::UidValidity(value)) = &outcome.code {
uidvalidity = Some(*value);
}
}
Response::MailboxData(MailboxDatum::Exists(count)) => exists = Some(*count),
_ => {}
},
),
)
.await??
.map_err(Error::Server)?;
let uidvalidity = uidvalidity
.ok_or_else(|| Error::Protocol("the mailbox opened without a UIDVALIDITY".into()))?;
// RFC 3501 §6.3.1: EXISTS is required.
let exists =
exists.ok_or_else(|| Error::Protocol("the mailbox opened without EXISTS".into()))?;
Ok(Opened {
uidvalidity,
exists,
})
}
/// Every UID in the open mailbox with its flags.
pub async fn flags(&mut self) -> Result<Vec<(u32, Vec<String>)>> {
let got = self
.uid_fetch("1:*", "(UID FLAGS)", "reading flags")
.await?;
Ok(got.into_iter().map(|(uid, it)| (uid, it.flags)).collect())
}
/// Headers of `uids`; a UID gone meanwhile is absent.
pub async fn headers(&mut self, uids: &[u32]) -> Result<Vec<Got>> {
let what = "(UID FLAGS BODY.PEEK[HEADER])";
let got = self.uid_fetch(&set(uids), what, "fetching headers").await?;
asked(got, uids, "HEADER")
}
/// Whole messages of `uids`; a UID gone meanwhile is absent.
pub async fn bodies(&mut self, uids: &[u32]) -> Result<Vec<Got>> {
let what = "(UID FLAGS BODY.PEEK[])";
let got = self
.uid_fetch(&set(uids), what, "fetching messages")
.await?;
asked(got, uids, "")
}
/// Each UID's MIME tree; a UID gone meanwhile is absent.
pub async fn structures(&mut self, uids: &[u32]) -> Result<Vec<(u32, Node)>> {
let what = "(UID BODYSTRUCTURE)";
let mut got = self
.uid_fetch(&set(uids), what, "reading structures")
.await?;
let mut out = Vec::new();
for &uid in uids {
let Some(it) = got.remove(&uid) else {
continue;
};
let node = it
.structure
.ok_or_else(|| Error::Protocol(format!("UID {uid} came without a structure")))?;
out.push((uid, node?));
}
Ok(out)
}
/// The message without its left-out parts; `None` once gone.
pub async fn text(&mut self, uid: u32, node: &Node) -> Result<Option<Got>> {
let peeks: Vec<String> = node
.sections()
.iter()
.map(|section| format!("BODY.PEEK[{section}]"))
.collect();
let what = format!("(UID FLAGS {})", peeks.join(" "));
let mut got = self
.uid_fetch(&uid.to_string(), &what, "fetching message text")
.await?;
let Some(it) = got.remove(&uid) else {
return Ok(None);
};
Ok(Some(Got {
uid,
bytes: node.build(&it.sections)?,
flags: it.flags,
}))
}
/// UIDs of messages that arrived on or after `day` (`19-Jul-2026`).
pub async fn since(&mut self, day: &str) -> Result<Vec<u32>> {
let mut uids = Vec::new();
let args = [Arg::Atom("SINCE"), Arg::Atom(day)];
within(
"searching by date",
self.conn.run("UID SEARCH", &args, |response, _| {
if let Response::MailboxData(MailboxDatum::Search(found)) = response {
uids.extend_from_slice(found);
}
}),
)
.await??
.map_err(Error::Server)?;
uids.sort_unstable();
Ok(uids)
}
/// The raw message; `None` once the UID is gone.
pub async fn fetch(&mut self, mailbox: &str, uid: u32) -> Result<Option<Vec<u8>>> {
self.examine(mailbox).await?;
Ok(self.bodies(&[uid]).await?.pop().map(|got| got.bytes))
}
pub async fn logout(mut self) -> Result<()> {
within("logging out", self.conn.run("LOGOUT", &[], |_, _| {}))
.await??
.map_err(Error::Server)
}
/// Every FETCH for a UID, merged; flags may come apart.
async fn uid_fetch(
&mut self,
uids: &str,
what: &str,
step: &'static str,
) -> Result<HashMap<u32, Fetch>> {
let mut got: HashMap<u32, Fetch> = HashMap::new();
let args = [Arg::Atom(uids), Arg::Atom(what)];
within(
step,
self.conn.run("UID FETCH", &args, |response, _| {
if let Response::Fetch(_, values) = response {
merge(&mut got, values);
}
}),
)
.await??
.map_err(Error::Server)?;
Ok(got)
}
}
#[derive(Default)]
struct Fetch {
flags: Vec<String>,
sections: HashMap<String, Vec<u8>>,
structure: Option<Result<Node>>,
}
/// Unsolicited FETCH responses without a UID are skipped.
fn merge(got: &mut HashMap<u32, Fetch>, values: &[AttributeValue]) {
let Some(uid) = values.iter().find_map(|value| match value {
AttributeValue::Uid(uid) => Some(*uid),
_ => None,
}) else {
return;
};
let it = got.entry(uid).or_default();
for value in values {
match value {
AttributeValue::Flags(flags) => {
// \Recent belongs to one session.
it.flags = flags
.iter()
.filter(|flag| !flag.eq_ignore_ascii_case(r"\Recent"))
.map(|flag| flag.to_string())
.collect();
it.flags.sort();
}
AttributeValue::BodySection {
section,
data: Some(data),
..
} => {
it.sections
.insert(parts::section_key(section), data.to_vec());
}
AttributeValue::BodyStructure(structure) => {
it.structure = Some(Node::read(structure));
}
_ => {}
}
}
}
/// The asked UIDs in order; each needs `section`.
fn asked(mut got: HashMap<u32, Fetch>, uids: &[u32], section: &str) -> Result<Vec<Got>> {
let mut out = Vec::new();
for &uid in uids {
let Some(mut it) = got.remove(&uid) else {
continue;
};
let bytes = it
.sections
.remove(section)
.ok_or_else(|| Error::Protocol(format!("UID {uid} came without {section:?}")))?;
out.push(Got {
uid,
flags: it.flags,
bytes,
});
}
Ok(out)
}
/// `1:3,7` from sorted UIDs.
fn set(uids: &[u32]) -> String {
let mut out = String::new();
let mut at = 0;
while at < uids.len() {
let start = uids[at];
let mut end = start;
while at + 1 < uids.len() && uids[at + 1] == end + 1 {
at += 1;
end = uids[at];
}
if !out.is_empty() {
out.push(',');
}
match start == end {
true => out.push_str(&start.to_string()),
false => out.push_str(&format!("{start}:{end}")),
}
at += 1;
}
out
}
fn folder(name: String, attributes: &[NameAttribute]) -> Folder {
let mut role = name.eq_ignore_ascii_case("INBOX").then_some(Role::Inbox);
let mut selectable = true;
for attribute in attributes {
match attribute {
NameAttribute::NoSelect => selectable = false,
NameAttribute::Extension(other) if other.eq_ignore_ascii_case("\\NonExistent") => {
selectable = false
}
NameAttribute::Sent => role = Some(Role::Sent),
NameAttribute::Drafts => role = Some(Role::Drafts),
NameAttribute::Trash => role = Some(Role::Trash),
NameAttribute::Junk => role = Some(Role::Junk),
NameAttribute::Archive => role = Some(Role::Archive),
NameAttribute::All => role = Some(Role::All),
NameAttribute::Flagged => role = Some(Role::Flagged),
_ => {}
}
}
Folder {
name,
role,
selectable,
}
}
/// imap-proto keeps quoted escapes; a literal is as sent.
fn mailbox_name(name: &str, raw: &[u8]) -> String {
let line = raw.strip_suffix(b"\r\n").unwrap_or(raw);
let name_end = |end: &[u8], mark: &[u8]| {
end.strip_suffix(name.as_bytes())
.is_some_and(|rest| rest.ends_with(mark))
};
let literal = name_end(line, b"}\r\n");
let quoted = line
.strip_suffix(b"\"")
.is_some_and(|end| name_end(end, b"\""));
if literal || !quoted {
return name.to_owned();
}
let mut plain = String::with_capacity(name.len());
let mut chars = name.chars();
while let Some(char) = chars.next() {
match char {
'\\' => plain.extend(chars.next()),
_ => plain.push(char),
}
}
plain
}
#[cfg(test)]
mod tests {
#[test]
fn a_uid_set_joins_runs() {
assert_eq!(super::set(&[1, 2, 3, 7, 9, 10]), "1:3,7,9:10");
assert_eq!(super::set(&[5]), "5");
}
}
-197
View File
@@ -1,197 +0,0 @@
//! Tagged commands, literals and replies on one stream.
use base64::{Engine, engine::general_purpose::STANDARD};
use imap_proto::{Response, Status};
use tokio::io::{AsyncRead, AsyncWrite};
use crate::{
Error, Result,
wire::{Wire, words},
};
/// One command argument.
pub(super) enum Arg<'a> {
/// Written as is.
Atom(&'a str),
/// Quoted, or a literal when quotes cannot carry it.
Text(&'a str),
}
/// OK, or the words of NO or BAD.
pub(super) type Outcome = std::result::Result<(), String>;
pub(super) struct Conn<S> {
pub(super) wire: Wire<S>,
tags: u32,
}
impl<S: AsyncRead + AsyncWrite + Unpin> Conn<S> {
pub(super) fn new(io: S) -> Self {
Self {
wire: Wire::new(io),
tags: 0,
}
}
/// `* BYE` ends with the server's words.
pub(super) async fn greeting(&mut self) -> Result<()> {
self.wire
.next(|response, raw| match response {
Response::Data {
status: Status::Ok, ..
} => Ok(()),
Response::Data {
status: Status::Bye,
..
} => Err(Error::Server(words(raw))),
Response::Data { status, .. } => Err(Error::Protocol(format!(
"the greeting was {status:?}, not OK"
))),
_ => Err(Error::Protocol("the first line was not a greeting".into())),
})
.await?
}
fn tag(&mut self) -> String {
self.tags += 1;
format!("A{}", self.tags)
}
/// Sends a command; untagged responses go to `each`.
pub(super) async fn run(
&mut self,
verb: &str,
args: &[Arg<'_>],
mut each: impl FnMut(&Response, &[u8]),
) -> Result<Outcome> {
let tag = self.tag();
log::debug!("mail: IMAP {tag} {verb}");
let mut line = format!("{tag} {verb}").into_bytes();
for arg in args {
line.push(b' ');
match arg {
Arg::Atom(atom) => line.extend_from_slice(atom.as_bytes()),
Arg::Text(text) => match quoted(text)? {
Some(quoted) => line.extend_from_slice(quoted.as_bytes()),
None => {
line.extend_from_slice(format!("{{{}}}\r\n", text.len()).as_bytes());
self.wire.write(&line).await?;
line.clear();
if let Err(words) = self.proceed(&tag, &mut each).await? {
return Ok(Err(words));
}
line.extend_from_slice(text.as_bytes());
}
},
}
}
line.extend_from_slice(b"\r\n");
self.wire.write(&line).await?;
loop {
match self.step(&tag, &mut each).await? {
Step::Done(outcome) => return Ok(outcome),
Step::Untagged => {}
Step::Continue => {
return Err(Error::Protocol(
"the server asked for more than was sent".into(),
));
}
}
}
}
/// Waits for `+` before a literal.
async fn proceed(
&mut self,
tag: &str,
each: &mut impl FnMut(&Response, &[u8]),
) -> Result<Outcome> {
loop {
match self.step(tag, each).await? {
Step::Continue => return Ok(Ok(())),
Step::Done(Ok(())) => {
return Err(Error::Protocol(
"the command ended before its literal".into(),
));
}
Step::Done(refused) => return Ok(refused),
Step::Untagged => {}
}
}
}
/// Answers the first challenge; empty after, so a refusal ends.
pub(super) async fn xoauth2(&mut self, initial: &str) -> Result<Outcome> {
let tag = self.tag();
self.wire
.write(format!("{tag} AUTHENTICATE XOAUTH2\r\n").as_bytes())
.await?;
let mut answer = Some(STANDARD.encode(initial));
loop {
match self.step(&tag, &mut |_, _| {}).await? {
Step::Continue => {
let line = answer.take().unwrap_or_default() + "\r\n";
self.wire.write(line.as_bytes()).await?;
}
Step::Done(outcome) => return Ok(outcome),
Step::Untagged => {}
}
}
}
async fn step(&mut self, tag: &str, each: &mut impl FnMut(&Response, &[u8])) -> Result<Step> {
self.wire
.next(|response, raw| match response {
Response::Continue(_) => Ok(Step::Continue),
Response::Done {
tag: done, status, ..
} => {
if done.0 != tag {
return Err(Error::Protocol(format!(
"a reply for {} came while waiting for {tag}",
done.0
)));
}
match status {
Status::Ok => Ok(Step::Done(Ok(()))),
Status::No | Status::Bad => Ok(Step::Done(Err(words(raw)))),
other => Err(Error::Protocol(format!("{tag} ended with {other:?}"))),
}
}
untagged => {
each(untagged, raw);
Ok(Step::Untagged)
}
})
.await?
}
}
enum Step {
Continue,
Untagged,
Done(Outcome),
}
/// `None` when only a literal can carry the text.
fn quoted(text: &str) -> Result<Option<String>> {
if text.contains('\0') {
return Err(Error::Invalid("IMAP cannot carry a NUL byte".into()));
}
if !text
.bytes()
.all(|byte| byte.is_ascii() && byte != b'\r' && byte != b'\n')
{
return Ok(None);
}
let mut quoted = String::with_capacity(text.len() + 2);
quoted.push('"');
for char in text.chars() {
if char == '"' || char == '\\' {
quoted.push('\\');
}
quoted.push(char);
}
quoted.push('"');
Ok(Some(quoted))
}
-290
View File
@@ -1,290 +0,0 @@
//! Text without attachments, from BODYSTRUCTURE (§12.3).
use std::collections::HashMap;
use imap_proto::{BodyParams, BodyStructure, MessageSection, SectionPath};
use crate::{Error, Result};
/// A message's MIME tree; paths are IMAP part numbers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Node {
/// `body` parts come down; the rest wait for a request.
Leaf { path: Vec<u32>, body: bool },
Multi {
path: Vec<u32>,
boundary: String,
children: Vec<Node>,
},
}
impl Node {
/// The reader's rules: `mime/walk.rs`.
pub(super) fn read(structure: &BodyStructure) -> Result<Self> {
match structure {
BodyStructure::Multipart { .. } => body(structure, Vec::new()),
single => Ok(Node::Leaf {
path: vec![1],
body: !attachment(single) && shown(single),
}),
}
}
/// True when nothing is left out.
pub fn complete(&self) -> bool {
match self {
Node::Leaf { body, .. } => *body,
Node::Multi { children, .. } => children.iter().all(Node::complete),
}
}
/// Part numbers left out, as `2` or `1.3`.
pub fn missing(&self) -> Vec<String> {
let mut out = Vec::new();
self.walk(&mut |node| {
if let Node::Leaf { path, body: false } = node {
out.push(number(path));
}
});
out
}
/// The FETCH sections that rebuild it.
pub fn sections(&self) -> Vec<String> {
let mut out = vec!["HEADER".to_owned()];
if let Node::Leaf { body, .. } = self {
if *body {
out.push("1".into());
}
return out;
}
self.walk(&mut |node| match node {
Node::Leaf { path, body } => {
out.push(mime(path));
if *body {
out.push(number(path));
}
}
Node::Multi { path, .. } if !path.is_empty() => out.push(mime(path)),
Node::Multi { .. } => {}
});
out
}
/// The message with left-out parts empty.
pub fn build(&self, got: &HashMap<String, Vec<u8>>) -> Result<Vec<u8>> {
let mut out = header(take(got, "HEADER")?);
match self {
Node::Leaf { body: true, .. } => out.extend_from_slice(take(got, "1")?),
Node::Leaf { body: false, .. } => {}
multi => content(multi, got, &mut out)?,
}
Ok(out)
}
fn walk(&self, each: &mut impl FnMut(&Node)) {
each(self);
if let Node::Multi { children, .. } = self {
for child in children {
child.walk(each);
}
}
}
}
/// The key a FETCH section comes back under.
pub(super) fn section_key(section: &Option<SectionPath>) -> String {
let named = |part: &MessageSection| match part {
MessageSection::Header => "HEADER",
MessageSection::Mime => "MIME",
MessageSection::Text => "TEXT",
};
match section {
None => String::new(),
Some(SectionPath::Full(part)) => named(part).to_owned(),
Some(SectionPath::Part(path, None)) => number(path),
Some(SectionPath::Part(path, Some(part))) => format!("{}.{}", number(path), named(part)),
}
}
/// RFC 2183 §2.8, §2.9: the whole entity waits.
fn body(structure: &BodyStructure, path: Vec<u32>) -> Result<Node> {
if attachment(structure) {
return Ok(Node::Leaf { path, body: false });
}
inside(structure, path)
}
fn inside(structure: &BodyStructure, path: Vec<u32>) -> Result<Node> {
let BodyStructure::Multipart { common, bodies, .. } = structure else {
return Ok(Node::Leaf {
body: shown(structure),
path,
});
};
let boundary = param(&common.ty.params, "boundary")
.ok_or_else(|| Error::Protocol("a multipart came without a boundary".into()))?;
let related = common.ty.subtype.eq_ignore_ascii_case("related");
let root = related.then(|| root(&common.ty.params, bodies));
let children = bodies
.iter()
.zip(1..)
.enumerate()
.map(|(at, (child, number))| {
let mut next = path.clone();
next.push(number);
match root {
None => body(child, next),
Some(Root::At(root)) if root == at => inside(child, next),
Some(Root::Unseen) if multipart(child) => inside(child, next),
// RFC 2387 §4, §6.1: one entity, no disposition.
Some(_) => Ok(Node::Leaf {
body: id(child).is_some(),
path: next,
}),
}
})
.collect::<Result<_>>()?;
Ok(Node::Multi {
path,
boundary: boundary.to_owned(),
children,
})
}
/// The related root, or one hidden in a multipart.
#[derive(Clone, Copy)]
enum Root {
At(usize),
Unseen,
}
/// RFC 2387: `start` names the root, else the first.
fn root(params: &BodyParams, bodies: &[BodyStructure]) -> Root {
let Some(start) = param(params, "start") else {
return Root::At(0);
};
if let Some(at) = bodies
.iter()
.position(|child| id(child).is_some_and(|id| bare(id) == bare(start)))
{
return Root::At(at);
}
if bodies.iter().any(multipart) {
return Root::Unseen;
}
log::warn!("mail: related start names no part; first part is the root");
Root::At(0)
}
fn multipart(structure: &BodyStructure) -> bool {
matches!(structure, BodyStructure::Multipart { .. })
}
/// Plain and HTML text, and `cid:` targets.
fn shown(structure: &BodyStructure) -> bool {
match structure {
BodyStructure::Text { common, .. }
if ["plain", "html"]
.iter()
.any(|kind| common.ty.subtype.eq_ignore_ascii_case(kind)) =>
{
true
}
BodyStructure::Text { .. } | BodyStructure::Basic { .. } => id(structure).is_some(),
BodyStructure::Message { .. } | BodyStructure::Multipart { .. } => false,
}
}
/// RFC 2183 §2.8: unknown dispositions are attachments.
fn attachment(structure: &BodyStructure) -> bool {
let common = match structure {
BodyStructure::Basic { common, .. }
| BodyStructure::Text { common, .. }
| BodyStructure::Message { common, .. }
| BodyStructure::Multipart { common, .. } => common,
};
common
.disposition
.as_ref()
.is_some_and(|it| !it.ty.eq_ignore_ascii_case("inline"))
}
fn id<'a>(structure: &'a BodyStructure) -> Option<&'a str> {
match structure {
BodyStructure::Basic { other, .. }
| BodyStructure::Text { other, .. }
| BodyStructure::Message { other, .. } => other.id.as_deref(),
BodyStructure::Multipart { .. } => None,
}
}
fn bare(id: &str) -> &str {
id.trim().trim_start_matches('<').trim_end_matches('>')
}
fn param<'a>(params: &'a BodyParams, name: &str) -> Option<&'a str> {
params
.iter()
.flatten()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_ref())
}
fn content(node: &Node, got: &HashMap<String, Vec<u8>>, out: &mut Vec<u8>) -> Result<()> {
match node {
Node::Leaf { path, body: true } => out.extend_from_slice(take(got, &number(path))?),
Node::Leaf { body: false, .. } => {}
Node::Multi {
boundary, children, ..
} => {
for child in children {
let path = match child {
Node::Leaf { path, .. } | Node::Multi { path, .. } => path,
};
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
out.extend(header(take(got, &mime(path))?));
content(child, got, out)?;
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
}
}
Ok(())
}
/// A header block ends with a blank line.
fn header(bytes: &[u8]) -> Vec<u8> {
let mut out = bytes.to_vec();
if out.is_empty() {
return b"\r\n".to_vec();
}
while !out.ends_with(b"\r\n\r\n") {
out.extend_from_slice(b"\r\n");
}
out
}
fn take<'a>(got: &'a HashMap<String, Vec<u8>>, key: &str) -> Result<&'a [u8]> {
got.get(key)
.map(Vec::as_slice)
.ok_or_else(|| Error::Protocol(format!("section {key} did not come")))
}
/// A whole message's body is `TEXT`.
fn number(path: &[u32]) -> String {
if path.is_empty() {
return "TEXT".into();
}
path.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(".")
}
fn mime(path: &[u32]) -> String {
format!("{}.MIME", number(path))
}
#[cfg(test)]
mod tests;
@@ -1,151 +0,0 @@
use std::collections::HashMap;
use imap_proto::{AttributeValue, Response};
use super::Node;
/// The node of a `* 1 FETCH (BODYSTRUCTURE …)` line.
fn node(structure: &str) -> Node {
let line = format!("* 1 FETCH (BODYSTRUCTURE {structure})\r\n");
let (_, response) = Response::parse(line.as_bytes()).unwrap();
let Response::Fetch(_, attributes) = response else {
panic!("not a FETCH: {line}");
};
let [AttributeValue::BodyStructure(structure)] = attributes.as_slice() else {
panic!("no structure: {line}");
};
Node::read(structure).unwrap()
}
/// Reads the rebuilt message; sections not given come empty.
fn rebuilt(node: &Node, given: &[(&str, &str)]) -> crate::Mail {
let mut got: HashMap<String, Vec<u8>> = given
.iter()
.map(|(key, text)| ((*key).to_owned(), text.replace('\n', "\r\n").into_bytes()))
.collect();
for section in node.sections() {
got.entry(section).or_default();
}
crate::read(&node.build(&got).unwrap()).unwrap()
}
const TEXT: &str = "(\"TEXT\" \"PLAIN\" (\"CHARSET\" \"us-ascii\") NIL NIL \"7BIT\" 6 1)";
const HTML: &str = "(\"TEXT\" \"HTML\" (\"CHARSET\" \"us-ascii\") NIL NIL \"7BIT\" 9 1)";
#[test]
fn an_attached_multipart_waits_whole() {
let structure = format!(
"({TEXT}({TEXT}{HTML} \"ALTERNATIVE\" (\"BOUNDARY\" \"b2\") \
(\"ATTACHMENT\" (\"FILENAME\" \"note\")) NIL NIL) \"MIXED\" (\"BOUNDARY\" \"b1\") NIL NIL NIL)"
);
let node = node(&structure);
assert!(!node.complete());
assert_eq!(node.missing(), ["2"]);
assert_eq!(node.sections(), ["HEADER", "1.MIME", "1", "2.MIME"]);
let mail = rebuilt(
&node,
&[
(
"HEADER",
"MIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=\"b1\"\n\n",
),
("1.MIME", "Content-Type: text/plain\n\n"),
("1", "hello\n"),
(
"2.MIME",
"Content-Type: multipart/alternative; boundary=\"b2\"\n\
Content-Disposition: attachment; filename=\"note\"\n\n",
),
],
);
assert_eq!(mail.text.as_deref().map(str::trim_end), Some("hello"));
assert_eq!(mail.attachments.len(), 1);
assert_eq!(mail.attachments[0].content_type, "multipart/alternative");
}
#[test]
fn a_related_image_marked_attachment_comes_down() {
let structure = format!(
"({HTML}(\"IMAGE\" \"PNG\" NIL \"<logo>\" NIL \"BASE64\" 12 NIL \
(\"ATTACHMENT\" (\"FILENAME\" \"logo.png\")) NIL NIL) \
\"RELATED\" (\"BOUNDARY\" \"r1\") NIL NIL NIL)"
);
let node = node(&structure);
assert!(node.complete());
assert!(node.missing().is_empty());
let mail = rebuilt(
&node,
&[
(
"HEADER",
"MIME-Version: 1.0\nContent-Type: multipart/related; boundary=\"r1\"\n\n",
),
("1.MIME", "Content-Type: text/html\n\n"),
("1", "<img src=\"cid:logo\">\n"),
(
"2.MIME",
"Content-Type: image/png\nContent-ID: <logo>\n\
Content-Disposition: attachment; filename=\"logo.png\"\n\
Content-Transfer-Encoding: base64\n\n",
),
("2", "iVBORw0KGgo=\n"),
],
);
assert_eq!(mail.inline.len(), 1);
assert_eq!(mail.inline[0].bytes, b"\x89PNG\r\n\x1a\n");
}
#[test]
fn a_related_start_root_ignores_its_disposition() {
let structure = "((\"IMAGE\" \"PNG\" NIL NIL NIL \"BASE64\" 8 NIL NIL NIL NIL)\
(\"TEXT\" \"HTML\" NIL \"<root>\" NIL \"7BIT\" 9 1 NIL (\"ATTACHMENT\" NIL) NIL NIL) \
\"RELATED\" (\"BOUNDARY\" \"r1\" \"START\" \"<root>\") NIL NIL NIL)";
let node = node(structure);
assert_eq!(node.missing(), ["1"]);
}
#[test]
fn an_attached_message_root_waits_whole() {
let structure =
format!("({TEXT}{TEXT} \"MIXED\" (\"BOUNDARY\" \"b1\") (\"ATTACHMENT\" NIL) NIL NIL)");
let node = node(&structure);
assert_eq!(node.missing(), ["TEXT"]);
assert_eq!(node.sections(), ["HEADER"]);
}
#[test]
fn a_related_start_may_name_a_multipart() {
let structure = format!(
"((\"IMAGE\" \"PNG\" NIL \"<logo>\" NIL \"BASE64\" 12 NIL NIL NIL NIL)\
({TEXT}{HTML} \"ALTERNATIVE\" (\"BOUNDARY\" \"b2\") NIL NIL NIL) \
\"RELATED\" (\"BOUNDARY\" \"r1\" \"START\" \"<root>\") NIL NIL NIL)"
);
let node = node(&structure);
assert!(node.complete());
let mail = rebuilt(
&node,
&[
(
"HEADER",
"MIME-Version: 1.0\nContent-Type: multipart/related; boundary=\"r1\"; \
start=\"<root>\"\n\n",
),
(
"1.MIME",
"Content-Type: image/png\nContent-ID: <logo>\n\
Content-Transfer-Encoding: base64\n\n",
),
("1", "iVBORw0KGgo=\n"),
(
"2.MIME",
"Content-Type: multipart/alternative; boundary=\"b2\"\nContent-ID: <root>\n\n",
),
("2.1.MIME", "Content-Type: text/plain\n\n"),
("2.1", "hello\n"),
("2.2.MIME", "Content-Type: text/html\n\n"),
("2.2", "<p>hello</p>\n"),
],
);
assert_eq!(mail.text.as_deref().map(str::trim_end), Some("hello"));
assert!(mail.html.is_some());
}
-28
View File
@@ -1,28 +0,0 @@
//! Mail protocols, sync and local storage for ElyMail.
mod account;
mod error;
mod imap;
mod mime;
mod net;
pub mod oauth;
mod pop;
mod smtp;
mod store;
pub mod sync;
mod vault;
mod wire;
pub use account::{Account, Auth, Checked, Protocol};
pub use error::{Error, Result};
pub use imap::{Folder, Got, Imap, Opened, Role};
pub use mime::{Built, Draft, Mail, Mailbox, Part, build, read};
pub use net::{Login, Secret, Security, Server, system_tls};
pub use pop::Pop;
pub use smtp::Smtp;
pub use store::{
AccountId, Cached, Fetched, Filter, Held, Key, LocalFolder, MailboxId, MessageId, Scope, Sizes,
Store, Stored, Summary, Synced,
};
pub use tokio_rustls::TlsConnector;
pub use vault::{Backend, Kept, Keyring, Vault};
-275
View File
@@ -1,275 +0,0 @@
use mail_builder::{
MessageBuilder,
headers::{address::Address as Out, content_type::ContentType, message_id::MessageId},
mime::MimePart,
};
use mail_parser::{Address, HeaderValue, MessageParser};
use serde::{Deserialize, Serialize};
use crate::{Error, Result, smtp::mailbox};
mod walk;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Mailbox {
pub name: Option<String>,
pub email: String,
}
/// An attachment or a `cid:` target.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Part {
pub name: Option<String>,
pub content_type: String,
/// Content-Type parameters, such as charset.
pub params: Vec<(String, String)>,
pub content_id: Option<String>,
/// Transfer-decoded; a multipart keeps its headers.
pub bytes: Vec<u8>,
}
/// A message as read; bodies are the sender's own parts.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mail {
pub subject: Option<String>,
pub from: Vec<Mailbox>,
pub to: Vec<Mailbox>,
pub cc: Vec<Mailbox>,
/// Seconds since the Unix epoch.
pub date: Option<i64>,
pub message_id: Option<String>,
pub in_reply_to: Vec<String>,
pub references: Vec<String>,
pub text: Option<String>,
pub html: Option<String>,
pub inline: Vec<Part>,
pub attachments: Vec<Part>,
/// The header block as it arrived.
pub headers: String,
}
/// A message to write; Bcc stays in the SMTP envelope.
#[derive(Clone, Debug)]
pub struct Draft {
pub from: Mailbox,
pub to: Vec<Mailbox>,
pub cc: Vec<Mailbox>,
pub subject: String,
pub text: String,
pub html: Option<String>,
pub attachments: Vec<Part>,
pub in_reply_to: Option<String>,
pub references: Vec<String>,
/// Seconds since the Unix epoch.
pub date: i64,
}
pub struct Built {
/// Without angle brackets, as `Mail::message_id` reads it.
pub message_id: String,
pub bytes: Vec<u8>,
}
pub fn read(raw: &[u8]) -> Result<Mail> {
let message = MessageParser::default()
.parse(raw)
.ok_or_else(|| Error::Mime("not a mail message".into()))?;
let problems = message
.parts
.iter()
.filter(|part| part.is_encoding_problem)
.count();
if problems > 0 {
log::warn!("mail: {problems} MIME parts did not decode cleanly");
}
if let Err(err) = walk::depth(&message) {
walk::dispose(message);
return Err(err);
}
let header_end = message.root_part().offset_body as usize;
let shape = walk::shape(&message);
Ok(Mail {
subject: message.subject().map(str::to_owned),
from: mailboxes(message.from().into_iter()),
to: mailboxes(message.all_to()),
cc: mailboxes(message.all_cc()),
date: message.date().map(|date| date.to_timestamp()),
message_id: message.message_id().map(str::to_owned),
in_reply_to: ids(message.in_reply_to()),
references: ids(message.references()),
text: joined(shape.text),
html: joined(shape.html),
inline: shape.inline,
attachments: shape.attachments,
headers: String::from_utf8_lossy(&raw[..header_end.min(raw.len())]).into_owned(),
})
}
/// Whether any header block needs SMTPUTF8.
pub(crate) fn utf8_headers(raw: &[u8]) -> Result<bool> {
let message = MessageParser::default()
.parse(raw)
.ok_or_else(|| Error::Mime("not a mail message".into()))?;
let checked = walk::depth(&message).map(|()| walk::utf8_headers(&message));
walk::dispose(message);
checked
}
pub fn build(draft: &Draft) -> Result<Built> {
let people = [&draft.from].into_iter().chain(&draft.to).chain(&draft.cc);
for person in people {
mailbox(&person.email)?;
line("a name", person.name.as_deref().unwrap_or_default())?;
}
line("the subject", &draft.subject)?;
for id in draft.in_reply_to.iter().chain(&draft.references) {
line("a message ID", id)?;
}
let message_id = message_id(&draft.from.email)?;
let mut builder = MessageBuilder::new()
.message_id(message_id.as_str())
.date(draft.date)
.from(address(&draft.from))
.subject(draft.subject.as_str())
.body(body(draft)?);
if !draft.to.is_empty() {
builder = builder.to(Out::new_list(draft.to.iter().map(address).collect()));
}
if !draft.cc.is_empty() {
builder = builder.cc(Out::new_list(draft.cc.iter().map(address).collect()));
}
if let Some(id) = &draft.in_reply_to {
builder = builder.in_reply_to(id.as_str());
}
if !draft.references.is_empty() {
let ids = draft.references.iter().map(String::as_str);
builder = builder.references(MessageId::new_list(ids));
}
let bytes = builder
.write_to_vec()
.map_err(|err| Error::Mime(err.to_string()))?;
Ok(Built { message_id, bytes })
}
/// Mixed of the alternative bodies and the attachments.
fn body(draft: &Draft) -> Result<MimePart<'_>> {
let text = MimePart::new("text/plain", draft.text.as_str());
let body = match &draft.html {
Some(html) => {
let html = MimePart::new("text/html", html.as_str());
MimePart::new("multipart/alternative", vec![text, html])
}
None => text,
};
if draft.attachments.is_empty() {
return Ok(body);
}
let mut parts = vec![body];
for part in &draft.attachments {
parts.push(attachment(part)?);
}
Ok(MimePart::new("multipart/mixed", parts))
}
fn attachment(part: &Part) -> Result<MimePart<'_>> {
let name = part
.name
.as_deref()
.ok_or_else(|| Error::Invalid("an attachment needs a file name".into()))?;
line("a file name", name)?;
line("a content type", &part.content_type)?;
let mut kind = ContentType::new(part.content_type.as_str());
for (key, value) in &part.params {
line("a content type parameter", key)?;
line("a content type parameter", value)?;
kind = kind.attribute(key.as_str(), value.as_str());
}
let composite = ["multipart/", "message/"]
.iter()
.any(|prefix| part.content_type.starts_with(prefix));
let mut attachment = if composite {
// RFC 2045 §6.4: composites stay unencoded.
let body = composite_body(part)?;
let encoding = if body.is_ascii() { "7bit" } else { "8bit" };
MimePart::new(kind, body).transfer_encoding(encoding)
} else {
MimePart::new(kind, part.bytes.as_slice())
}
.attachment(name);
if let Some(id) = &part.content_id {
line("a content ID", id)?;
attachment = attachment.cid(id.as_str());
}
Ok(attachment)
}
/// A multipart as read carries its headers.
fn composite_body(part: &Part) -> Result<&[u8]> {
if !part.content_type.starts_with("multipart/") {
return Ok(&part.bytes);
}
if !part.params.iter().any(|(key, _)| key == "boundary") {
return Err(Error::Invalid(
"a multipart attachment has no boundary".into(),
));
}
if let Some(body) = part.bytes.strip_prefix(b"\r\n") {
return Ok(body);
}
part.bytes
.windows(4)
.position(|end| end == b"\r\n\r\n")
.map(|at| &part.bytes[at + 4..])
.ok_or_else(|| Error::Invalid("a multipart attachment has no header end".into()))
}
/// A random left side at the sender's domain.
fn message_id(from: &str) -> Result<String> {
let (_, domain) = from
.rsplit_once('@')
.ok_or_else(|| Error::Invalid(format!("not a mail address: {from:?}")))?;
let mut random = [0u8; 16];
getrandom::fill(&mut random).map_err(|err| Error::Mime(err.to_string()))?;
let left: String = random.iter().map(|byte| format!("{byte:02x}")).collect();
Ok(format!("{left}@{domain}"))
}
/// A line break would start a new header.
fn line(what: &str, text: &str) -> Result<()> {
if text.contains(['\r', '\n']) {
return Err(Error::Invalid(format!("{what} has a line break")));
}
Ok(())
}
fn address(mailbox: &Mailbox) -> Out<'_> {
Out::new_address(mailbox.name.as_deref(), mailbox.email.as_str())
}
fn mailboxes<'a>(lists: impl Iterator<Item = &'a Address<'a>>) -> Vec<Mailbox> {
lists
.flat_map(Address::iter)
.filter_map(|addr| {
let Some(email) = addr.address.as_deref() else {
log::warn!("mail: an address without an email was skipped");
return None;
};
Some(Mailbox {
name: addr.name.as_deref().map(str::to_owned),
email: email.to_owned(),
})
})
.collect()
}
fn ids(value: &HeaderValue) -> Vec<String> {
match value {
HeaderValue::Text(id) => vec![id.to_string()],
HeaderValue::TextList(ids) => ids.iter().map(ToString::to_string).collect(),
_ => Vec::new(),
}
}
fn joined(parts: Vec<String>) -> Option<String> {
(!parts.is_empty()).then(|| parts.join("\n"))
}
-267
View File
@@ -1,267 +0,0 @@
use mail_parser::{
Encoding, Message, MessagePart, MimeHeaders, PartType,
decoders::{base64::base64_decode, quoted_printable::quoted_printable_decode},
};
use super::Part;
use crate::{Error, Result};
/// Deeper trees are refused before the walk.
const DEPTH: usize = 64;
/// What a MIME tree shows: bodies, `cid:` targets, attachments.
#[derive(Default)]
pub(super) struct Shape {
pub text: Vec<String>,
pub html: Vec<String>,
pub inline: Vec<Part>,
pub attachments: Vec<Part>,
}
pub(super) fn shape(message: &Message) -> Shape {
let mut shape = Shape::default();
Walk { message }.body(0, &mut shape);
shape
}
/// Iterative, so no tree can exhaust the stack.
pub(super) fn depth(message: &Message) -> Result<()> {
let mut stack = vec![(message, 0, 1)];
while let Some((message, id, level)) = stack.pop() {
if level > DEPTH {
return Err(Error::Mime(format!("MIME parts nest deeper than {DEPTH}")));
}
match &message.parts[id as usize].body {
PartType::Multipart(children) => {
stack.extend(children.iter().map(|&child| (message, child, level + 1)));
}
PartType::Message(inner) => stack.push((inner, 0, level + 1)),
_ => {}
}
}
Ok(())
}
/// Drops a deep tree without recursion.
pub(super) fn dispose(message: Message) {
let mut stack = vec![message];
while let Some(mut message) = stack.pop() {
for part in &mut message.parts {
if let PartType::Message(inner) = std::mem::take(&mut part.body) {
stack.push(inner);
}
}
}
}
/// RFC 6531 §3.4: 8-bit header bytes at any depth.
pub(super) fn utf8_headers(message: &Message) -> bool {
let mut stack = vec![message];
while let Some(message) = stack.pop() {
// Part offsets index the whole buffer.
let raw = message.raw_message.as_ref();
for part in &message.parts {
let header = &raw[part.offset_header as usize..part.offset_body as usize];
if !header.is_ascii() {
return true;
}
// Encoded parts carry no 8-bit bytes.
match &part.body {
PartType::Message(inner) if part.encoding == Encoding::None => stack.push(inner),
_ => {}
}
}
}
false
}
struct Walk<'a, 'x> {
message: &'a Message<'x>,
}
impl Walk<'_, '_> {
fn part(&self, id: u32) -> &MessagePart<'_> {
&self.message.parts[id as usize]
}
fn body(&self, id: u32, out: &mut Shape) {
let part = self.part(id);
// RFC 2183 §2.8, §2.9: the whole entity.
if !inline(part) {
out.attachments.push(self.leaf(part));
return;
}
self.content(id, out, true);
}
/// A related root is no `cid:` target.
fn content(&self, id: u32, out: &mut Shape, target: bool) {
let part = self.part(id);
match &part.body {
PartType::Multipart(children) => {
self.target(part, target, out);
match multipart(part).as_str() {
"alternative" => self.alternative(children, out),
"related" => self.related(part, children, out),
_ => children.iter().for_each(|&child| self.body(child, out)),
}
}
PartType::Text(text) if plain(part) => {
out.text.push(text.to_string());
self.target(part, target, out);
}
PartType::Html(html) => {
out.html.push(html.to_string());
self.target(part, target, out);
}
_ => {
let leaf = self.leaf(part);
let message = matches!(part.body, PartType::Message(_));
match leaf.content_id {
Some(_) if !message => out.inline.push(leaf),
_ => out.attachments.push(leaf),
}
}
}
}
/// RFC 2392: any part with a Content-ID.
fn target(&self, part: &MessagePart, target: bool, out: &mut Shape) {
if target && part.content_id().is_some() {
out.inline.push(self.leaf(part));
}
}
/// RFC 2046 §5.1.4: the last supported version wins.
fn alternative(&self, children: &[u32], out: &mut Shape) {
let versions: Vec<Shape> = children
.iter()
.map(|&child| {
let mut version = Shape::default();
self.body(child, &mut version);
version
})
.collect();
let text = versions.iter().rposition(|v| !v.text.is_empty());
let html = versions.iter().rposition(|v| !v.html.is_empty());
for (at, version) in versions.into_iter().enumerate() {
let bodiless = version.text.is_empty() && version.html.is_empty();
if Some(at) != text && Some(at) != html && !bodiless {
continue;
}
if Some(at) == text {
out.text.extend(version.text);
}
if Some(at) == html {
out.html.extend(version.html);
}
out.inline.extend(version.inline);
out.attachments.extend(version.attachments);
}
}
/// RFC 2387: `start` names the root; §4 voids dispositions.
fn related(&self, part: &MessagePart, children: &[u32], out: &mut Shape) {
let start = part
.content_type()
.and_then(|kind| kind.attribute("start"))
.map(|start| start.trim().trim_start_matches('<').trim_end_matches('>'));
let root = match start {
None => 0,
Some(start) => children
.iter()
.position(|&child| self.part(child).content_id() == Some(start))
.unwrap_or_else(|| {
log::warn!("mail: related start names no part; first part is the root");
0
}),
};
for (at, &child) in children.iter().enumerate() {
if at == root {
self.content(child, out, false);
continue;
}
// RFC 2387 §6.1: a resource is one entity.
let leaf = self.leaf(self.part(child));
match leaf.content_id {
Some(_) => out.inline.push(leaf),
None => out.attachments.push(leaf),
}
}
}
fn leaf(&self, part: &MessagePart) -> Part {
let kind = part.content_type();
let content_type = kind.map_or_else(
// RFC 2046 §5.1.5: digest children are messages.
|| match part.body {
PartType::Message(_) => "message/rfc822".to_owned(),
_ => "text/plain".to_owned(),
},
|kind| match &kind.c_subtype {
Some(subtype) => format!("{}/{subtype}", kind.c_type),
None => kind.c_type.to_string(),
},
);
let params = kind
.and_then(|kind| kind.attributes())
.into_iter()
.flatten()
.filter(|attr| !attr.name.eq_ignore_ascii_case("name"))
.map(|attr| (attr.name.to_ascii_lowercase(), attr.value.to_string()))
.collect();
Part {
name: part.attachment_name().map(str::to_owned),
content_type: content_type.to_ascii_lowercase(),
params,
content_id: part.content_id().map(str::to_owned),
bytes: self.octets(part),
}
}
/// Transfer-decoded bytes; text keeps its own charset.
fn octets(&self, part: &MessagePart) -> Vec<u8> {
let raw = self.message.raw_message();
let body = &raw[part.offset_body as usize..part.offset_end as usize];
match &part.body {
PartType::Binary(bytes) | PartType::InlineBinary(bytes) => bytes.to_vec(),
PartType::Message(message) => message.raw_message().to_vec(),
PartType::Multipart(_) => {
raw[part.offset_header as usize..part.offset_end as usize].to_vec()
}
PartType::Text(_) | PartType::Html(_) => {
let decoded = match part.encoding {
Encoding::None => Some(body.to_vec()),
Encoding::QuotedPrintable => quoted_printable_decode(body),
Encoding::Base64 => base64_decode(body),
};
decoded.unwrap_or_else(|| {
log::warn!("mail: a text attachment kept its encoded bytes");
body.to_vec()
})
}
}
}
}
/// RFC 2183 §2.8: unknown types are attachments.
fn inline(part: &MessagePart) -> bool {
part.content_disposition()
.is_none_or(|disposition| disposition.c_type.eq_ignore_ascii_case("inline"))
}
fn multipart(part: &MessagePart) -> String {
part.content_type()
.and_then(|kind| kind.c_subtype.as_deref())
.unwrap_or_default()
.to_ascii_lowercase()
}
/// RFC 2045 §5.2: no Content-Type is text/plain.
fn plain(part: &MessagePart) -> bool {
part.content_type().is_none_or(|kind| {
kind.c_subtype
.as_deref()
.is_none_or(|subtype| subtype.eq_ignore_ascii_case("plain"))
})
}
-85
View File
@@ -1,85 +0,0 @@
use std::{future::Future, sync::Arc, time::Duration};
use rustls::{ClientConfig, crypto::ring, pki_types::ServerName};
use rustls_platform_verifier::BuilderVerifierExt;
use serde::{Deserialize, Serialize};
use tokio::net::TcpStream;
use tokio_rustls::{TlsConnector, client::TlsStream};
use crate::{Error, Result};
pub(crate) type Stream = TlsStream<TcpStream>;
const WAIT: Duration = Duration::from_secs(60);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Server {
pub host: String,
pub port: u16,
pub security: Security,
}
/// No plaintext: every mode ends in TLS.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Security {
Tls,
StartTls,
}
#[derive(Clone)]
pub struct Login {
pub user: String,
pub secret: Secret,
}
#[derive(Clone)]
pub enum Secret {
Password(String),
/// An OAuth access token, sent as XOAUTH2.
Token(String),
}
impl Login {
/// The SASL XOAUTH2 initial response, before base64.
pub(crate) fn xoauth2(&self, token: &str) -> String {
format!("user={}\x01auth=Bearer {token}\x01\x01", self.user)
}
}
/// Verifies servers with the system's trust store.
pub fn system_tls() -> Result<TlsConnector> {
let config = ClientConfig::builder_with_provider(Arc::new(ring::default_provider()))
.with_safe_default_protocol_versions()
.and_then(|builder| builder.with_platform_verifier())
.map_err(|err| Error::Tls(err.to_string()))?
.with_no_client_auth();
Ok(TlsConnector::from(Arc::new(config)))
}
pub(crate) async fn within<T>(step: &'static str, work: impl Future<Output = T>) -> Result<T> {
tokio::time::timeout(WAIT, work)
.await
.map_err(|_| Error::Timeout(step))
}
pub(crate) async fn tcp(server: &Server) -> Result<TcpStream> {
let stream = within(
"connecting",
TcpStream::connect((server.host.as_str(), server.port)),
)
.await?
.map_err(Error::Connect)?;
log::info!("mail: connected to {}:{}", server.host, server.port);
Ok(stream)
}
pub(crate) async fn tls(tcp: TcpStream, server: &Server, tls: &TlsConnector) -> Result<Stream> {
let name = ServerName::try_from(server.host.clone())
.map_err(|err| Error::Tls(format!("{}: {err}", server.host)))?;
let stream = within("starting TLS", tls.connect(name, tcp))
.await?
.map_err(|err| Error::Tls(err.to_string()))?;
log::info!("mail: TLS with {}:{}", server.host, server.port);
Ok(stream)
}
-277
View File
@@ -1,277 +0,0 @@
use std::time::SystemTime;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use sha2::{Digest, Sha256};
use tokio::net::TcpListener;
use crate::{Error, Result};
mod loopback;
mod token;
/// One OAuth authorization server and this app's client.
pub struct Provider {
pub name: &'static str,
pub authorize: String,
pub token: String,
pub client_id: String,
/// Google's desktop clients carry one; Microsoft's do not.
pub client_secret: Option<String>,
pub scope: String,
/// Authorize parameters this server adds.
pub extra: &'static [(&'static str, &'static str)],
/// The registered loopback host.
pub redirect_host: &'static str,
}
/// Tokens from one exchange or refresh.
pub struct Tokens {
pub access: String,
pub expires_at: SystemTime,
/// A new refresh token; store it, drop the old one.
pub refresh: Option<String>,
/// The signed-in address, from the ID token.
pub email: Option<String>,
}
/// Gmail: IMAP, POP3 and SMTP under one scope.
pub fn google() -> Result<Provider> {
Ok(gmail(
built("google", option_env!("ELY_GOOGLE_CLIENT_ID"))?,
built("google", option_env!("ELY_GOOGLE_CLIENT_SECRET"))?,
))
}
fn gmail(client_id: String, secret: String) -> Provider {
Provider {
name: "google",
authorize: "https://accounts.google.com/o/oauth2/v2/auth".into(),
token: "https://oauth2.googleapis.com/token".into(),
client_id,
client_secret: Some(secret),
scope: "https://mail.google.com/ openid email".into(),
// Refresh token on every consent.
extra: &[("access_type", "offline"), ("prompt", "consent")],
redirect_host: "127.0.0.1",
}
}
/// Outlook.com and Microsoft 365.
pub fn microsoft() -> Result<Provider> {
Ok(outlook(built(
"microsoft",
option_env!("ELY_MICROSOFT_CLIENT_ID"),
)?))
}
fn outlook(client_id: String) -> Provider {
Provider {
name: "microsoft",
authorize: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize".into(),
token: "https://login.microsoftonline.com/common/oauth2/v2.0/token".into(),
client_id,
client_secret: None,
scope: [
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/POP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
"offline_access openid email profile",
]
.join(" "),
extra: &[],
// Port ignored only for localhost.
redirect_host: "localhost",
}
}
fn built(name: &'static str, value: Option<&str>) -> Result<String> {
match value {
Some(value) if !value.is_empty() => Ok(value.to_owned()),
_ => Err(Error::Unavailable(name)),
}
}
/// A sign-in waiting for the browser to come back.
pub struct SignIn {
provider: Provider,
listener: TcpListener,
url: String,
redirect: String,
state: String,
verifier: String,
}
impl SignIn {
/// Opens the loopback port and builds the browser URL.
pub async fn start(provider: Provider, hint: &str) -> Result<Self> {
let listener = TcpListener::bind(("127.0.0.1", 0))
.await
.map_err(|err| Error::SignIn(format!("no loopback port: {err}")))?;
let port = listener
.local_addr()
.map_err(|err| Error::SignIn(format!("no loopback port: {err}")))?
.port();
let redirect = format!("http://{}:{port}", provider.redirect_host);
let state = random();
let verifier = random();
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let mut query = vec![
("client_id", provider.client_id.as_str()),
("response_type", "code"),
("redirect_uri", redirect.as_str()),
("scope", provider.scope.as_str()),
("state", state.as_str()),
("code_challenge", challenge.as_str()),
("code_challenge_method", "S256"),
];
query.extend_from_slice(provider.extra);
if !hint.is_empty() {
query.push(("login_hint", hint));
}
let url = format!("{}?{}", provider.authorize, encode(&query));
log::info!("oauth: {} sign-in waits on 127.0.0.1:{port}", provider.name);
Ok(Self {
provider,
listener,
url,
redirect,
state,
verifier,
})
}
/// The page to open in the system browser.
pub fn url(&self) -> &str {
&self.url
}
/// Waits for the redirect, then trades the code for tokens.
pub async fn finish(self) -> Result<Tokens> {
let code = loopback::code(&self.listener, &self.state).await?;
log::info!("oauth: {} returned a code", self.provider.name);
drop(self.listener);
let mut form = vec![
("grant_type", "authorization_code".to_owned()),
("client_id", self.provider.client_id.clone()),
("code", code),
("code_verifier", self.verifier),
("redirect_uri", self.redirect),
];
if let Some(secret) = &self.provider.client_secret {
form.push(("client_secret", secret.clone()));
}
let tokens = token::post(&self.provider, form, false).await?;
if tokens.refresh.is_none() {
return Err(Error::SignIn("no refresh token granted".into()));
}
Ok(tokens)
}
}
/// A fresh access token; a refused refresh needs sign-in again.
pub async fn refresh(provider: &Provider, refresh: &str) -> Result<Tokens> {
let mut form = vec![
("grant_type", "refresh_token".to_owned()),
("client_id", provider.client_id.clone()),
("refresh_token", refresh.to_owned()),
];
if let Some(secret) = &provider.client_secret {
form.push(("client_secret", secret.clone()));
}
token::post(provider, form, true).await
}
/// 32 random bytes, base64url: a PKCE verifier or a state.
fn random() -> String {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("system randomness");
URL_SAFE_NO_PAD.encode(bytes)
}
fn encode(pairs: &[(&str, &str)]) -> String {
pairs
.iter()
.map(|(key, value)| format!("{}={}", escape(key), escape(value)))
.collect::<Vec<_>>()
.join("&")
}
fn escape(text: &str) -> String {
let mut out = String::new();
for byte in text.bytes() {
if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_client_id_makes_the_provider_unavailable() {
assert!(matches!(
built("google", None),
Err(Error::Unavailable("google"))
));
assert!(matches!(
built("google", Some("")),
Err(Error::Unavailable("google"))
));
assert_eq!(built("google", Some("id-1")).unwrap(), "id-1");
}
async fn asked(provider: Provider) -> Vec<(String, String)> {
let sign_in = SignIn::start(provider, "").await.unwrap();
let (_, query) = sign_in.url().split_once('?').unwrap();
form_urlencoded::parse(query.as_bytes())
.into_owned()
.collect()
}
fn value<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
pairs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
#[tokio::test]
async fn gmail_asks_for_a_refresh_token_every_time() {
let pairs = asked(gmail("id".into(), "s".into())).await;
assert_eq!(value(&pairs, "access_type"), Some("offline"));
assert_eq!(value(&pairs, "prompt"), Some("consent"));
assert!(
value(&pairs, "scope")
.unwrap()
.contains("https://mail.google.com/")
);
}
#[tokio::test]
async fn outlook_asks_for_offline_access_and_the_address_claims() {
let pairs = asked(outlook("id".into())).await;
let scope: Vec<&str> = value(&pairs, "scope").unwrap().split(' ').collect();
for needed in ["offline_access", "openid", "email", "profile"] {
assert!(scope.contains(&needed), "{needed}");
}
assert_eq!(value(&pairs, "access_type"), None);
assert!(
value(&pairs, "redirect_uri")
.unwrap()
.starts_with("http://localhost:")
);
}
#[test]
fn query_values_are_escaped() {
assert_eq!(
encode(&[("scope", "a b/c"), ("x", "é~")]),
"scope=a%20b%2Fc&x=%C3%A9~"
);
}
}
@@ -1,85 +0,0 @@
use std::time::Duration;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
};
use crate::{Error, Result};
const LIMIT: usize = 8 * 1024;
const READ: Duration = Duration::from_secs(10);
const PAGE: &str = "ElyMail: sign-in finished. You can close this tab.";
/// Serves redirects until one carries a code or error.
pub(super) async fn code(listener: &TcpListener, state: &str) -> Result<String> {
loop {
let (mut stream, _) = listener
.accept()
.await
.map_err(|err| Error::SignIn(format!("loopback accept failed: {err}")))?;
let Some(target) = request(&mut stream).await else {
continue;
};
let query = target.split_once('?').map_or("", |(_, query)| query);
let pairs: Vec<(String, String)> = form_urlencoded::parse(query.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect();
let field = |name: &str| {
pairs
.iter()
.find(|(key, _)| key == name)
.map(|(_, v)| v.clone())
};
if field("code").is_none() && field("error").is_none() {
reply(&mut stream, "404 Not Found", "").await;
continue;
}
reply(&mut stream, "200 OK", PAGE).await;
if field("state").as_deref() != Some(state) {
return Err(Error::SignIn("redirect state does not match".into()));
}
if let Some(error) = field("error") {
return Err(Error::SignIn(match field("error_description") {
Some(text) => format!("{error}: {text}"),
None => error,
}));
}
return field("code").ok_or_else(|| Error::SignIn("redirect without a code".into()));
}
}
/// The request target of one GET, None for anything else.
async fn request(stream: &mut TcpStream) -> Option<String> {
let mut head = Vec::new();
let mut chunk = [0u8; 1024];
while !head.windows(4).any(|window| window == b"\r\n\r\n") {
let read = tokio::time::timeout(READ, stream.read(&mut chunk)).await;
match read {
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return None,
Ok(Ok(count)) => head.extend_from_slice(&chunk[..count]),
}
if head.len() > LIMIT {
log::warn!("oauth: loopback request over {LIMIT} bytes dropped");
return None;
}
}
let line = head.split(|byte| *byte == b'\n').next()?;
let line = std::str::from_utf8(line).ok()?.trim_end();
let mut parts = line.split(' ');
match (parts.next(), parts.next()) {
(Some("GET"), Some(target)) => Some(target.to_owned()),
_ => None,
}
}
async fn reply(stream: &mut TcpStream, status: &str, body: &str) {
let text = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
if let Err(err) = stream.write_all(text.as_bytes()).await {
log::warn!("oauth: loopback reply failed: {err}");
}
}
-110
View File
@@ -1,110 +0,0 @@
use std::time::{Duration, SystemTime};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::Deserialize;
use ureq::Agent;
use super::{Provider, Tokens};
use crate::{Error, Result};
const TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Deserialize)]
struct Granted {
access_token: String,
expires_in: u64,
refresh_token: Option<String>,
id_token: Option<String>,
}
#[derive(Deserialize)]
struct Refused {
error: String,
error_description: Option<String>,
}
#[derive(Deserialize)]
struct Claims {
email: Option<String>,
preferred_username: Option<String>,
}
/// Posts to the token endpoint off the async thread.
pub(super) async fn post(
provider: &Provider,
form: Vec<(&'static str, String)>,
refreshing: bool,
) -> Result<Tokens> {
let url = provider.token.clone();
let name = provider.name;
let started = SystemTime::now();
let (status, body) = tokio::task::spawn_blocking(move || send(&url, &form))
.await
.map_err(|err| Error::SignIn(format!("token request stopped: {err}")))??;
if status == 200 {
let tokens = granted(&body, started)?;
log::info!(
"oauth: {name} token granted, refresh {}",
tokens.refresh.is_some()
);
return Ok(tokens);
}
let refused: Refused = serde_json::from_str(&body)
.map_err(|_| Error::SignIn(format!("token endpoint answered {status}")))?;
let reason = match refused.error_description {
Some(text) => format!("{}: {text}", refused.error),
None => refused.error.clone(),
};
log::warn!("oauth: {name} token refused ({status}, {})", refused.error);
if refreshing
&& matches!(
refused.error.as_str(),
"invalid_grant" | "interaction_required"
)
{
return Err(Error::SignInAgain(reason));
}
Err(Error::SignIn(reason))
}
fn send(url: &str, form: &[(&'static str, String)]) -> Result<(u16, String)> {
let agent: Agent = Agent::config_builder()
.timeout_global(Some(TIMEOUT))
.http_status_as_error(false)
.build()
.into();
let mut response = agent
.post(url)
.send_form(form.iter().map(|(key, value)| (*key, value.as_str())))
.map_err(|err| Error::SignIn(format!("token endpoint unreachable: {err}")))?;
let status = response.status().as_u16();
let body = response
.body_mut()
.read_to_string()
.map_err(|err| Error::SignIn(format!("token reply unreadable: {err}")))?;
Ok((status, body))
}
fn granted(body: &str, started: SystemTime) -> Result<Tokens> {
let granted: Granted = serde_json::from_str(body)
.map_err(|err| Error::SignIn(format!("token reply unreadable: {err}")))?;
let email = match &granted.id_token {
Some(token) => email(token)?,
None => None,
};
Ok(Tokens {
access: granted.access_token,
expires_at: started + Duration::from_secs(granted.expires_in),
refresh: granted.refresh_token,
email,
})
}
/// The address claim; the token came straight from the endpoint.
fn email(token: &str) -> Result<Option<String>> {
let unreadable = || Error::SignIn("ID token unreadable".into());
let payload = token.split('.').nth(1).ok_or_else(unreadable)?;
let bytes = URL_SAFE_NO_PAD.decode(payload).map_err(|_| unreadable())?;
let claims: Claims = serde_json::from_slice(&bytes).map_err(|_| unreadable())?;
Ok(claims.email.or(claims.preferred_username))
}
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff. Show more