Remove CEF: the web content crate, ElyMark's page, their packaging, licences and tests (C14.5)
PRD and agents.md name the native route; ElyMail's body will not use CEF or Electron. ElyMark's package carries the KaTeX fonts' OFL. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2e8379b0b1
commit
8a942e6d80
77 files changed
+105
-10718
No files matched your search
@@ -1,6 +1,2 @@
|
||||
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[env]
|
||||
# cef-dll-sys builds against the CEF that scripts/fetch-cef.sh fetched.
|
||||
CEF_PATH = { value = "engines/cef", relative = true }
|
||||
Generated
+5
-243
@@ -374,7 +374,7 @@ version = "0.38.0+1.3.281"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
|
||||
dependencies = [
|
||||
"libloading 0.8.9",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -966,29 +966,6 @@ dependencies = [
|
||||
"shlex 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cef"
|
||||
version = "154.3.0+154.0.32"
|
||||
source = "git+https://github.com/tauri-apps/cef-rs?rev=a712ad9bacd234c050631a95b9745b27d346b7cc#a712ad9bacd234c050631a95b9745b27d346b7cc"
|
||||
dependencies = [
|
||||
"cef-dll-sys",
|
||||
"libloading 0.9.0",
|
||||
"objc2 0.6.4",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cef-dll-sys"
|
||||
version = "154.3.0+154.0.32"
|
||||
source = "git+https://github.com/tauri-apps/cef-rs?rev=a712ad9bacd234c050631a95b9745b27d346b7cc#a712ad9bacd234c050631a95b9745b27d346b7cc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cc",
|
||||
"cmake",
|
||||
"download-cef",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
@@ -1069,49 +1046,9 @@ checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading 0.8.9",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -1121,15 +1058,6 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
@@ -1256,18 +1184,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"unicode-width",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
@@ -1328,29 +1244,10 @@ version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -1892,7 +1789,7 @@ version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
|
||||
dependencies = [
|
||||
"libloading 0.8.9",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1925,25 +1822,6 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "download-cef"
|
||||
version = "3.0.0"
|
||||
source = "git+https://github.com/tauri-apps/cef-rs?rev=a712ad9bacd234c050631a95b9745b27d346b7cc#a712ad9bacd234c050631a95b9745b27d346b7cc"
|
||||
dependencies = [
|
||||
"bzip2",
|
||||
"clap",
|
||||
"fs-err",
|
||||
"indicatif",
|
||||
"regex",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1_smol",
|
||||
"tar",
|
||||
"thiserror 2.0.21",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -2306,26 +2184,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ely-web-content"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cef",
|
||||
"ely-gpui-component",
|
||||
"ely-platform",
|
||||
"ely-ui",
|
||||
"futures",
|
||||
"gpui-pre",
|
||||
"image",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.11"
|
||||
@@ -2349,12 +2207,6 @@ dependencies = [
|
||||
"phf 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
@@ -2585,16 +2437,6 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.13"
|
||||
@@ -2754,15 +2596,6 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
@@ -3898,19 +3731,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indicatif"
|
||||
version = "0.18.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
|
||||
dependencies = [
|
||||
"console",
|
||||
"portable-atomic",
|
||||
"unicode-width",
|
||||
"unit-prefix",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.11.5"
|
||||
@@ -4225,7 +4045,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libloading 0.8.9",
|
||||
"libloading",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
@@ -4335,16 +4155,6 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
@@ -7145,17 +6955,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socks"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
@@ -7262,12 +7061,6 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
@@ -7524,17 +7317,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.8.1"
|
||||
@@ -8002,12 +7784,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unit-prefix"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.6.1"
|
||||
@@ -8031,15 +7807,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"cookie_store",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"socks",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
@@ -8629,7 +8401,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading 0.8.9",
|
||||
"libloading",
|
||||
"log",
|
||||
"naga",
|
||||
"ndk-sys",
|
||||
@@ -9422,16 +9194,6 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xcb"
|
||||
version = "1.7.1"
|
||||
|
||||
@@ -49,7 +49,6 @@ objc2-core-media = { version = "0.3", default-features = false, features = ["std
|
||||
objc2-core-video = { version = "0.3", default-features = false, features = ["std", "CVBuffer", "CVImageBuffer", "CVPixelBuffer"] }
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFString", "CFCGTypes"] }
|
||||
core-video = "0.5"
|
||||
cef = { git = "https://github.com/tauri-apps/cef-rs", rev = "a712ad9bacd234c050631a95b9745b27d346b7cc", default-features = false }
|
||||
core-foundation = "0.10"
|
||||
objc2 = "0.6"
|
||||
security-framework = "3.7"
|
||||
@@ -61,7 +60,6 @@ ely-lok = { path = "crates/ely-lok" }
|
||||
ely-office = { path = "crates/ely-office" }
|
||||
ely-pdf-engine = { path = "crates/ely-pdf-engine" }
|
||||
ely-platform = { path = "crates/ely-platform" }
|
||||
ely-web-content = { path = "crates/ely-web-content" }
|
||||
ely-ui = { path = "crates/ely-ui" }
|
||||
|
||||
[workspace.lints.clippy]
|
||||
|
||||
@@ -24,10 +24,10 @@ Ely Office 提供八个桌面应用。用户可以写文档、计算表格、制
|
||||
| ElyPDF | PDF 阅读、编辑、批注、表单和页面处理 | MuPDF;OCR 复用 Tesseract | PDF |
|
||||
| ElyMail | 邮件收发、本地存储和搜索 | MailCore 2,加本地同步与存储 | 邮件、附件、草稿 |
|
||||
| ElyTree | Git 图形化版本管理 | Git CLI | 本地 Git 仓库 |
|
||||
| ElyMark | 本地 Markdown 编辑 | Milkdown 与 CodeMirror 6;参考 MarkText、ColaMD 的编辑与文件处理方式 | Markdown 与本地附件 |
|
||||
| ElyMark | 本地 Markdown 编辑 | Ely-GPUI-Components 的 Markdown 文档组件,markdown-rs 解析;参考 MarkText、ColaMD、Markion | Markdown 与本地附件 |
|
||||
| ElyAgent | 独立的 Agent 配置与会话管理 | 嵌入的 Kigi CLI Rust 代码 | 配置、会话记录、运行状态 |
|
||||
|
||||
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 编辑框架、本地 HTML 宿主和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
|
||||
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
|
||||
|
||||
ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能。它不是其他七个应用的聊天侧栏,也不是整个套件的控制中枢。[S19]
|
||||
|
||||
@@ -35,7 +35,7 @@ ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能
|
||||
|
||||
1. 首个正式版交付全部八个应用及本文定义的功能,不发布只有入口和占位界面的套件。
|
||||
2. Doc、Table、Show 共用 LOK 适配实现。PDF 使用 MuPDF。不开设替代内核路线。
|
||||
3. GPUI 负责应用界面。专业内容区域可以使用内核渲染结果或本地 Web 内容区域,但外观和行为必须遵守 Ely 规范。
|
||||
3. GPUI 负责应用界面。专业内容区域可以使用内核渲染结果,但外观和行为必须遵守 Ely 规范;不用 CEF 与 Electron。
|
||||
4. 应用可以独立启动、更新和卸载。安装 ElyMark、ElyTree 或 ElyAgent 不得强制安装 LOK。
|
||||
5. 用户文件采用通用格式。应用数据库只保存索引、配置和运行记录,不取代用户文件。
|
||||
6. 不建设云端文档平台、微服务系统、统一业务数据库或跨应用 Agent 编排系统。
|
||||
@@ -377,7 +377,7 @@ XFA 动态表单、任意嵌入脚本执行、专业印前分色及 PDF 到 Offi
|
||||
|
||||
提供多账号邮件客户端,不建设邮件托管服务。MailCore 2 负责 IMAP、POP、SMTP 和邮件解析等能力。Ely 负责账号流程、本地存储、搜索、同步队列和界面。MailCore 2 官方列出 C++ API 与三端支持,但发行前仍须验证实际构建和依赖。[S10]
|
||||
|
||||
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文和富文本撰写区使用本地内容宿主,不能把正文 HTML 解析当成完整页面渲染。
|
||||
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文和富文本撰写区不用 CEF 与 Electron,渲染方式随 ElyMail 任务另定;不能把正文 HTML 解析当成完整页面渲染。
|
||||
|
||||
### 12.2 功能要求
|
||||
|
||||
@@ -474,9 +474,9 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
|
||||
|
||||
提供以本地 Markdown 文件为中心的编辑器。支持可视化编辑、源码编辑和只读预览。文件可由其他编辑器、Git 或 Agent 修改,不要求导入专有知识库。
|
||||
|
||||
默认使用 Milkdown 承担可视化编辑,CodeMirror 6 承担源码编辑。两者接入同一个文件会话,只允许当前模式提交修改,不同时维护两份独立可写文档。Milkdown 的 Markdown 与编辑模型转换需要单独处理源码保留,不能把序列化输出默认视为无损原文。[S14][S15][S16]
|
||||
界面用 Ely-GPUI-Components 的 Markdown 文档组件,全部原生 GPUI:全文只在一个源码编辑器里,可视模式按块渲染,按下的块以原文编辑;源码与预览共用同一份文本。解析用 markdown-rs,公式用 RaTeX,Mermaid 用 mermaid-rs-renderer。文本即文件原文,不经编辑模型序列化。[S14][S15][S16]
|
||||
|
||||
参考 MarkText 的 Markdown 编辑行为、ColaMD 的本地文件同步和内容保留规则,不复制其整个应用窗口,不把 Electron 作为 Ely 的桌面外壳。当前 ColaMD 仍包含 Milkdown 与 CodeMirror 相关依赖,但具体实现必须按选定提交核对,不凭包名推断编辑行为。[S17][S18]
|
||||
参考 MarkText 的 Markdown 编辑行为、ColaMD 的本地文件同步和内容保留规则、Markion 的 GPUI 原生 Markdown 编辑,不复制其整个应用窗口。不用 Electron,不用 CEF 或其他网页内容宿主。[S17][S18]
|
||||
|
||||
### 14.2 功能要求
|
||||
|
||||
@@ -605,7 +605,6 @@ ely-office/
|
||||
ely-lok/ # LOK 接入;仅 Doc/Table/Show 使用
|
||||
ely-pdf-engine/ # MuPDF 与 OCR 接入
|
||||
ely-mail-engine/ # 邮件协议、同步和本地存储
|
||||
ely-web-content/ # 本地 HTML 与 Markdown 内容宿主
|
||||
assets/
|
||||
tests/
|
||||
packaging/
|
||||
@@ -625,23 +624,19 @@ C/C++ 异常、原生错误与资源释放在 FFI 边界处理,不能跨 Rust
|
||||
|
||||
邮件同步、Git 命令、OCR、缩略图与索引构建在后台任务中执行,不阻塞 UI 事件循环。后台指应用运行期间的本地任务,不表示默认安装常驻系统服务。
|
||||
|
||||
### 17.3 本地 Web 内容宿主
|
||||
### 17.3 内容渲染
|
||||
|
||||
Mail 的 HTML 正文和富文本撰写、Mark 的编辑与预览需要成熟 Web 内容渲染。本 PRD 的默认实现采用 CEF 离屏内容区域,只在这两个应用引入。CEF 提供离屏画面和输入事件接口,适合接入自绘界面,但 GPUI 的实际接入仍须三端验证。[S22]
|
||||
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文和富文本撰写的渲染方式随 ElyMail 任务另定,定前不写成已有。
|
||||
|
||||
CEF 只处理内容区域,不绘制应用标题栏、菜单、文件树和设置页面。不引入 Electron 应用外壳,不运行面向浏览器的本地文档服务器。CEF 必需的辅助进程随应用发行。
|
||||
Ely 主题 token 用于内容区。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示。
|
||||
|
||||
Ely 主题 token 同步到内容区样式。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示,不出现框架默认主题。
|
||||
|
||||
邮件阅读环境禁用脚本及文件桥接。Markdown 编辑环境只运行随包代码,通过受限桥接访问当前用户打开的文件与资源。外部文档内容不能调用任意本地命令。
|
||||
|
||||
不同时维护 CEF、系统 WebView 和全原生 Markdown 三种路线。若 CEF 原型不能满足三端要求,先修订该接入决策并更新 PRD,不能在应用内部临时混用多套未验收宿主。
|
||||
邮件阅读不运行正文脚本。Markdown 文档里的 HTML 不运行;数学与图只渲染保存的源码。外部文档内容不能调用任意本地命令。
|
||||
|
||||
### 17.4 不建设的技术设施
|
||||
|
||||
不引入 Kubernetes、Docker 运行依赖、远程数据库、消息中间件、事件溯源系统、通用插件市场、统一知识图谱、分布式搜索或独立账号中心。
|
||||
|
||||
应用间不做 RPC 服务发现。Agent 不以独立后台服务器为前提。Git 不同时接入多个 Git 内核。Markdown 不叠加第三个编辑内核。新抽象必须服务于已有的重复需求。
|
||||
应用间不做 RPC 服务发现。Agent 不以独立后台服务器为前提。Git 不同时接入多个 Git 内核。Markdown 只有一个解析内核(markdown-rs)。新抽象必须服务于已有的重复需求。
|
||||
|
||||
## 18. 数据、配置与隐私
|
||||
|
||||
@@ -735,7 +730,7 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
|
||||
| PERF-08 | 资源释放 | 连续打开关闭 20 次后,活动文档对象与句柄回到基线,内存无持续单向增长 |
|
||||
| PERF-09 | 多会话 | 4 个 Agent 会话同时流式输出时,其他会话选择和日志滚动仍满足交互目标 |
|
||||
|
||||
不提前宣传固定内存占用或比其他 Office 更快。发布报告列出单应用、内核子进程及内容宿主的总内存,不能只测 GPUI 主进程。
|
||||
不提前宣传固定内存占用或比其他 Office 更快。发布报告列出单应用与内核子进程的总内存,不能只测 GPUI 主进程。
|
||||
|
||||
### 20.2 稳定性
|
||||
|
||||
@@ -769,7 +764,7 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
|
||||
| LibreOffice/LOK | 保留 MPL 2.0 及附带依赖的相应声明;发布对受约束文件的修改与所需资料 [S5] |
|
||||
| MailCore 2 | 保留其许可、版权和免责条款,并检查其传递依赖 [S24] |
|
||||
| Git | 作为独立程序分发并遵守其 GPLv2 等实际适用条款;提供所发行版本的源码取得方式 [S23] |
|
||||
| Markdown 与内容宿主 | 保留 Milkdown、CodeMirror、CEF 及其依赖的通知;逐项检查实际发行包 [S14][S16][S22] |
|
||||
| Markdown | 保留 markdown-rs、RaTeX(含随带字体)、mermaid-rs-renderer 及其依赖的许可;逐项检查实际发行包 [S14][S15][S16] |
|
||||
| OCR、字体、词典和媒体组件 | 记录每个资源的来源、版本和再分发条件,不从系统目录随意拷贝受限资源 |
|
||||
|
||||
MuPDF 的 AGPL 路线允许符合条件的开源集成;“项目开源”本身不足以证明整个发行包已经符合条件。是否收费与源码义务分开处理。[S8][S9]
|
||||
@@ -843,13 +838,13 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
| LOK 高级功能 | 母版、放映、图表、修订、输入法、打印和无障碍的接口映射及用例 | 补齐桥接或维护必要上游补丁,不跳回上游原生 UI |
|
||||
| MuPDF 编辑 | 字体、对象修改、表单、删除及保存的样本结果 | 修复对象编辑与输出;不得改为视觉覆盖冒充编辑 |
|
||||
| MailCore 2 | 三端可重复构建、TLS、OAuth、同步和 MIME 测试 | 处理依赖及适配问题;替换后端必须显式修订选型 |
|
||||
| 本地 Web 内容 | CEF 与 GPUI 的输入、焦点、缩放、弹层和辅助技术测试 | 接入未通过不能以独立 Electron 窗口代替 |
|
||||
| Markdown 原生编辑 | 可视、源码、预览的输入、焦点、缩放、输入法和辅助技术测试 | 未通过不能以 CEF、WebView 或 Electron 代替 |
|
||||
| Markdown 原文保留 | 无编辑保存、模式切换、局部修改和外部冲突的字节级差异 | 修复映射与保存;未知语法回到原文模式,不删除 |
|
||||
| 邮箱服务注册 | 正式 OAuth 配置、适用审核状态与用户流程 | 不将未获许可的内置登录描述为可用 |
|
||||
| Agent 后端差异 | 每个预设后端的版本、启动、控制、日志和恢复能力表 | 按真实能力显示,禁止模拟未提供的接口 |
|
||||
| 打包与许可 | 三端依赖清单、源码对应关系、资源再分发检查 | 发行前解决,不以“整个项目开源”代替检查 |
|
||||
|
||||
选择版本时记录源码提交、编译选项和补丁。升级 LOK、MuPDF、GPUI、CEF 或编辑框架后运行相关回归测试。不要跟随默认分支自动升级正式构建。
|
||||
选择版本时记录源码提交、编译选项和补丁。升级 LOK、MuPDF、GPUI 或 Markdown 解析与渲染库后运行相关回归测试。不要跟随默认分支自动升级正式构建。
|
||||
|
||||
## 24. 交付物与完成定义
|
||||
|
||||
@@ -908,14 +903,14 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
[S13] Git 官方文档,git-status 与 porcelain 输出格式。
|
||||
`https://git-scm.com/docs/git-status`
|
||||
|
||||
[S14] Milkdown 官方仓库,框架结构与许可证。
|
||||
`https://github.com/Milkdown/milkdown`
|
||||
[S14] markdown-rs 官方仓库,CommonMark、GFM 与扩展语法解析,许可证。
|
||||
`https://github.com/wooorm/markdown-rs`
|
||||
|
||||
[S15] Milkdown 官方 Transformer 文档,编辑模型与 Markdown AST 转换。
|
||||
`https://milkdown.dev/docs/api/transformer`
|
||||
[S15] RaTeX 官方仓库,LaTeX 公式排版与许可证。
|
||||
`https://github.com/erweixin/RaTeX`
|
||||
|
||||
[S16] CodeMirror 官方开发入口与仓库迁移说明;应用依赖按实际发布包锁定。
|
||||
`https://github.com/codemirror/dev`
|
||||
[S16] mermaid-rs-renderer 官方仓库,Mermaid 图的 Rust 渲染与许可证。
|
||||
`https://github.com/1jehuang/mermaid-rs-renderer`
|
||||
|
||||
[S17] MarkText 官方仓库,编辑模式、格式能力与许可证。
|
||||
`https://github.com/marktext/marktext`
|
||||
@@ -933,10 +928,6 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
|
||||
[S21] Tesseract 官方 API 示例与本地 OCR 接入。
|
||||
`https://tesseract-ocr.github.io/tessdoc/APIExample.html`
|
||||
|
||||
[S22] Chromium Embedded Framework 官方说明与离屏渲染接入。
|
||||
`https://github.com/chromiumembedded/cef`
|
||||
`https://chromiumembedded.github.io/cef/general_usage.html`
|
||||
|
||||
[S23] Git 官方 COPYING 文件。
|
||||
`https://raw.githubusercontent.com/git/git/master/COPYING`
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Offline-first desktop apps in Rust and GPUI. Files stay in your folders; no acco
|
||||
| ElyShow | Presentations (PPTX, ODP) | LibreOfficeKit, system media |
|
||||
| ElyPDF | PDF reading and editing, OCR, signatures | MuPDF, Tesseract |
|
||||
| ElyTree | Git client | Git |
|
||||
| ElyMark | Markdown | Milkdown, CodeMirror in CEF |
|
||||
| ElyMark | Markdown | markdown-rs, RaTeX, mermaid-rs-renderer |
|
||||
| ElyMail, ElyAgent | Second release | — |
|
||||
|
||||
## Build (macOS)
|
||||
@@ -20,8 +20,7 @@ scripts/fetch-references.sh # upstream sources at pinned commits
|
||||
scripts/build-libreoffice.sh # patched LibreOffice 26.2.6.3 (about an hour)
|
||||
scripts/build-mupdf.sh # patched MuPDF 1.28.5
|
||||
scripts/build-git.sh && scripts/fetch-git-lfs.sh
|
||||
scripts/fetch-tessdata.sh && scripts/fetch-cef.sh
|
||||
scripts/build-mark-content.sh # ElyMark's page
|
||||
scripts/fetch-tessdata.sh
|
||||
cargo build -p ely-lok --bins -p ely-pdf-engine --bins
|
||||
cargo run -p ely-doc # or ely-table, ely-show, ely-pdf, ely-tree, ely-mark
|
||||
```
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| Doc / Table / Show | LibreOfficeKit,独立工作进程 |
|
||||
| Show 媒体 | 各平台原生:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(LOK 不放媒体) |
|
||||
| PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 |
|
||||
| Mail | MailCore 2 + SQLite;正文用 CEF 离屏内容区 |
|
||||
| Mail | MailCore 2 + SQLite;正文渲染随其任务另定(不用 CEF 与 Electron) |
|
||||
| Tree | Git CLI,参数数组启动 |
|
||||
| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析,RaTeX 公式,mermaid-rs-renderer 图 |
|
||||
| Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 |
|
||||
@@ -22,10 +22,10 @@
|
||||
## 目录
|
||||
|
||||
```text
|
||||
apps/ 八个应用,各自一个二进制;ely-mark/web 是其网页源码(锁定版本,esbuild 构建)
|
||||
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端)ely-web-content(CEF 离屏页面,ElyMark 与 ElyMail 共用);ely-mail-engine 随其任务建
|
||||
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 随其任务建
|
||||
reference/ 上游源码,只读,不属于本项目,不审查
|
||||
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本;content/<应用> 是网页内容的构建产物
|
||||
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本
|
||||
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
|
||||
vendor/ crates.io 上游 crate 的副本加本项目的修复,经工作区 `[patch.crates-io]` 换入(gpui-pre、gpui-pre-windows);首个提交为原样,其后是修复
|
||||
packaging/ 三端打包脚本,apps.tsv 是八个应用的二进制名、名字、桌面分类与发布批次;icons/ 由图标母版渲染各端格式;libreoffice/ely.xcd 是内核的默认配置层
|
||||
@@ -71,12 +71,8 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
||||
| gpui 无障碍只有 `aria_value` 等,无文本选区与插入点:LOK 文档的光标与选区到不了辅助技术 | `LokView` 只桥接当前段落文本(`Role::MultilineTextInput`)。上游待办:gpui 补文本选区 AX |
|
||||
| 选中对象的框与手柄:LOK 不画进图块 | `LokView` 按图形选区画框与 8 个手柄;按住手柄拖动经 `setGraphicSelection` 交内核(缩放;裁剪模式下裁剪) |
|
||||
| 虚拟化 PDF 页面栈(`DocumentViewer` 一次铺全部页,不合 PERF-05) | `crates/ely-pdf-engine`、`apps/ely-pdf` |
|
||||
| CEF 离屏内容视图 | `crates/ely-web-content` |
|
||||
| CEF 页面的无障碍:离屏渲染的页面不进系统 AX 树(CEF 另给 AX 事件,GPUI 无从挂接) | 未桥接;页面文字在辅助技术里不可见。上游待办:gpui 可挂外部 AX 子树 |
|
||||
| 组件库的大纲项(`lists::Outline`)与提示(`feedback::Alert`)无无障碍角色:屏幕阅读器读不到 | 上游待办;ElyMark 的搜索失败改为结果列表里的行。`feedback::Banner` 已修(fac20ab:危险与警告为 alert,其余为 status,名为其文字) |
|
||||
| `documents::ZenMode` 把正文夹进 Sm 宽的列,与 ElyMark 的正文宽度设置冲突 | ElyMark 专注模式自己收起标签条、左栏与状态栏,正文宽度由页面设置 |
|
||||
| CEF 页面的弹出层(`select` 下拉、日期框)在离屏模式另画一层 | 不画;Ely 的页面不用原生 `select`,用自绘菜单 |
|
||||
| macOS 的 CEF 离屏输入不带 AppKit 编辑命令:Blink 只凭它们处理 Cmd+方向键与 Cmd+Backspace | `ely-web-content` 的 `edit.rs`:页面没处理这些键时照 AppKit 标准键位执行 |
|
||||
| `documents::ZenMode` 把正文夹进 Sm 宽的列,与 ElyMark 的正文宽度设置冲突 | ElyMark 不用它,专注模式原生重做(C14.12) |
|
||||
| 表格公式栏与名称框(网格是 LOK 图块,`tables::Spreadsheet` 不用) | `apps/ely-table` |
|
||||
| Doc/Table/Show 工具栏(`documents::FixedFormatToolbar` 绑 `TextInput`,不接 LOK) | 各 app,用 `shell::Toolbar` 组合 |
|
||||
| CSV 导入的编码、分隔符、引号、列类型(`misc::CsvImporter` 只读逗号 CSV、映射到属主字段,文案写死英文) | `apps/ely-table` 的 `text.rs`,内核按选项导入 |
|
||||
@@ -125,7 +121,6 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
||||
scripts/doctor.sh 工具链版本
|
||||
scripts/fetch-references.sh 取上游源码到 reference/
|
||||
scripts/fetch-tessdata.sh 取 OCR 语言数据到 engines/tessdata(钉提交,核对散列)
|
||||
scripts/fetch-cef.sh 取 CEF minimal 包到 engines/cef(随 cef-rs 钉的版本,核对 SHA-1)
|
||||
scripts/build-git.sh 构建钉住的 Git 到 engines/git(ElyTree 随包)
|
||||
scripts/fetch-git-lfs.sh 取 git-lfs 放进 engines/git(核对散列)
|
||||
scripts/review.sh ID 轮次 路径 审查一条任务
|
||||
@@ -152,7 +147,6 @@ 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/scripts/hardened-macos.sh ELY_SIGN_IDENTITY 签名(hardened runtime)的 ElyMark 照样画出页面
|
||||
tests/engines/*.sh 引擎探测(LOK、MuPDF、libetpan);LOK 探测本构建的内核,不挂载官方镜像
|
||||
scripts/inert-lo.sh 路径.app 构建出的 LibreOffice.app 去掉文档类型、URL 类型、快速查看与聚焦插件并注销(build-libreoffice.sh 调用)
|
||||
scripts/extract-official-lo.sh 用 7zz 把官方镜像的 Frameworks 与 Resources 解到 engines/lo-official(不挂载,无包,“未打补丁即拒绝”的单测用)
|
||||
@@ -164,7 +158,6 @@ tests/scripts/doc-review.sh 真窗口 ElyDoc:无日期的批注逐条写明
|
||||
tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限)
|
||||
tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志
|
||||
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
|
||||
scripts/build-mark-content.sh 按锁文件构建 ElyMark 页面到 engines/content/ely-mark(不跑安装脚本)
|
||||
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
|
||||
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
|
||||
tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动
|
||||
@@ -172,7 +165,7 @@ tests/scripts/update.sh 装好的 ElyTree 从本机服务的新版下载
|
||||
tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限)
|
||||
tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限)
|
||||
tests/scripts/panels.sh 真窗口 ElyDoc:左侧栏默认不宽于 230、拖动内侧边缘变宽、宽度存入设置、重启后照旧(需辅助功能权限)
|
||||
tests/scripts/storage.sh 真窗口 ElyMark:设置里的存储列出各类大小,日志当场清空,缓存下次启动前清掉,恢复点数写入设置(需辅助功能权限)
|
||||
tests/scripts/storage.sh 真窗口 ElyMark:设置里的存储列出各类大小,日志当场清空,恢复副本下次启动前清掉,恢复点数写入设置(需辅助功能权限)
|
||||
tests/scripts/recovery.sh 真窗口 ElyDoc:恢复点、崩溃后副本、连续两次崩溃、只杀引擎后重新打开(需辅助功能权限)
|
||||
tests/scripts/pdf-recovery.sh 真窗口 ElyPDF:加锁文件的封存恢复点、空闲引擎退出后重开时再问密码(需辅助功能权限)
|
||||
tests/scripts/autosave.sh 真窗口 ElyDoc:自动保存开关、停手即写、外部改动不覆盖(需辅助功能权限)
|
||||
@@ -181,7 +174,6 @@ tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住
|
||||
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 并按自算的预期核对(往返用例调用)
|
||||
tests/scripts/webview.sh 真窗口 CEF 页面:输入法组字与提交、键入、焦点、主题变量、缩放、不可离开所在文件夹
|
||||
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(只有静态资源),地址 https://ely-office.zhangyanghaha0407.workers.dev
|
||||
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
|
||||
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
|
||||
@@ -215,7 +207,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-05(用户定):ElyAgent 的后端与内置模型的登录配置以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP;沿用其提供商登录(订阅账号或 API 密钥)与模型配置。
|
||||
- 2026-10-05(用户定):八个应用都不许卡顿:键入、选择、滚动、文档渲染都要即时(GPUI 的 120 FPS 手感),树莓派 3 上也要流畅。输入延迟与帧时间是验收项,要实测;界面线程不等内核、不持 worker 锁做同步请求,空闲不做事。
|
||||
- 2026-10-05:LOK 的输入与事件路径(实测后定)。原样:视图的事件监听长轮询 worker(持 worker 锁最多 50 毫秒,worker 读线程也随之阻塞最多 250 毫秒),按键排在锁后,键入锁等待中位 290 毫秒、最长 9 秒;输入队列在界面执行器上逐条跳回界面线程,每键约一帧,连续键入积压到 1 至 2 秒。现在:worker 有新事件即主动推送(`tag::EVENTS`,推送线程在输出锁下取批,批次即发送时状态),客户端读线程把回复与事件分开,事件存入 `client::Feed`,视图以异步方式等它(`Feed::next`,有新批次时唤醒,不占线程、不碰 worker 锁;原先每个打开的文档阻塞一个后台线程最多一秒,文档多于核数时会饿死输入与图块;监听任务归视图所有,视图释放即停);每个视图一条后台通道(`view/lane.rs`),输入优先、图块其次,连续执行,界面线程只收落地结果;文档尺寸与页框只在内核报尺寸变化时在后台读;批次弄脏的已显示图块即刻重绘,不等下一帧;视图的 `Reported` 事件只在内核回报或输入落地时发出,滚动与图块到达不触发应用的观察钩子;编辑与光标移动后应用要读的内核列表(ElyDoc 导航、字数、审阅,ElyTable 工作表与几何、单元格规则、格式面板)经 `ely_lok::read_off` 在后台读,一次一个,结果按发起者核对。实测(macOS,`tests/scripts/lag.sh`):按打字速度键入时输入落地 0 至 1 毫秒,图块 1 至 3 毫秒,按键到新图块 ElyDoc 中位 19 毫秒、ElyTable 3 毫秒;连续快速键入(脚本一次送出近百字)时,各次运行在 3 至 225 毫秒的中位之间摆动:新文档首次键入时内核有一次 150 至 230 毫秒的冷启动(首块图块与拼写等初始化),同一批字排在它之后;原为中位 1 秒、p95 1.9 秒。树莓派 3 未实测(本机无设备)。界面线程不碰 worker:应用对内核的读取、保存与另存为的导出、导出 PDF、比较、邮件合并都经 `Tabs::read_in`(或 `ely_lok::read_off`)在后台执行,回到窗口时按发起的标签与读取时的状态核对;保存导出期间标签状态为“正在保存”,同一标签的后续保存排队到它结束;另存为在写入前按文件身份再查一次目标是否已被别的标签持有;导出期间又有编辑时,“保存后关闭”不关闭,回到关闭询问;关闭标签时的撤销历史也在后台读;邮件合并预览与比较在发起时就保留自己的标签号与工作目录;读取失败不立即重试,等下一次状态变化;载入时一并在后台读版面与撤销历史(`ely_lok::layout`,`LokView::open` 接收版面)。窗口关闭回调先不放行,在后台逐个读标签的撤销历史并关闭(有改动的照常询问),最后一个标签关闭后移除窗口;内核文档的关闭也在后台(2026-10-06 起,原先同步读历史、在界面线程持 worker 锁关闭)。
|
||||
- 2026-10-04(用户定):八个应用的内容区一律随系统深浅色:Doc 页面、Table 网格、Show 幻灯片、PDF 页面、邮件正文、Mark 编辑区、Agent 输出。自动颜色随之翻转,文件里写明的颜色照原样;打印与导出 PDF 仍为浅色。各引擎(LOK、MuPDF、CEF)接入应用主题,不留引擎默认色。LOK:`ely.xcd` 定义 `COLOR_SCHEME_ELY_DARK` 与 `COLOR_SCHEME_ELY_LIGHT`(文档底色、自动文字色、网格、链接等取 Ely 默认色板;内核自带的深色方案颜色留空、按应用外观取默认值,传方案名不改外观,所以不用它),`LokView` 在主题深浅变化时经有序队列发 `.uno:ChangeTheme`(不算编辑),页面底色深色取 `surface`、浅色取 `paper`;Omarchy 色板与这两套固定色可能略有差别。导出 PDF 实测仍为白底。图块作废时旧图仍显示,新图到达后替换,编辑与主题切换都不闪。
|
||||
- 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`。
|
||||
@@ -229,13 +221,12 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-06:ElyShow 输出与演示检查(C10.6,SHOW-13、14,QA-12)。补丁 `0037-pdf-impress-handouts.patch`:PDF 导出的 `ExportHandouts`(每页张数 1、2、3、4、6、9)照打印的讲义版式(`SdPage::CalculateHandoutAreas`,3 张带笔记线)把 `PageRange` 内未隐藏的幻灯片排到讲义页上;其他张数不出页,导出失败。讲义版式会改文档的讲义页,所以讲义与图片都在另存的 ODP 副本上做(`output::on_copy`,做完关闭并删除)。`Pdf::handouts` 经 worker 的 `EXPORT_PDF` 传入。导出对话框:内容 PDF、图片、讲义,范围全部、当前、从–到,讲义每页张数(默认 3);打印对话框:幻灯片或讲义,生成同一 PDF 后交系统打印面板。图片每张未隐藏的幻灯片一个 PNG,宽 1920,副本设浅色方案,文件名 `名称-序号.png`,所选文件夹里任一目标已存在即整体不写。PPTX/ODP 由保存与另存为负责。补丁 `0038-lok-impress-slide-check.patch`:读 `.uno:SlideCheck`(每张幻灯片与母版:文字所用字体族、链接图片与 OLE 链接及是否缺失、内核不能显示的对象:读不出的图片、无数据的 OLE;缺失的链接图片只作为链接报一次)。演示检查以内核报的缺失字体(`FONTS_MISSING`)为准,按所用字体定位到幻灯片或母版;媒体缺失与媒体链接取自 `.uno:SlideShow`;点一行转到所在第一张幻灯片。系统打印面板只经 `ely_office::print_later` 打开:它在前台任务里、GPUI 放开应用之后运行(面板的模态循环会回调 GPUI,在实体更新里打开即借用冲突崩溃),三个应用共用。图片输出先以 `create_new` 占下全部目标(不跟随符号链接),任一冲突或失败都删去本次占下的文件。输出与检查的对话框由后台读取打开,回来时须仍是最近一次请求(`Deck::ask_dialog`、`dialog_due`)且没有别的对话框;对话框种类或当前标签一有变化,此前的请求即作废(`watch_dialogs`,随文档通知)。图片的张数与隐藏取自写出的副本本身;PNG 写完显式 flush。读不出的图片按载入后的状态判定(`NONE` 或解码失败的 `Default` 占位);OLE 链接检查源文件是否在。
|
||||
- 2026-10-06(用户定):ElyPDF 签名与权限。验证签名时证书信任取操作系统的信任库(macOS 钥匙串的信任设置;Windows、Linux 随其构建)。可视签名三种来源:手写、键入姓名、图片文件;可视签名不是数字签名。只凭用户密码打开、带所有者限制的文件照限制办(打印、复制、编辑等被拦并说明原因),输入所有者密码可解除;改保护须所有者权限。
|
||||
- 2026-10-06(用户定):OCR 随包语言数据用 tessdata_fast,英文、简体中文、繁体中文(eng、chi_sim、chi_tra)。
|
||||
- 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;CEF 内容宿主随 ElyMark 先建(C14.0),邮件编号依次前移。
|
||||
- 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;邮件编号依次前移。
|
||||
- 2026-10-06(用户定):`ely-office` 的外壳不再只服务 LOK:`Host` 带关联类型 `Engine`(`ely_office::Engine`:后台载入、视图、撤销历史的标记、写出、关闭,视图的落定、编辑计数、待落地、焦点与事件),`Tab` 与 `Core` 按引擎取文档、视图与 worker 类型。LOK 的实现在 `ely_office::lok`(`Lok`、`start`、只对 LOK 的 `Commands` 即 `run_active`/`run_in`;查找替换、面板、图表仍只限 LOK);MuPDF 的在 `apps/ely-pdf/src/engine.rs`(`Pdf`;worker 首次载入才起,`Mupdf::get`)。`Kind::macros` 为 `None` 表示该格式无宏,不扫文件。ElyPDF 每个标签一个 `PdfView` 实体(页面、图块、文字、选择、找到的词都在里面,自己绘制),`Viewer` 只管标签条、书签栏、查找框与状态栏;打开、密码、关闭询问与保存走外壳。外壳的密码框:对话框开着时后到的加锁文件排队(`Core::locked`),对话框关闭(含取消、提交)后下一个再问;每次询问一个身份(按输入框实体),连续询问的 Esc 各归各的;提交时对话框先关、再载入。ElyDoc 的加锁测试原先清的是应用自己的对话框字段,改为清外壳的。
|
||||
- 2026-10-07:本地 Web 内容宿主(C14.0,§17.3)。`crates/ely-web-content`:CEF 154.0.32(Chromium 154.0.8037.58,cef-rs a712ad9,`default-features = false`),`scripts/fetch-cef.sh` 取 minimal 包到 `engines/cef`,`.cargo/config.toml` 的 `CEF_PATH` 让构建用它。进程:应用的 `main` 先调 `ely_web_content::subprocess()`(参数里有 `--type=` 即为 CEF 子进程,载入框架后 `execute_process`,不碰 GPUI);开发构建子进程即应用本身的二进制;macOS 包里是五个辅助应用(`<二进制名> Helper`、`(GPU)`、`(Renderer)`、`(Plugin)`、`(Alerts)`,`Contents/Frameworks`,`LSUIElement`,可执行文件为 `ely-web-helper`),框架在 `Contents/Frameworks`,`packaging/macos/bundle.sh` 对依赖 ely-web-content 的应用自动放入并先签内层;查找次序 `ELY_CEF`、包内、辅助应用所在、构建时的 `engines/cef`。启动:`start` 在 GPUI 里只做准备(载入框架、给 NSApp 的类补上 Chromium 要的 `isHandlingSendEvent`、`setHandlingSendEvent:` 与包住 `sendEvent:`,并加 `CrAppProtocol`、`CrAppControlProtocol`),`initialize` 与建浏览器都在消息泵任务里、GPUI 的更新之外进行(实测:在更新里同步调 CEF,CEF 转动 AppKit 的循环,GPUI 报 RefCell 已借用、整窗不画);浏览器在泵里同步建(`browser_host_create_browser_sync`;原为异步,建好之前视图已释放时浏览器无人关闭),视图要么在建之前已走(不建),要么有浏览器可关。凡在 GPUI 里对 CEF 的调用(输入、焦点、尺寸、脚本、缩放、重新载入、关闭)都排进主线程的队列,由泵在下一轮、更新之外依次执行(实测:在实体释放时同步关浏览器,CEF 转动 AppKit 的循环去校验菜单,GPUI 的菜单回调遇已借用的应用状态而中止,约三成复现)。泵:外部消息泵,按 `on_schedule_message_pump_work` 的时间,另照 cefclient 每轮后以 1/30 秒兜底(实测:只按请求时第一轮后 CEF 再不请求,页面不载入);空闲时 CPU 约 1.5%。离屏渲染须用 Alloy 风格(`runtime_style`;Chrome 风格不出画面)。同一缓存目录只允许一个实例,第二个启动时报出占用它的进程号。画面:`on_paint` 的 BGRA 直接成 GPUI 图像,上一帧在下一次绘制时从图集释放(`drop_image`);光标随页面。输入:鼠标点击、移动、滚轮(行按 40 点),键照 cefclient 的 macOS 做法:键盘按下带字符与系统键码发 `KEYDOWN` 再发 `CHAR`(退格、回车、方向等用 AppKit 的字符),可打出字的键等 GPUI 的输入法回调后以所打的字发同样两个事件,多字或组字经 `ime_commit_text`/`ime_set_composition`(无替换范围时用 CEF 的“无范围”,两端为最大值;传空指针 CEF 不收);松开带同一字符(不带时 Chromium 当作又按一次,实测退格删两个字);拷贝、剪切、粘贴、全选走框架命令。焦点随 GPUI 的焦点给页面。页面与应用之间:CEF 的消息路由,页面调 `window.elyQuery({request, onSuccess, onFailure})`(`onSuccess` 与 `onFailure` 都必须有,缺一路由即不发;页面载入时路由未就绪,早到的查询丢失),应用收到 `Event::Query(请求, 回复)`;应用以 `run_js` 调页面。主题:每次载入与主题变化时把调色板、代码色、字体与字号比例、深浅写成 `:root` 的 `--ely-*` 变量与 `color-scheme`。缩放 `set_zoom(倍数)`。页面只许载入所在内容文件夹里的文件:按真实路径判定(`..`、百分号转义、指向外面的符号链接都算外面,文件须存在),导航与每个资源请求(脚本、图片、样式、请求等)都过这道门,资源另许 `data:` 与本页的 `blob:`,其余一律取消;另在每个 CEF 进程以 `--proxy-server=127.0.0.1:9` 与 `--proxy-bypass-list=<-loopback>` 把一切网络(含本机回环与 WebSocket,后者不经资源回调)送往不通的代理;WebRTC 走 UDP 不经代理,故另以 `--force-webrtc-ip-handling-policy=disable_non_proxied_udp` 只许它经代理,并在每个页面上下文建立时、页面脚本之前删去 `RTCPeerConnection` 等构造(渲染进程的 `on_context_created`);页面的地址由真实路径逐字节转义生成(文件名含 `#`、`?` 照样载入);用户点出的链接以 `Event::Leave` 交给应用;`WebView::open_inert` 开的页面不运行脚本(`BrowserSettings.javascript` 关),ElyMark 的打印与导出 PDF 暂存页用它,文档里的 HTML 脚本不运行(M-15);页面的警告与错误只记行号(警告文字与来源都可由文档伪造,不进日志);页面自己的诊断经 `elyQuery` 交应用记录。载入失败(主框架,停止不算)、渲染进程退出、CEF 起不来都报 `Event::Failed`,页面区改显示组件库的错误视图与“重新载入”(Line truncated
|
||||
- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1)。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。
|
||||
- 2026-10-07:ElyTree 储藏、远程、工作树、子模块、LFS(C13.5,TREE-10 至 12,§13.3)。左栏第四页“仓库”:上方取回(`fetch --progress --all`)、拉取(`pull --progress`,照用户的 pull 设置)、推送、强制推送…;储藏(`stash list -z`,新的在前;“储藏改动…”问说明与是否包括未跟踪的文件,`stash push`),远程(名称与地址,`remote` 加 `config --get-regexp` 读出取回与推送地址),工作树(`worktree list --porcelain -z`,行名为文件夹名,有失效的可“清理”即 `worktree prune`),子模块(索引里的 gitlink 与文件夹自身 `.git` 的 HEAD:未检出、在记录的提交上、在别的提交上、冲突),LFS 文件(`git lfs ls-files --long --json`:工作树里是内容、对象在本机而工作树是指针、只有指针而对象不在本机);没有子模块或 LFS 文件时不列。各列表分别读出,一个读不出只在该处显示 Git 原话;切到此页时读,此后每次状态重读时重读,只取最新一次。选中一项,中间显示:储藏为提交详情的同一套(`hist::Picked`,历史与储藏共用读取与面板):改动的文件(对第一个父提交)与储藏的未跟踪文件(第三个父提交,各自对空树),选文件看差异,上方应用、弹出、删除…;远程为地址与推送地址,取回、修改…(改名 `remote rename`、改地址 `remote set-url`)、移除…(先问);工作树为路径与分支或提交、锁定与失效原因,打开(作为标签)、移除…(先问,主工作树与当前的不给);子模块为记录的与检出的提交,“检出记录的提交”(`submodule update --init`,经远程运行)与打开。用户输入的名称、地址、路径前一律加 `--`(推送的远程名同样)。表单与选择对话框(新建分支、标签、改名、身份、储藏、远程、工作树、选远程与上游)记下打开时的仓库,提交时写进那个仓库,不随当前标签变;储藏列表按每条三个字段读(说明可为空)。储藏的应用、弹出、删除按 `stash@{n}` 执行,执行前在写队列里核对它仍是所见的那个储藏(另有储藏插入即拒绝,什么也不做);弹出遇冲突时 Git 保留储藏,对话框显示 Git 原话。删除储藏、移除远程与工作树每次都问。分支页的本地分支多出“设置上游…”(列出远程分支,`branch --set-upstream-to=refs/remotes/名`)与“取消上游”。推送:有上游即 `push --progress`;没有上游时后台读出远程,问推到哪个远程(`push --set-upstream -- 远程 refs/heads/分支:refs/heads/分支`),没有远程或 HEAD 游离即说明。强制推送(§13.3):后台读出当前分支、上游的远程与远端分支、上次取回时远端的提交(租约)、此刻 HEAD 的提交(源)与远端有而源没有的提交,对话框写明远程、分支与将被覆盖的提交(或写明没有),确认后推送的就是所示的源提交(`push --force-with-lease=远端分支:租约提交 -- 远程 源提交:远端分支`),确认前本地分支再动也不改变推送内容;远端自上次取回后变了即被拒绝;没有上游或游离即说明不能。远程命令(取回、拉取、推送、子模块更新)是写队列里的一种(`Write::Remote`),经 `running::Running` 运行:Git 在自己的会话里(`setsid`,SSH 找不到终端,不会挂在终端提示上;凭据照用 credential helper 与 ssh-agent),进度逐行显示在中间上方的提示条,可停止(向整个进程组发 SIGTERM,Git 自行清理;已结束的不再发);用户停止时提示“已停止”、清掉其后的排队写,不当作失败;失败时对话框显示 Git 的 stdout 与 stderr(以回车结尾的进度行被下一行改写,只留最后一次;以换行结尾的行全部保留,钩子的每一行都在);拉取遇冲突照合并类命令判定为停下。关窗与退出时停下所有远程命令与克隆。克隆改用同一运行器。真窗口(macOS):仓库页五类列表、储藏详情与文件、工作树详情、分支行。未验证:Windows 与 Linux(停止远程命令在非 Unix 上报未构建);真实网络与 HTTPS、SSH 凭据;LFS 对象的下载(只显示状态)。
|
||||
- 2026-10-07(用户定):跟随组件库最新的 main。组件库 K01 把 gpui 从 zed 仓库的提交换成 crates.io 的 `gpui-pre` =0.3.8(`gpui-pre-platform` 同版本),K02 让 Tab 绑在 FocusScope 的 ElyFocus 上下文下、更深的上下文先得;本项目工作区的 `gpui`、`gpui_platform` 随之改为这两个包(以 `package =` 保留原名,源码不变),组件库 pin 到 dabc1eb(其上加本项目一个提交:树与树行的无障碍角色)。升级后全工作区编译无改动。
|
||||
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据、CEF 与页面的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);CEF 的 LICENSE 与 Chromium 的 CREDITS、页面的第三方通知;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。正式签名(hardened runtime)时 CEF 的渲染与 GPU 辅助程序带 `allow-jit`,插件辅助程序带 `allow-unsigned-executable-memory` 与 `disable-library-validation`(`packaging/macos/entitlements`;原先不带,V8 保留不到代码区,页面载入失败);`tests/scripts/hardened-macos.sh` 以 `ELY_SIGN_IDENTITY` 签 ElyMark 并核对页面画出。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
|
||||
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);ElyMark 带 RaTeX 嵌入的 KaTeX 字体的 OFL 与说明;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
|
||||
- 2026-10-08(用户定):深色主题不用纯黑或近黑,底色取柔和的灰 `#252525`。组件库 fd0efc7:深色色板 bg `#252525`、surface `#2c2c2c`、sunken `#202020`、overlay `#323232`、hover `#383838`、active 与 border `#3f3f3f`、border_strong `#555555`;为让说明文字在较亮的 hover 与 active 上仍有 4.5:1,muted 提到 `#c4c2c0`、subtle 与代码注释提到 `#acaaa8`;色盲友好图表的紫与橙改为 `#9d8dec`、`#ffd93d`(3:1 且各色两两分得开)。`ely.xcd` 的深色方案随之:页面 `#2c2c2c`、页外 `#252525`、边界与网格 `#555555`、域底纹 `#3f3f3f`。跨应用同步外观、设置里的浅色/深色/跟随系统、Omarchy 下随 Omarchy 主题,本来已有(共用 `settings.json` 加文件监视;`settings.sh` 核对 ElyPDF 选深色后 ElyTree 即刻变深);Omarchy 的读法与 OmaPhoto 相同(`~/.local/state/omarchy/current/theme/colors.toml`、`mode` 与具名颜色)。
|
||||
- 2026-10-08:图像的显存与内存(C20.2 长时测试查出)。GPUI 把每张画过的 `RenderImage` 留在窗口的图集里,直到调 `window.drop_image`;LOK 的图块、PDF 的图块与缩略图、演示的缩略图、动画预览与放映图层原先都不释放,ElyDoc 编辑保存 60 轮后应用进程涨到约 2 GB(内核不涨)。组件库 `primitives::Retired`:不再画的图先交给它,下一次绘制时释放(`release` 只放别处已不持有的,`release_all` 给自知不再画的属主,`append` 接过关闭者的);LokView、PdfView 在替换、挤出、清空图块时交出旧图,绘制前释放;ElyShow 每个标签一份,关闭的标签与结束的预览交给 Deck;放映按窗口各一份(观众屏、演讲者视图),离场中的幻灯片还在画时其图层等它结束。修后同样 100 轮在 340 至 440 MB 之间起落。
|
||||
- 2026-10-08:性能基准(C20.1,§20.1)。`tests/perf/bench.py` 在发行构建上测 PERF-01 至 08(每项 30 次,P50、P95,含全部子进程的内存),样本由 `tests/perf/samples.py` 按固定种子生成到 `engines/bench`,记录写到 `docs/perf/<日期>-<机型>.md`(格式见其 README);`ELY_STALLS=1` 开主线程忙碌记录(只供测量,空闲的应用照样不做事)。实测改动:Doc、Table、Show 原先要等内核启动完(约 2.5 至 3.9 秒)才开窗口,ElyDoc 日常启动 P95 3.9 秒不达标;现 `Worker::launch` 只起进程,内核的版本与图块核对在第一个请求前做(`Worker::ready`,任何请求都先过它,未核对的 worker 收不到请求),`ely_office::start` 在后台核对并设作者名,失败即 `startup::fail`;窗口约 0.15 秒可操作,空白文档在内核首次载入后出现(中位约 2.7 秒,其中约 2.2 秒是内核首次载入文档)。启动参数改为按当前目录取绝对路径(原相对路径成了 `file://相对` 而打不开)。补丁 `0039-skia-macos-no-unused-font-manager.patch`:macOS Skia 首次画字时以全部系统字体再建一个无人使用的 `SkFontMgr`(约 370 个字体枚举加排序,主线程约 175 毫秒),新文档的第一个字与打开文档的首块带字图块因此迟到;去掉后 PERF-03 的 10 次对比 P95 由 2.6 秒降到 1.5 秒。PERF-02 的计时原以输入落地时的代际为基准,内核的作废事件先于落地回报到达时,该次编辑的图块不计,钟一直走到下一次按键(记下的是按键间隔约 250 毫秒);现按编辑的修订认定(`view/clock.rs`:修订不低于该编辑的批次所升的代际,其图块即为所求),实测中位 2 毫秒、P95 5 毫秒。PERF-08:关闭的标签原把其图块留在 GPUI 的图集里(每个文档约 20 MB 显存,40 次新建关闭后应用涨约 250 MB);`Engine::retire` 交出关闭视图的全部图像(LOK 图块、PDF 图块与缩略图、ElyMark 页面的画面),外壳在窗口的下一帧释放,修后 40 次在 454 至 462 MB 之间。历史首屏改为从按下“历史”计(原从进程启动计,含打开仓库)。R1 后基准更严:PERF-03、04 到视图收下的第一块图块(`lok: tile (x, y) shown`),其间有图块画失败即整次作废;DOCX 样本原以单独一段的分页符分页,内核只排出 60 余页(`Heading1` 段上的 `pageBreakBefore` 同样丢页),改为分页符放在标题段内,PERF-03 之前先核对恰为 100 页(视图记录页数);PERF-02 每键须有自己的计时,缺即失败;PERF-08 循环前记基线,记内核中的文档数(worker 每次载入关闭写 `N documents open`),文档数须回到基线,文件数自第一次之后不变(第一次打开模板对话框时进程一次性载入两个系统字体与两个 AppKit 本地化表)。worker 的启动核对失败后保持失败,此后一切请求被拒(原核对失败的状态与成功相同,第二次即放行)。空白文档就绪由约 2.5 秒降到约 1.0 秒,发生在本次重建内核之后,与 0039 无关(对比实测),原因未查明。
|
||||
@@ -243,23 +234,15 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-08:兼容清单与往返验证(C19.1,§19.1、§19.2)。`docs/compatibility.md` 是随版本发布的兼容清单(版本、内核与补丁、验证机,各应用的编辑保存、导入受限与输出,已验证与限制),版本或格式行为变了即同步改它。往返用例在 `crates/ely-lok/src/client/tests/independent.rs`:Ely 编辑、保存、关闭、再打开、再编辑、再保存,再由 `tests/scripts/ooxml.py` 检查;它不用 LibreOffice,自己解 OOXML,预期值自己算(整数、日期序号、错误值、文本严格相等,浮点按用例声明的容差),DOCX 另交 macOS 的 `textutil`;没有 python3 时报 SKIP。与 Microsoft Office 的交叉检查未做(本机没有 Office)。
|
||||
- 2026-10-08:恢复副本、引擎停止与自动保存(C06.5,§6.2、§6.3)。Doc、Table、Show、PDF 共用 `ely_office::Recovering`:每 10 秒为有改动、已落定、不在导出的标签经 `Engine::snapshot`(写副本,文档不动)写一个恢复点(`ely_files::Recovery`,限额取存储设置;加密文档封存),索引在数据目录 `recovery/index.json`;崩溃标记照 ElyMark 的 `Crash`,下次启动把崩溃留下的最新点开成未命名副本并标为未保存,提示写明只恢复了内容,光标与撤销历史没有。引擎进程退出(`Engine::ended`,客户端 `try_wait`)时 `ely_office::Stopping` 在上方显示提示条(原因、最近一次恢复点的时间)与“重新打开文档”:`Engine::restart` 换上新 worker(LOK 照原配置与作者名重起,PDF 下次打开再起),有改动的标签从最新点开成副本,无改动的从文件重开,有改动却没有恢复点的列出;被放下的标签迟到的失败不再提示,指向它们的关闭询问一并撤下。重开的副本接过原恢复点的文件夹(封存随之),旧点留到新标签写下新点为止;载入或解封失败的条目留在索引里,下次崩溃后的启动再给。需要密码的副本(加密 PDF 的恢复点照原加密写出)照样询问,答后仍是未命名副本(`Source::Locked`)。引擎是否退出在出错时与每次恢复计时时查,空闲的引擎退出也会显示。恢复索引经 `ely_files::save_atomic` 暂存后替换,写不进时上一份照旧可读;写不进即提示一次、每次计时重试。干净结束后的下次启动只删已接过或正常的条目,没能重开的留着再试。自动保存(设置“文档”页,每个应用一个开关,存在应用的 `settings.json` 的 `autosave`,默认关):只对本应用自有格式(`Kind::save_as`)、无风险(宏、签名、修复)、状态为“已编辑”的标签,编辑计数停 2 秒不变即走普通保存;保存失败、外部改动后状态不再是“已编辑”,不再自动写,等用户处理。Cmd+W 关闭标签是共用命令(`ely_ui::commands::CloseTab`,原来只有 ElyPDF 绑了;文档应用里 Cmd+W 落进 LOK,内核按自己的 Ctrl+W 关掉框架,worker 下一次绘制或读取即在已释放的文档上段错误);worker 另拒绝内核关闭框架与退出的快捷键(Mod1 加 W、F4、Q)。真窗口(macOS):`recovery.sh`(崩溃后副本回来、关闭询问;只杀引擎后提示条、重新打开、文字回来、关闭询问),`autosave.sh`(关时不写,开关存下,开后停手即写,外部改动后不覆盖)。未验证:Table、Show、PDF 的真窗口恢复(同一外壳);Windows 与 Linux。
|
||||
- 2026-10-08(用户定):首版发布六个应用(ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark);ElyMail 与 ElyAgent 第二批发布。ElyMark(C14)做完即开始首版的完整测试与打包,C12、C15 留到第二批。
|
||||
- 2026-10-08:数据管理(C18.3,§18.3)。设置的“存储”页(每个应用):本应用数据目录里的日志(`ely.log`)、网页内容缓存(`web`,只 ElyMark 显示)、恢复副本(`recovery`)各自的大小与清除(`ely_platform::storage`);日志当场清空(日志器以追加打开,截短不坏句柄),缓存与恢复副本在用着,写下记号(`.clear-web`、`.clear-recovery`),下次启动在设置与窗口之前删去(删不掉即启动失败、写明原因,记号留着下次再试,不打开没删掉的数据);每次清除先问,写明删什么、何时删;每个文档保留的恢复点(10、20、50)与空间(32、64、256 MB)存在应用设置的 `recovery`(严格读,不合即报错),ElyMark 写恢复点时照用(C06.5 的文档应用同)。日志超过 8 MB 时,启动时只留后 4 MB、从整行起。数据库的升级前备份与失败只读只属 ElyMail(第二批),首版的六个应用没有数据库;邮件与 Agent 历史导出同属第二批。组件库 ea4ed4c:`SettingsRow` 是以标题命名、以说明描述的组(原先行标题不进 AX 树,两个同名按钮分不出);待清除的状态以禁用的按钮写明。
|
||||
- 2026-10-08:数据管理(C18.3,§18.3)。设置的“存储”页(每个应用):本应用数据目录里的日志(`ely.log`)、恢复副本(`recovery`)各自的大小与清除(`ely_platform::storage`);日志当场清空(日志器以追加打开,截短不坏句柄),恢复副本在用着,写下记号(`.clear-recovery`),下次启动在设置与窗口之前删去(删不掉即启动失败、写明原因,记号留着下次再试,不打开没删掉的数据);每次清除先问,写明删什么、何时删;每个文档保留的恢复点(10、20、50)与空间(32、64、256 MB)存在应用设置的 `recovery`(严格读,不合即报错),ElyMark 写恢复点时照用(C06.5 的文档应用同)。日志超过 8 MB 时,启动时只留后 4 MB、从整行起。数据库的升级前备份与失败只读只属 ElyMail(第二批),首版的六个应用没有数据库;邮件与 Agent 历史导出同属第二批。组件库 ea4ed4c:`SettingsRow` 是以标题命名、以说明描述的组(原先行标题不进 AX 树,两个同名按钮分不出);待清除的状态以禁用的按钮写明。
|
||||
- 2026-10-08(用户定:图标照 Papirus 重画):每个应用是 Papirus 192 网格上的一个大的扁平物件(页、讲台、信封、终端),主体一种饱和色,折角为其浅色,内容为白色、至多一个点缀色,主体与内容带 Papirus 的柔和投影(下移 4 或 2、模糊、20% 黑)。macOS 为浅色 squircle 上的同一物件(占主体 86%),Linux 与 Windows 只有物件。48 像素以下用无投影、无小细节的简化版。母版由 `packaging/icons/masters.py` 写出,配色见 `assets/icons/README.md`。Papirus 图标包(`assets/papirus_icons-master.zip`,用户放入)只作参照,不随包。
|
||||
- 2026-10-08(用户要求核对各系统的文件关联):文件类型只写在 `packaging/types.tsv`(应用、扩展名、MIME;`/` 为文件夹)。Linux 的桌面项由它写 `MimeType=`(原先没有,桌面环境从不提供 Ely 打开文件);Windows 安装程序由 `packaging/windows/types.sh` 生成的 NSIS 宏注册(原先没有):HKCU 下每个扩展名的 `OpenWithProgids` 与 `Applications\<程序>\SupportedTypes`,ProgID `ElyOffice.<名称>` 带图标与打开命令,ElyTree 在文件夹右键菜单加“Open in ElyTree”;只进“打开方式”,从不改默认(同 macOS 的 Alternate),卸载时删去。makensis 在本机编过 ElyDoc 与 ElyTree 的安装程序;Windows 与 Linux 上实际打开文件未验证。
|
||||
- 2026-10-08(用户定:窗口缩窄时控件挤成一团、越出边框,不可接受):窄窗口的版面。外壳一次排两侧栏(`shell/panel.rs` 的 `room`,有单测):内容区至少留 360,放不下时两侧先缩到各自最窄,再先藏右栏、后藏左栏,窗口变宽即回来,用户拖的宽度不变。组件库 6d95b24 至 69265b4:`ScrollArea::vertical` 只纵向滚动、内容不得宽于区域(侧栏一律用它,原先横向不受限,整栏按自然宽度排后被裁);`PropertyGrid` 每行可折行,放不下时控件到标签下方,标签列 104;全部图标的 `SegmentedControl` 与 `Tabs` 放不下全部文字时只显示图标,文字作提示与无障碍名(宽度取上一帧量得的条宽与文字排版宽度);`StatusBar` 两侧与各项可缩,文字以省略号结尾;`Banner` 文字折行;`Button` 不再拒绝收缩,只在无法折行时缩并以省略号结尾;未聚焦的单行文本框显示文字开头(原插入点在末尾,看到的是尾部);`Switch::name` 只给无障碍名(行已有标签时不再重复画字);`CommitInput` 的勾选与按钮一行可折。各应用固定宽度的控件框(`w_32` 等)改为占满、以原宽为上限。`tests/scripts/layout.sh`:六个应用各开样本,640、800、1100 宽下读 AX 树,任何元素越出窗口左右即失败,并留截图供目视(栏内裁切 AX 看不出)。缩放(用户问为何 Doc、Table、Show 不能缩放;视图早有 `set_zoom`,界面从未接上):`ely_office::Zooming`,Cmd+= / Cmd+- / Cmd+0,“视图”菜单三项,状态栏右侧 `ZoomControl` 的属主模式(组件 69265b4:显示属主给的百分比、按下交属主;原只缩放整个界面);Cmd+滚轮与触控板捏合以指针处为中心缩放(`view/zoom.rs`,档位 25% 至 400%);ElyShow 的 Cmd+0 回到适合幻灯片,手动缩放即停止适合。缩放只在内存里随标签,不跨重启。
|
||||
- 2026-10-08(用户定:侧栏太占地方,要可调):侧栏宽度(C05.6)。外壳的左右侧栏(`shell/panel.rs`,八个应用共用)默认左 220、右 320(原取 `sidebar_width` 240 与 `sheet_size` 380;右 320 是 ElyDoc 属性栏不截断控件的最窄宽度,实测 280 截断数值框);内侧边缘是组件库的 `layout::resize_edge`,拖动改宽,左至少 160、右至少 280(原 220,属性栏放不下)、至多窗口的 45%;拖动停 0.5 秒后存入应用设置的 `panels`(`settings::panels`),退出时还没存的当场写入(窗口位置同),启动时严格读回(不是正数即在设置窗口写明并用默认),写入失败也在设置窗口写明;开合动画期间不显示拖动边。
|
||||
- 2026-10-08:凭据与私密内容(C18.2,§18.2)。恢复密钥(加密文档的恢复副本用)存在系统凭据库(keyring:钥匙串、Windows 凭据、Secret Service);凭据库不可用或拒绝时改用本次运行的随机密钥,只在内存(`RecoveryKey { key, lasting }`,`lasting` 为假即提示恢复不跨重启),从不写成普通文件;用旧密钥封存的恢复点读不开即报“无法恢复”,不导出明文。PDF 打开密码与证书密码只在 worker 内存里、文档关闭即弃。日志:worker 的 stderr 经 `ely_ipc::relay` 按其级别并入应用日志(原先从访达启动时 worker 的日志丢失),两个 worker 与应用一样读 `ELY_LOG`、默认 info(LOK worker 原读 `RUST_LOG`,平时只出错误);日志调用不打印正文、密码、密钥;引擎(worker、内核、MuPDF)给的错误串可能引用文档,日志只记请求号与错误类别,全文只经回复交给界面;worker 的 stderr 只放行其结构化日志记录与崩溃栈帧,引擎自己打印的文字不进日志(只计数)。worker 启动失败也写成结构化记录,最后一条错误由 `Relay::last_error` 带进客户端的报错。诊断导出时姓名来源(账户名、设置)读不出即不导出并写明原因;日志尾部从整行开始。诊断(设置里的“诊断”,每个应用):先列出要导出的内容(本应用日志的最新 4 MB、macOS 上 7 天内本应用与其 worker 的崩溃报告,列表在 AX 树里),开关“去掉路径与个人姓名”默认开(凡路径与 URL 整段换成 `<path>`:有引号到引号为止,否则到行末;分段标题只写文件名;账户全名、登录名与作者名按从长到短换成 `<user>`),导出为一个文本文件(`ely_platform::diagnostics`),不上传。Git 服务的凭据照用用户自己的 credential helper 与 ssh-agent;打开文件不发任何请求。未验证:Windows 与 Linux 的凭据库与崩溃报告位置(那两端诊断只含日志)。
|
||||
- 2026-10-08:配置(C18.1,§18.1)。通用偏好在 `<本机数据目录>/ElyOffice/settings.json`,各应用的在其数据目录的 `settings.json`;都经 `ely_platform::settings::Store`:带 `version`(本构建为 1;更新的版本或读不通即报错,不覆盖),写入在同目录的 `.settings.lock`(`ely_platform::lock::FileLock`,flock 或 LockFileEx,由 ely-files 移来共用)下先重读、只改所写的键、未知字段原样留下,临时文件 fsync 后原子替换;多个应用同时写不丢字段(单测八路并发)。通用字段(`settings::Shared`,类型不对即报错):外观(跟随系统、浅色、深色)、语言(跟随系统、English、简体中文;下次启动生效,设置里写明)、动效(跟随系统、减少、完整)、跟随 Omarchy(只在有 Omarchy 时显示)、作者名(空即账户名;`user::name` 先取它,ElyPDF 的批注、LOK 的批注与修订都用它)。环境变量 `ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION`、`ELY_LOCALE` 仍优先。`ely_ui::settings` 启动时读入(读不出即用默认值、记下原因),以 `notify` 监视文件夹,别的应用写入后即刻重读:外观、Omarchy、动效当场换,作者名经 `observe_global` 在后台交给 LOK worker(`set_author`)。设置窗口(应用菜单“设置…”,Cmd+,,每个应用一个)用组件库 `SettingsLayout`、`SettingsSection`、`SettingsRow`、`ThemeSelector`(组件 489ef31 起三项文字进内建中英目录)、`SegmentedControl`、`Switch`、`forms::Input`:通用;Doc、Table、Show、PDF 另有“文档”(作者名,失焦或回车写入);ElyTree 另有“Git”(Git 程序,空即随带的;写入前在后台核对 2.40 或更新,不是即在框下以 Banner 说明、不写;`ely_platform::git::locate` 次序为 `ELY_GIT`、此设置、随带的,版本核对移入 `ely_platform::git::system_version`,下次启动 ElyTree 生效)。文件读不出时窗口上方以 Banner 写明原因,改动不保存。作者名与 Git 程序每次提交都编号,写入经 `Store::set_if` 在文件锁下核对仍是最新的提交才落盘(等锁期间改了主意,旧写入作废);写入失败另以 Banner 写明,键入的值留着可重试;作者框在未编辑时跟随别的应用写入的名字。版本核对先看 `--version` 的退出状态(`git::version_of`)。各应用主窗口的位置、大小与最大化、全屏存在应用的设置里(`window`,停止移动 0.5 秒后写),启动时所记之处的中心仍在某块显示器上才用。另:ElyMark(CEF)直接启动的应用继承了 Chromium 忽略或屏蔽的 SIGTERM,测试的 pkill 杀不掉、留下进程(实测数个钟头的残留);`apps::open` 在 exec 前把 SIGTERM、SIGINT、SIGHUP、SIGQUIT 复原并清空信号屏蔽(单测:启动者忽略并屏蔽 SIGTERM 时子进程照样被终止)。未验证:Windows 与 Linux 的锁与窗口位置。
|
||||
- 2026-10-08:进程与状态核对(C17.1,§17.2、§17.4)。帧(`ely-ipc`)由长度、标签、请求号与内容组成:客户端每个请求一个递增的请求号,worker 的回复带回同一个,不符即报错(`check_reply`;原先只靠一问一答的次序配对,丢一个回复即整串错位而不自知);LOK 主动推送的事件请求号为 0(`UNASKED`);PDF worker 的版本戳包含 `ely-ipc` 的源码。帧超过上限(64 MB)发送前即报错,什么也不写;worker 的结果超限时回一个错误,worker 不退出。LOK 的输出读不下去(坏帧、提前结束)时,原因经回复通道交给等待的请求,客户端停下并回收 worker 再报出(原先请求一直等进程结束)。会话:LOK 每次载入一个会话号(递增,原先用内核文档指针,释放后的地址被下一份文档复用,旧句柄读到新文档、事件被当作已关的丢掉);PDF 的 `Doc` 带打开它的 worker 会话,客户端发送前核对,别的 worker 的旧句柄被拒;worker 的文档号带进程前缀(进程号与启动时刻),别的进程的号在此不存在。修订(PDF):`Doc` 记着读到时的历史标记(标记相同即文档相同,撤销回到原标记),指向页、对象、位置的请求带上它,worker 在动内核之前核对,不符即以 `stale:` 拒绝;整份文档的请求(页数、保存、撤销、元数据、附件列表与添加、保护等)带“任意”;按键读取或移除附件带标记。ElyPDF 的编辑在排队时记下当时的句柄,落地后按新标记换句柄,过期的读取照旧按版本丢弃(画不出的块不因过期记为失败),过期的编辑提示“文档已改动,未执行”:排在别的编辑后面的编辑因此被拒(它对着的是前一个编辑落地之前的页面),再按一次即可。LOK 的修订(R2):worker 给每份文档记一个修订,会改文档的请求(键、鼠标按下与松开、拖动、文字、命令、粘贴、剪切、函数补全;不按键的鼠标移动不算)在执行前加一;按编号指向工作表、幻灯片、对象、参考线、制表位、行列的命令(参数里有 `Index`、`nTabNumber`、`TabNo`、`Part`、`Row`、`Col`、`Column` 或区域的四角,值不是 -1;-1 是新建)先发 `EXPECT` 带读到时的修订,不符即以 `stale:` 拒绝,内核不动;同一次读取得出的一组命令(对象列表的多选)只核对一次,在一次 worker 调用里依次执行(`Worker::run_all`、`LokView::run_all_at`、`Commands::run_all_in`)。`LokView::run` 拒绝带编号的命令,须经 `run_at`;外壳的 `run_in` 对带编号的命令用 `Host::read_at`(ElyTable、ElyShow 为其读取时同一次 worker 调用里取的 `Worker::now`,ElyDoc 为视图所见:推送的事件批次带修订,每个输入落地后视图另取一次修订);`Tabs::read_in` 也给读取闭包这个句柄(ElyPDF 为视图当前的句柄)。ElyTable、ElyShow 在视图所见修订新于其读取时重读,所以键入之后按编号的命令不会一直过期;记着编号的对话框(删除与重命名工作表、删除重复项、幻灯片备注栏键入期间)在读取时记下修订,确认按它执行(`Commands::run_on`),其间文档结构变了即拒绝;ElyDoc 属性栏按视图所见的修订发(删除所示的制表位)。输入失败时视图照样取回修订(请求在执行前已计数),之后的编号命令不会一直过期。过期的拒绝提示“文档在此间已改动,请重试”(`office.error.changed`)。MuPDF 打开中途(加锁检查、日志、修复)失败即释放已打开的文档与修复副本(原先每次失败留下整个文档;MuPDF 的存储另有 256 MB 上限的缓存,满后不再增长)。FFI:MuPDF 每个调用在垫片的 `fz_try`/`fz_catch` 里,返回码加错误信息;LibreOffice 只经 LOK 的 C 接口;worker 崩溃时客户端报出退出状态。工作进程:LOK 随应用窗口启动(启动即开空白文档),MuPDF 在打开第一个 PDF 时启动;不建进程池。后台:Git、OCR、缩略图、文件夹搜索、导出与系统打开都在后台任务里。不建设 §17.4 所列设施(无 RPC 服务发现、无常驻服务、Git 只有一个内核、Markdown 只有 Milkdown 与 CodeMirror)。
|
||||
- 2026-10-08:配置(C18.1,§18.1)。通用偏好在 `<本机数据目录>/ElyOffice/settings.json`,各应用的在其数据目录的 `settings.json`;都经 `ely_platform::settings::Store`:带 `version`(本构建为 1;更新的版本或读不通即报错,不覆盖),写入在同目录的 `.settings.lock`(`ely_platform::lock::FileLock`,flock 或 LockFileEx,由 ely-files 移来共用)下先重读、只改所写的键、未知字段原样留下,临时文件 fsync 后原子替换;多个应用同时写不丢字段(单测八路并发)。通用字段(`settings::Shared`,类型不对即报错):外观(跟随系统、浅色、深色)、语言(跟随系统、English、简体中文;下次启动生效,设置里写明)、动效(跟随系统、减少、完整)、跟随 Omarchy(只在有 Omarchy 时显示)、作者名(空即账户名;`user::name` 先取它,ElyPDF 的批注、LOK 的批注与修订都用它)。环境变量 `ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION`、`ELY_LOCALE` 仍优先。`ely_ui::settings` 启动时读入(读不出即用默认值、记下原因),以 `notify` 监视文件夹,别的应用写入后即刻重读:外观、Omarchy、动效当场换,作者名经 `observe_global` 在后台交给 LOK worker(`set_author`)。设置窗口(应用菜单“设置…”,Cmd+,,每个应用一个)用组件库 `SettingsLayout`、`SettingsSection`、`SettingsRow`、`ThemeSelector`(组件 489ef31 起三项文字进内建中英目录)、`SegmentedControl`、`Switch`、`forms::Input`:通用;Doc、Table、Show、PDF 另有“文档”(作者名,失焦或回车写入);ElyTree 另有“Git”(Git 程序,空即随带的;写入前在后台核对 2.40 或更新,不是即在框下以 Banner 说明、不写;`ely_platform::git::locate` 次序为 `ELY_GIT`、此设置、随带的,版本核对移入 `ely_platform::git::system_version`,下次启动 ElyTree 生效)。文件读不出时窗口上方以 Banner 写明原因,改动不保存。作者名与 Git 程序每次提交都编号,写入经 `Store::set_if` 在文件锁下核对仍是最新的提交才落盘(等锁期间改了主意,旧写入作废);写入失败另以 Banner 写明,键入的值留着可重试;作者框在未编辑时跟随别的应用写入的名字。版本核对先看 `--version` 的退出状态(`git::version_of`)。各应用主窗口的位置、大小与最大化、全屏存在应用的设置里(`window`,停止移动 0.5 秒后写),启动时所记之处的中心仍在某块显示器上才用。未验证:Windows 与 Linux 的锁与窗口位置。
|
||||
- 2026-10-08:进程与状态核对(C17.1,§17.2、§17.4)。帧(`ely-ipc`)由长度、标签、请求号与内容组成:客户端每个请求一个递增的请求号,worker 的回复带回同一个,不符即报错(`check_reply`;原先只靠一问一答的次序配对,丢一个回复即整串错位而不自知);LOK 主动推送的事件请求号为 0(`UNASKED`);PDF worker 的版本戳包含 `ely-ipc` 的源码。帧超过上限(64 MB)发送前即报错,什么也不写;worker 的结果超限时回一个错误,worker 不退出。LOK 的输出读不下去(坏帧、提前结束)时,原因经回复通道交给等待的请求,客户端停下并回收 worker 再报出(原先请求一直等进程结束)。会话:LOK 每次载入一个会话号(递增,原先用内核文档指针,释放后的地址被下一份文档复用,旧句柄读到新文档、事件被当作已关的丢掉);PDF 的 `Doc` 带打开它的 worker 会话,客户端发送前核对,别的 worker 的旧句柄被拒;worker 的文档号带进程前缀(进程号与启动时刻),别的进程的号在此不存在。修订(PDF):`Doc` 记着读到时的历史标记(标记相同即文档相同,撤销回到原标记),指向页、对象、位置的请求带上它,worker 在动内核之前核对,不符即以 `stale:` 拒绝;整份文档的请求(页数、保存、撤销、元数据、附件列表与添加、保护等)带“任意”;按键读取或移除附件带标记。ElyPDF 的编辑在排队时记下当时的句柄,落地后按新标记换句柄,过期的读取照旧按版本丢弃(画不出的块不因过期记为失败),过期的编辑提示“文档已改动,未执行”:排在别的编辑后面的编辑因此被拒(它对着的是前一个编辑落地之前的页面),再按一次即可。LOK 的修订(R2):worker 给每份文档记一个修订,会改文档的请求(键、鼠标按下与松开、拖动、文字、命令、粘贴、剪切、函数补全;不按键的鼠标移动不算)在执行前加一;按编号指向工作表、幻灯片、对象、参考线、制表位、行列的命令(参数里有 `Index`、`nTabNumber`、`TabNo`、`Part`、`Row`、`Col`、`Column` 或区域的四角,值不是 -1;-1 是新建)先发 `EXPECT` 带读到时的修订,不符即以 `stale:` 拒绝,内核不动;同一次读取得出的一组命令(对象列表的多选)只核对一次,在一次 worker 调用里依次执行(`Worker::run_all`、`LokView::run_all_at`、`Commands::run_all_in`)。`LokView::run` 拒绝带编号的命令,须经 `run_at`;外壳的 `run_in` 对带编号的命令用 `Host::read_at`(ElyTable、ElyShow 为其读取时同一次 worker 调用里取的 `Worker::now`,ElyDoc 为视图所见:推送的事件批次带修订,每个输入落地后视图另取一次修订);`Tabs::read_in` 也给读取闭包这个句柄(ElyPDF 为视图当前的句柄)。ElyTable、ElyShow 在视图所见修订新于其读取时重读,所以键入之后按编号的命令不会一直过期;记着编号的对话框(删除与重命名工作表、删除重复项、幻灯片备注栏键入期间)在读取时记下修订,确认按它执行(`Commands::run_on`),其间文档结构变了即拒绝;ElyDoc 属性栏按视图所见的修订发(删除所示的制表位)。输入失败时视图照样取回修订(请求在执行前已计数),之后的编号命令不会一直过期。过期的拒绝提示“文档在此间已改动,请重试”(`office.error.changed`)。MuPDF 打开中途(加锁检查、日志、修复)失败即释放已打开的文档与修复副本(原先每次失败留下整个文档;MuPDF 的存储另有 256 MB 上限的缓存,满后不再增长)。FFI:MuPDF 每个调用在垫片的 `fz_try`/`fz_catch` 里,返回码加错误信息;LibreOffice 只经 LOK 的 C 接口;worker 崩溃时客户端报出退出状态。工作进程:LOK 随应用窗口启动(启动即开空白文档),MuPDF 在打开第一个 PDF 时启动;不建进程池。后台:Git、OCR、缩略图、文件夹搜索、导出与系统打开都在后台任务里。不建设 §17.4 所列设施(无 RPC 服务发现、无常驻服务、Git 只有一个内核、Markdown 只有 markdown-rs)。
|
||||
- 2026-10-08:应用之间的连接(C16.1,§16;首版只做六个应用之间的三项,邮件与 Agent 的三项随第二批)。启动别的 Ely 应用(`ely_platform::apps`):先找与本应用同一安装或构建的同级(`.app` 旁、Windows 的 `Ely Office\名称`、开发构建同一目录),再找 macOS 登记过的(按包标识 `AppId::bundle_id`,`NSWorkspace`);`.app` 一律经 `open -a` 交给 Launch Services,正在运行的应用经其打开请求收下文件(不另起一个),其余直接启动。应用切换:每个应用的应用菜单有“打开 Ely 应用”子菜单,只列已安装的其他 Ely 应用(`commands::switch_to`,八个无参数动作),启动失败即提示原因。导出后查看:导出、提取、拆分、删除标记内容、清理、保护、签名与附件另存成功后,提示带“打开”:PDF 在已安装的 ElyPDF 打开(作为新标签,不当作原编辑会话),否则与其他文件、文件夹一样交系统默认程序(gpui `open_with_system`);ElyPDF 打开失败即提示原因,不改交系统。ElyMark 导出缺图、图或公式时的警告同样带“打开”。Markdown 查看版本:ElyMark 状态栏的 Git 项把文件本身交给 ElyTree,ElyTree 收到文件即打开其所在仓库并显示该文件的历史(`Tree::open_path` 按文件所在文件夹开仓库,再以仓库内路径 `file_log`);不自动提交,不建仓库;未安装 ElyTree 时提示从发行包安装。真窗口(macOS):`mark-output.sh` 核对 ElyTree 打开该文件的历史、导出警告的“打开”在 ElyPDF 打开 PDF;`release-macos.sh` 从 ElyDoc 的应用菜单启动 ElyMark。AppKit 自己的窗口恢复关掉(`startup::no_window_restore`,`ApplePersistenceIgnoreState`):Ely 自己恢复会话;原先应用被强退后再启动,AppKit 先弹模态“是否重新打开窗口”,访达交来的文件被挡在后面(打包测试实测)。窗口测试的 `seen` 不看系统菜单(苹果菜单的“最近使用的项目”会列出别处打开过的同名文件)。未验证:Windows 与 Linux 的同级查找与系统打开;系统默认程序打开(会在本机弹出别的应用,未自动测)。
|
||||
- 2026-10-08:ElyMark 的单行 `$$…$$`(用户以 Pandoc 示例发现):remark-math 把它读成行内公式,序列化只写一个 `$`,与原文不同,整段退成原文块。现按原文记下每个行内公式的 `$` 数(`math.js`),两个及以上以显示模式排出(同 GitHub),写回照原数;导出同样以显示模式。Pandoc 专有的表格、定义列表、行块不是 CommonMark/GFM,照 §14.3 以原文块保留(`---- ----` 在 CommonMark 里是分隔线)。
|
||||
- 2026-10-08:ElyMark 书写视图、输出、Git 配合、大文件(C14.7,MARK-09、11、13、14,PERF-07)。书写视图:“视图”菜单的专注模式(⇧⌘F,收起标签条、左栏与状态栏;可视模式里光标所在的顶层块之外淡到 0.35,源码里当前行之外同),打字机滚动(插入点所在行保持在视口中部;上下各留半屏,首尾行也能居中),正文宽度窄、中、宽(36、48、64rem),字体无衬线、衬线、等宽,文字大小(⌘= ⌘- ⌘0,70% 至 200%,每档 10%)。全部在页面里以 CSS 实现(`look.js`:根元素的 data 属性与 `--ely-text-scale`),Markdown 不变;设置存在数据目录的 `look.json`(读不出即报错并用默认值),菜单随之打勾,启动时恢复;淡入淡出遵守系统的减少动效。组件库的 `ZenMode` 不用(见缺口)。输出(“文件›导出…”⌘E 一个对话框选 HTML、HTML 与资源文件夹、PDF、纯文本;“打印…”⌘P):页面以 unified(remark-parse、remark-gfm、remark-math、remark-rehype、rehype-katex、rehype-stringify,版本写死)把编辑器此刻的全文(与懒挂载、大文件无关,不截断)渲染成 HTML,Mermaid 用浅色主题画成 SVG(画不出的照原代码块并在导出后提示);front matter 不进输出。HTML 内嵌导出样式表(构建时单独产出 `export.css`:KaTeX 样式与 woff2 字体内嵌、浅色排版)与图片(文档相对路径、百分号转义、file URL 读成 data URL;网址照原样;读不到的图片照原链接并提示);HTML 与资源文件夹写 `名称.html` 与 `名称_files/`(样式表与图片副本,同名图片按序号区分;文件夹已在即拒绝,写坏即删去);纯文本以 hast-util-to-text 按块换行,公式写 TeX;PDF 先把同一份内嵌 HTML 暂存到工作目录,在不显示的 CEF 视图里由 Chromium 打印(`WebView::print_pdf`,结束经 `Event::Printed` 报回),再交到所选位置;打印同一份 PDF 交系统打印面板(`ely_office::print_later`)。导出都经 `ely_files::export`(不覆盖打开着的文件)。富文本复制用 Chromium 自己的复制(剪贴板带 HTML)。Git(MARK-13,不做第二套客户端):Git 的查找与运行环境移入 `ely_platform::git`(`locate`:`ELY_GIT`,应用旁,ElyTree 包内的 Git,本构建的 `engines/git`;`command`:只读不抢锁等同一套环境变量),ElyTree 照用;ElyMark 在后台对当前文件问 `rev-parse --show-toplevel` 与 `status --porcelain=v2 --ignored`,状态栏显示未改动、有改动、未跟踪、已忽略、有冲突(问不出即“未知”,提示里是原因;不在仓库里不显示),按一下以仓库根打开 ElyTree(`ely_platform::apps::open`);切换标签、每次保存(指纹变化)、外部改动与窗口回到前台时重问。大文件(MARK-14):超过 2 MiB 的文本以源码打开,上方提示条写明大小与“可视与预览已关闭,保存保留全部内容”,切到可视或预览即提示不切;保存与导出都是全文。PERF-07 实测(macOS,release):1 MB 文档自启动到可视可编辑 1.4 秒(首次冷启动 2.0 秒);4 MB 在可视下约 5 秒,故按源码打开。真窗口(macOS,`tests/scripts/mark-output.sh`):Git 状态未改动→键入自动保存后有改动→按下打开 ElyTree 于该仓库;专注模式收起标签、存入 `look.json`、重启仍在,文件不变;四种导出的内容(KaTeX、SVG、内嵌图片、全文;资源文件夹的样式与图片;PDF 以 pdftotext 读出文字;纯文本含 TeX);打印生成 PDF;3.3 MB 文件以源码打开、⌘1 不切、末尾编辑后全文保存、纯文本导出含最后一行;`ELY_CLIPBOARD_TEST=1` 时可视全选复制带 HTML。`mark-content.sh` 的 look 步核对淡出、窄列、衬线与插入点居中。去掉专注收起、图片内嵌、大文件阈值、Git 状态或截断文本导出时各自失败。窗口测试须在英文输入法下运行(中文输入法会把模拟键入收进组字)。导出时每张图定为一个 `src`(去掉 `srcset`、`sizes` 与 `picture` 的 `source`,无 `src` 时取 `srcset` 的首个地址);公式的 KaTeX 错误随导出提示(行号与 KaTeX 的说明),不当作完整导出。引用正文的提示走外壳的 `toast_unlogged`,日志只记计数与行号;组件库 28a6bba 起 `Toaster` 只记提示的编号与级别,不记标题。未验证:Windows 与 Linux;Windows 的 Git 路径在 ElyTree 包内的位置。
|
||||
- 2026-10-07:ElyMark 外部变更、自动保存与会话恢复(C14.6,MARK-08、12,§6.2、§6.3、§14.4,QA-22)。监视:一个 `notify` 监视器盖住各标签文件所在的文件夹(不递归),200 毫秒内的一串改动合成一次查看;按会话指纹(长度与修改时间)比对,自己的保存不触发。文件改了:读到稳定的字节(读前读后指纹相同,最多五次),无未保存内容(含页面还没送回的编辑)即原地换成磁盘的文字(`ely.refresh`:插入点所在的文件行与滚动尽量保持,时间线从这份文字重新开始),会话重载指纹并以新文件身份登记;有未保存内容即不动磁盘与编辑器,状态为“外部已改”,自动保存停止,上方提示条“比较…”。比较:组件库 `git::regions` 对“上次载入或保存时”的文字、你的与磁盘上的三方合并,`ThreeWayMerge` 逐个冲突选边(编辑器的文字先落定;比较期间磁盘再改即重读重比,选定前核对所读内容的指纹,不符即提示再比较一次、不写入),可“用磁盘上的”“保留你的”(立即覆盖写入)或冲突都选完后“用合并的”(磁盘的版本成为新的基底,合并结果作为一步可撤销的编辑,随后自动保存)。路径不见了:等 300 毫秒(原子替换的空档)仍不在,即在原文件夹里按文件身份(设备与 inode,不认符号链接)找改名后的文件(`Sessions::renamed_in`),找到即标签跟过去(与 F2 改名同一条路);找不到即为已删:内容留着,提示条“另存为…”,不再自动保存、不重建文件。读不出(权限等)或监视器出错:提示条写明原因与“再查一次”;“视图›检查磁盘上的文件”随时手动查看(网络目录等不保证有事件)。自动保存(§6.2):有路径的文件停手 1 秒后保存(`ely_files::PolicyEngine`),外部改动或删除后停止,选定处理后重新开始;失败照常留为未保存。会话(MARK-12):数据目录的 `session.json` 记各标签的路径(未命名为空)、名称、模式、插入点所在的文件行与滚动;每 10 秒有未保存内容且有变化的标签写一个恢复点(`ely_files::Recovery`,每标签一个文件夹,20 点 7 天);退出(Cmd+Q 或关窗)开始时先记下当时的标签;`session.lock` 写进程号,正常退出删去它与全部恢复点;下次启动锁还在且进程已不在即为崩溃(`ely_files::alive`)。启动时打开记下的文件,标签显示后换成记下的模式与位置;崩溃后的未保存内容从最新恢复点放回,一律开成未命名副本(有路径的文件照磁盘打开,从不先覆盖原件,§6.3;原件已不在也照样开副本并提示哪个文件没能打开)、标为未保存,并提示“恢复了某时未保存的内容;撤销历史未恢复”(§6.3 区分内容与历史)。显式“不保存”后正常退出,下次不当作崩溃,未命名的内容不回来。真窗口(macOS,`tests/scripts/mark-outside.sh`):原子替换后干净标签换成新文字、键入后自动保存;未保存时外部写入不被覆盖,比较三栏、保留你的写回;互不冲突的两处改动合并后都在;外部改名后标签名跟随、保存写到新名、旧名不重现;改成无权限读即提示、恢复权限后“再查一次”重载;删除后不再自动保存、不重建;退出后重开回到源码模式与原来的行;未命名文档写过恢复点后 `kill -9`,重开内容回来且关闭时问是否保存,选不保存后再开不回来。去掉干净重载、改名跟随、删除时停自动保存、读错提示、崩溃内容、退出时记下标签或恢复的位置时各自失败。启动时先显示可视再换到记下的模式(一次多余的绘制,已知)。启动参数给了文件时只开它们与崩溃恢复的副本,不连带上次的标签。“保留你的”以所比较的指纹为基线(`Session::rebase`)走普通保存,之后的外部改动照样冲突;自动保存撞上外部改动时交给比较(`Host::conflicted`),不弹外壳的冲突框;外壳在询问取消、保存面板取消、保存失败、冲突询问取消时都调 `Host::close_cancelled`,关闭标签时调 `Host::tab_closed`(ElyMark 即刻删其恢复点并更新清单);每个标签只在首次显示时恢复一次;缺列的位置读作 0。组件库 f756043:脏标签与非当前标签的关闭按钮透明而非隐藏,辅助技术可按。外壳冲突询问的“保留我的”以此刻磁盘的指纹为基线走普通保存(导出期间再改照样冲突;文件已不在时直接写),“从磁盘重新载入”先结束挂起的关闭;退出途中首次保存的标签以新路径记入清单。取消退出(未保存询问点取消)后清单照常跟随(`Host::clLine truncated
|
||||
- 2026-10-07:ElyMark 编辑工具与附件(C14.5,MARK-06、07)。查找替换(Cmd+F):组件库 `FindWidget` 盖在页面右上,查询与替换是两个 `TextInput`,带区分大小写、全词、正则三个开关;可视用 `prosemirror-search`,源码用 `@codemirror/search`;总数与当前序号由页面按不重叠的命中报回;正则写错时框内显示 JS 的原话(按两个编辑器所用的 `gmu` 标志编译);空的或无效的查询不执行查找命令(CodeMirror 对它会打开自带的面板);零宽命中按码点前进(落在代理对中间时 Unicode 正则会往回答,原先反复收集同一处直至页面崩溃);预览不替换;换模式或换标签后按同一查询重查。查找框整窗一个,各标签的页面各记其查询:下一处、替换每次都带上框里此刻的查询与替换文字,标签一换即按框里的重查。查询变了从当前命中的起点往后找,键入替换文字不移动命中。替换框回车替换一处。缩进:源码 Tab 与 Shift-Tab 为 `indentWithTab`;可视在列表里 Tab 下沉、Shift-Tab 上提,不在列表时吞掉;代码块同源码。WebView 在自己的键位上下文绑定 Tab 与 Shift-Tab 交给页面(组件库 K02 起更深的上下文先得),焦点不离开编辑器。列表续写:可视为 Milkdown 自带,源码为 lang-markdown 的回车续写。“表格”菜单:插入表格(3×3)、上下加行、左右加列、删行、删列(prosemirror-tables);插入后插入点落在新表第一格(Milkdown 在段末插入时把插入点留在段里,之后的行列操作落空);插入点不在表格里、或在源码与预览里即提示。代码高亮见 C14.3。附件:拖放图片文件或粘贴剪贴板里的 PNG,复制到文档旁的 `assets/`:拖放保留原名、重名加序号,粘贴名为 `pasted-毫秒.png`;都以 `create_new` 占名,从不覆盖已有文件,写坏即删去半成品(`ely_files::import_attachment` 原先先查后拷,其间出现的文件会被覆盖);在插入点写入相对链接,名称按百分号转义,源码里替代文字的方括号与反斜杠加转义;复制期间文档另存到别的文件夹即不写链接,提示图片留在原文件夹的 assets;未命名文档、预览与非图片文件拒绝并说明。粘贴经 `WebView::intercept_paste`:宿主先看剪贴板,图片自己处理,其余照常交给页面。macOS:CEF 离屏输入不带 AppKit 编辑命令,而 Blink 在 macOS 上只凭这些命令处理 Cmd+方向键与 Cmd+Backspace(Option 组合 Blink 自己处理),可视编辑器里这些键原来不动;`ely-web-content` 的键盘处理器(`edit.rs`)在页面没处理这些键时照 AppKit 标准键位以 `Selection.modify` 移动或扩选到行首行尾、文首文末,Cmd+Backspace 与 Cmd+Delete 删到行首、行尾;CodeMirror 自己处理的键不经此。组件库 8f4729c 与 aa0c99d:开关按钮(`ToggleButton`、`ToggleGroup` 的各项)在 AX 树里是按钮,名为其文字或提示,带按下状态。真窗口(macOS,`tests/scripts/mark-tools.sh`):可视全部替换,源码正则替换,Tab 与 Shift-Tab,列表续写,Cmd+Down 后插入表格、加行加列、删列,拖放图片(自带的拖动源)复制到 assets 并写入转义的链接;`ELY_CLIPBOARD_TEST=1` 时粘贴图片同样落地;复制被拖住(FIFO)时经菜单另存到别处,不写链接;两个标签各设查询后回到前一个全部替换按框里的;键入替换文字后替换一处落在原命中;源码拖入 `a]b.png` 写成 `![a\]b](…)`。去掉 AppKit 键、正则、Tab 绑定、插入点修正、文件夹核对、起点查找、标签跟随或转义时各自失败。`tests/scripts/comments.py` 另查 Swift 测试助手与页面的 JS 源码。本机有全局快捷键占用 Cmd+Shift+S 与 Cmd+Shift+Z(空白 AppKit 窗口也收不到),所以测试经菜单另存。未验证:Windows 与 Linux。
|
||||
- 2026-10-07:ElyMark 导航与搜索(C14.4,MARK-05、MARK-10,MARK-01 的打开文件夹)。左栏三页(分段控件):文件、大纲、搜索。打开(面板可选文件与文件夹,启动参数同)文件夹即在后台按其真实路径列出其下的 Markdown 文件(`folder.rs`:`.md`、`.markdown`,不进点开头的文件与文件夹,不跟随链接,只在所开的文件夹里,不扫整盘),以组件库 `lists::FileTree` 显示(只画可见行,可过滤),选中按回车或双击打开;窗口回到前台、改名之后即重列,列表变了即重搜文件夹。没开文件夹时文件页是最近列表(`ely_files::recent`:数据目录的 `recent.json`,新的在前,留 20 个,读不出即报错且不覆盖;ElyTree 的同一套移入此处共用),打开成功的文件与列出的文件夹记入,按真实路径(同一文件只一条);标签的路径变了(改名、另存为)即记入新路径,旧路径已不存在即移出。大纲:页面在显示后与每次送出全文时报出当前编辑器的标题(可视与预览取文档里的标题节点,源码取 CodeMirror 的语法树,ATX 与 Setext),以 `lists::Outline` 显示,原文块里的标题也在内(以 remark 读其源码,引用、列表中的标题同样列出,跳转落在标题文字上),选一项即把插入点放到该标题并把键盘焦点交给文档(预览滚到该处)。搜索:本文件或文件夹(打开了文件夹才有),按字面、不分大小写逐行找,打开着的文件按其当前文字(含未保存的),其余读盘,读不出或不是 UTF-8 的文件以“某文件未能搜索”的行列在结果最前(带原因),最多 500 条;本文件的结果随当前标签:切换标签即重搜,较早的搜索迟到即弃;结果列出行文字与“文件:行”,选中回车即打开(已开即转到,按真实路径认标签)并把插入点放到该行的那一处:源码定位到该行的命中,可视按写出时每个块在全文里的位置(`preserve.js` 的写出同时给出各块的区间)找到该行所在的块,再按块内此前命中的个数落到块中对应的命中(此前的行先经 Milkdown 解析,按编辑器所见的文字计数,链接地址等不算,与块内查找同按不重叠计;原文块照源码计数;该行所见文字没有查询词时——命中只在链接地址等处——落在该行开头);行号自文件开头算,可视与预览先减去 front matter 的行数,落在 front matter 里即滚到它。页面未显示完就要求的定位等显示后再做。多标签由外壳。真窗口(macOS,`tests/scripts/mark-places.sh`):文件树不列 .txt、点选回车打开,大纲跳到二级标题后键入落在标题里,文件夹搜索找到两个文件的行、不搜 .txt、选中结果打开并在该行键入,最近列表重启后还在、从中打开文件夹。组件库 dabc1eb 起树与树行有无障碍角色(树、树项,名称、层级、选中、展开);大纲项仍没有(测试按位置点选)。未验证:Windows 与 Linux。
|
||||
- 2026-10-07:ElyMark 编辑模式、文件与语法(C14.3,MARK-01 至 04,§14.3)。模式:可视、源码、预览,“视图”菜单与 Cmd+1/2/3,状态栏右侧分段控件;每个标签各记其模式,页面回落到源码时标签随之。切换在页面里进行(`ely.mode`):先取当前编辑器的全文并立即送出待送的全文,再以同一文字显示新模式;每次切换都新建目标模式的编辑器(不再保留隐藏的编辑器)。本文件只有一条时间线:带着编辑离开一个模式时,把离开时的全文记入时间线;编辑器自己的撤销用尽(各看其撤销深度,Milkdown 的历史键位排在我们的之后)时再按撤销,回到时间线上一条全文、以当前模式重建(另一段的编辑作为一步撤销),重做用尽时向后走;只有真正的编辑会截掉时间线上其后的部分,撤销、重做的跳转不算编辑;离开时编辑器还有可重做的步骤,就把全部重做后的文字记在时间线前方,回来后重做即到那里。模式请求按序号只认最新的:编辑器构建期间再要的模式记下,构建完且仍是最新请求时显示它,此后的新请求清掉它;较旧的显示回报回来时序号不符即退出,不碰排队的请求。预览每次新建、只读(正文与代码块)。切换本身不改文件。文件:新建(空的未命名 `untitled.md`,保存走另存为)、打开、保存、另存为(外壳)、重命名(F2,“文件”菜单):`commands::RENAME` 与 `commands::RENAMING`(含重命名的文件命令表),`document_root`/`set_document_menus` 改收文件命令表;ElyMark 的 `rename.rs` 在原文件夹里改名(只有它用,不放外壳),`ely_files::rename_new` 不覆盖已有文件(macOS `renamex_np` 加 `RENAME_EXCL`,Linux `renameat2` 加 `RENAME_NOREPLACE`,Windows `MoveFileExW` 不带替换;后两者未构建验证),名称须不含斜杠且扩展名为可保存的格式,未命名或正在保存的标签不改名,改名完成时标签须仍开着且仍是原路径,否则只记日志、不动登记;同文件登记记下打开时的文件身份,原路径已不存在而身份相同即为同一文件(改名间隙里打开新名字会转到原标签),改名后以 `Sessions::track` 迁移登记(不关再开);外壳每次保存成功后也以它更新登记的路径与身份(原子保存换了文件);标签名与 `Session::renamed` 的路径随之,内容与指纹不变。文件 URL:Windows 的驱动器路径写成 `file:///C:/…`、UNC 写成 `file://主机/…`,去掉 `\\?\` 前缀,围栏反向换回 Windows 路径再取真实路径(字符串转换有单测,Windows 上未运行)。CEF 的请求处理移入 `fence.rs`(`handlers.rs` 原超过 500 行)。文档旁的图片:页面 `<base>` 设为文件所在文件夹,`WebView::allow_images` 让该文件夹只放行图片请求(资源类型为图片;导航、脚本仍只限内容文件夹),另存为或改名后随文件移动(已显示的相对图片不重载,已知限制);Mermaid 的包按脚本自身位置载入,不受 `<base>` 影响。Milkdown 7.22.2 的图片解析把无标题图片的 `null` 标题交给要求字符串的属性,ProseMirror 拒绝、Milkdown 记错误后丢掉整张图;`image.js` 改为空字符串。实测(macOS,`tests/scripts/mark-modes.sh`):文档旁的图片画出(截窗口量色),三种模式往返不改文件,预览不收键入,可视里键入后经预览回来撤销得回原样,源码里的改动回到可视并保存,改名到已有文件被拒、已有文件不动,改名到新名后文件移动、继续编辑保存到新文件,新建打开空的可视文档(读外壳写下的 `untitled.md` 须为空),可视键入、源码键入、回可视后两次撤销回到两者之前、重做回来(撤销跳转当作编辑、丢掉时间线时失败)(去掉预览只读、去掉保留编辑器、去掉旧编辑器的链接时测试失败);`mark-content.sh` 在同一段脚本里连要两种模式,最后显示后者(去掉记下的模式时失败);可视已显示、其回报未答时再要源码,最后为源码(不核对最新请求时失败)。“直接打开文件夹”随文件树在 C14.4。未验证:Windows 与 Linux。
|
||||
- 2026-10-07:ElyMark 文件会话与原文保留(C14.2,§14.3,QA-21)。ElyMark 用 `ely-office` 的外壳(标签、打开、保存、另存为、关闭询问、冲突),引擎 `Mark`(`apps/ely-mark/src/engine.rs`):载入读字节,UTF-8 带不带 BOM 都可,UTF-16 与非 UTF-8 即报错并指出字节位置(`text.rs`);编辑器拿到去掉 BOM、CRLF 换成 LF 的文本;写出时文本与载入时相同即原样写回原字节,否则补回 BOM 并按载入时多数的换行(CRLF 多于单独 LF 即 CRLF;混用换行的文件编辑后统一成多数的那种,已知限制)。外壳的 `Engine::open` 改为收窗口、返回 `io::Result`(网页视图要窗口,页面开不了即为打开失败)。每个标签一个页面;页面每次改动立即报 `{"kind":"changed","version"}`,停手 300 毫秒后报该版本的全文 `{"kind":"text"}`;外壳的“已落定”即最新改动的全文已到,保存等它;历史标记为全文的长度与散列,撤销回原样即为未修改;外壳保存时先写出再读标记,两者之间页面可能送来新全文,所以写出时记下所写文字的标记,紧接的读标记交出它,之后的编辑仍算未保存。可视模式保留原文(`preserve.js`):照 Milkdown 自己的 remark 流程(`parse` 加 `runSync`)读出原文各顶层块的位置,与编辑器的顶层块一一对应(数目不同或有块无位置即不能对应;空文件 Milkdown 仍有一个空段落,照空处理)。每个顶层块单独序列化,与原文逐字相同的才是可视块,否则换成原文块(`ely_raw`:等宽、可编辑的纯文字,写出即其文字,不进撤销历史的一次替换);块间 Milkdown 不显示的原文(如链接定义:Milkdown 把引用链接内联、删去定义)也成原文块,所以没有藏起来的原文。序列化的标记照该文档自己多数的写法(`style.js`:无序与有序列表标记、强调、加粗、代码围栏、分隔线、Setext;以 remark-parse 统计后设 `remarkStringifyOptionsCtx`),写法一致的文档几乎全为可视块,与多数写法不同的块成原文块。写出时按块做 Myers 差异(`diff.js`;ProseMirror 未改的块是同一对象,否则按内容比较),未改的块与块间原文逐字照抄,只有改动的块用 Milkdown 序列化。YAML front matter 不进可视编辑器,以原文显示在上方,写出时原样接回。不能对应的文档直接以源码模式打开(可编辑,无损),并报 `fallback`。打开后插入点在文档开头。页面每次载入是一代(应用计数,随 `ely.show` 传入),回报带代数,别的代的回报丢弃;载入时版本计数归零,重载后显示最后收到的全文。故障视图按“重新载入”后焦点回到页面(原先留在消失的按钮上,键入无处可去)。普通 Markdown 读文件的权限错误不交外壳当作加密文件(外壳把 PermissionDenied 当作要密码),照系统原话报出;外壳打开失败另写日志。实测(macOS,`tests/scripts/mark-session.sh`):BOM、CRLF、front matter、Setext 标题、`1)` 列表、HTML 块、`~`删除线、硬换行、链接定义与 `[[...]]` 的文件,不改保存逐字节相同;在首段键入一字保存,除该段多一个字外逐字节相同(整篇序列化、去掉 BOM 时该测试失败);段落里的 `[[...]]`、引用里的 Setext 与 `[[...]]`、只有链接定义的文件都以原文块显示,键入一字只多一字;在链接定义前向前删除不移动、不修剪它;渲染进程被杀、按重新载入后再键入的字照样保存;无读权限的文件报“Permission denied”、不问密码(不用原文块、不按代丢弃回报时测试失败)。未验证:Windows 与 Linux;模式切换与撤销(C14.3)。
|
||||
- 2026-10-07:ElyMark 内容包(C14.1,§14、§17.3)。网页源码在 `apps/ely-mark/web`:版本全部写死,`package-lock.json` 锁定;`scripts/build-mark-content.sh` 以 `npm ci --ignore-scripts` 安装、esbuild 打成经典脚本(file:// 下模块脚本被 Chromium 拦下)到 `engines/content/ely-mark`,附 `THIRD-PARTY-NOTICES.txt`(按打进包的每个包取其许可文件;没有许可文件的只许 MIT,以清单的作者与 MIT 原文写出,构建时列出)。包:Milkdown 7.22.2(commonmark、gfm 含脚注与任务、history、代码块组件)、CodeMirror 6(代码块与源码模式,语言取 language-data,全部打进包)、KaTeX 0.19.0(字体随包)、Mermaid 12.1.0(单独的 `diagram.js`,首次遇到图时才载入;`securityLevel: strict`,不在页面上留错误图)。数学按 Crepe 的做法(remark-math,块公式为 LaTeX 代码块)。只渲染文档里保存的源码;公式与图出错时显示错误(公式带位置,图带行号)与原文,不清空代码块。页面接口 `ely.show(markdown, mode, texts)`,mode 为 visual、source、preview;每次显示一个新容器与序号,较早的显示若后完成即销毁其编辑器、不报告,页面上始终只有最后一次的编辑器;预览时正文与代码块都只读;显示后经 `elyQuery` 报 `{"kind":"shown"}`。颜色全部取 `--ely-*`(CodeMirror 的高亮、Mermaid 的主题变量),随系统深浅色:Milkdown 把预览净化后拷进页面(不保留我们给的元素),且每次调用给的回调都是新函数,所以 Mermaid 预览先返回带编号的空占位,绘制结果只在该占位仍在页面上时经回调落下(改语言、改文字、卸载都换掉占位,旧结果即弃);绘制在全页一条队列里依次进行,每次绘制带代次,调用 Mermaid 前与落下前都核对属主、占位与代次,失效即跳过(编辑器销毁后排着的绘制不再执行);`:root` 的样式一变,凡占位仍在页面上的图(含正在绘制的)都以新颜色重画,此前的结果作废,首次落下的就是新颜色。记录归各编辑器所有,编辑器销毁即释放,占位已离开页面的记录在下一次绘制或换主题时清掉。Milkdown 把视口外的代码块换成占位(懒挂载),滚入视口时再建。界面文字由应用给(`mark.*`)。打包时 `bundle.sh` 把 `engines/content/<应用>` 放进 `Resources/content`,缺即失败;应用先找包内,再找本构建的 `engines/content`,都没有即报错。ElyMark 启动后显示一个空的可视文档(文件会话在 C14.2)。实测(macOS,`tests/scripts/mark-content.sh`):三种模式下标题、任务、表格、脚注、行内与块公式、代码块(逐块滚入视口,须可见、未被遮挡)、Mermaid 图(不小于 100×80)与两种错误,切换深浅色后图的颜色随之变,连续两次显示只留并只报后一次,截窗口背景色占比低于 0.97,KaTeX 字体已载入,无越界请求;`.app` 包内页面显示。未验证:Windows 与 Linux;树莓派上 2.5 MB 主脚本的载入时间。
|
||||
- 2026-10-07:ElyTree 分支、标签、合并、变基、冲突(C13.4,TREE-07 至 09,§13.3)。Git 一律以 `GIT_EDITOR=true`、`GIT_SEQUENCE_EDITOR=true` 运行(没有终端可开编辑器,合并与撤销照 Git 自己拟的信息);失败时 stderr 为空即报 stdout(合并的冲突写在 stdout)。左栏第三页“分支”:本地分支(当前在前)、远程分支、标签(新的在前),`for-each-ref` 读出(附注标签取所指提交与标签自身的说明,`origin/HEAD` 不算分支,上游已删即不显示领先落后),上方“新建分支…”“新建标签…”;选中一个,中间列出提交、日期、上游与说明,以及适用的动作:切换(远程分支为 `switch --track refs/remotes/名`,不被同名标签劫走)、合并到当前分支、把当前分支变基到此、在此新建分支、重命名、删除(`branch -d`,Git 拒绝时报原话)、强制删除(`-D`)、删除标签。历史页提交详情多出拣选、撤销提交(合并提交以 `-m 1`)、在此新建分支、在此新建标签。新建分支可选“并切换过去”(`switch -c`);标签有说明即附注标签(`tag -a -m`,签名照用户的 `tag.gpgSign`)。变基、删除、强制删除、删除标签、中止、跳过每次都先问(每个确认只对这一次,§13.3),合并、拣选、撤销、继续不问。进行中的操作按 Git 目录里的 `rebase-merge`/`rebase-apply`、`MERGE_HEAD`、`CHERRY_PICK_HEAD`、`REVERT_HEAD` 判定(每次状态重读时读,只取最新一次),中间上方的提示写明正在把谁并到谁(变基时“当前侧”是新基底、“另一侧”是正在重放的提交),给继续(`<操作> --continue`)、跳过(合并无此项)、中止。命令失败时,只有合并、变基、拣选、撤销,且 Git 的输出有以 `CONFLICT (` 起头的行、失败后索引仍有未合并条目(`ls-files --unmerged`),才提示“因冲突停下”;其余(如钩子拒绝、冲突中 `checkout --ours` 的钩子失败)对话框显示 Git 的原话。失败的报错带上 stdout 与 stderr 两者(拣选的 CONFLICT 行在 stdout,钩子多写 stderr)。分支与标签的名称取自完整引用名(`refs/heads/`、`refs/remotes/`、`refs/tags/` 之后),同名的分支与标签各归各的;合并与变基用完整引用。分支行内的切换与删除接到同样的入口(删除照样先问)。冲突文件:中间上方是三侧与 `ThreeWayMerge` 的块级选择,各块照工作树文件里 Git 写下的标记分组(不另算一套合并;标记宽度取 `check-attr conflict-marker-size`,照 Git 的 `strtol_i` 读,不是正整数即 7;行须恰为该宽度,长短不同的同形行照正文;只按 LF 断行,同 Git,正文里的独立 CR 不算行界),基底取自 diff3 标记,没有时取 Git 自己 `merge-file --zdiff3 --marker-size` 对三个索引段的结果(块数相同时,只填没有基底的块,diff3 里空的基底照空);下方是可编辑的结果(`CodeEditor`,初为工作树里的带标记文件);选一侧即在结果里解决对应的那一块标记(其余手改照留),结果里的标记数与未选的冲突数对不上时(已手改过)不再按块选,提示在结果里改;“保存”只写工作树文件(经同一仓库的写队列,与 Git 的写操作不重叠;落地时按仓库与路径记下;路径已成符号链接即拒绝,不顺着链接写),“标为已解决”只做 `git add`(须先保存;结果里还有标记时先问),两者分开(§13.3);另有“用当前侧的文件”“用传入侧的文件”(`checkout --ours/--theirs`),一侧没有该文件时还有“以删除解决…”(`git rm`,先问)。符号链接、子模块、任一侧含 NUL(Git 视为二进制)、不是 UTF-8 或缺少的冲突整份解决:写明情形,给“用当前侧的文件”“用传入侧的文件”(只给有该文件的一侧)、“标为已解决”与“以删除解决…”。重读时结果有未保存的手改即保留并提示。真窗口(macOS):合并冲突的提示条、三侧与按块取另一侧后结果更新。未验证:Windows 与 Linux;交互式变基(不提供)。
|
||||
- 2026-10-07:ElyTree 提交、历史、Blame(C13.3,TREE-05、06)。左栏分“改动”与“历史”两页(`navigation::Tabs`)。提交:改动页下方是 `git::CommitInput`(摘要与说明两个框,摘要满 50 变黄、72 变红),上面一行是 Git 设置里的身份(每次状态重读与窗口回到前台时重读,只取最新一次;`user.name <user.email>`,未设即写明,点它开身份对话框:姓名、邮箱(两个输入框以其标签为无障碍名),可勾“用于所有仓库”写 `--global`,否则写本仓库;空着即提示不写)与签名(`commit.gpgsign` 为真时写明以 `gpg.format` 签名,默认 openpgp),签名照用户自己的 Git 与工具,Ely 不另设。提交框按仓库各存一份(换标签时存下当前的、换上另一个的:摘要、说明、修改最近提交与其草稿),提交为 `commit --cleanup=whitespace -F -`(信息经标准输入,`#` 开头的行照留,用户的 `commit.cleanup` 不删它们,不经 shell),“修改最近一次提交”勾上即显示为勾选、读出上次的信息填入(读回前不能提交;先存下已键入的,取消勾选时放回;每次勾选或取消、换仓库都使在途的读取作废,读回时须仍是发起的仓库与那一次),提交成功后只在提交框仍是所提交的内容时清空(提交期间又键入的留着,别的仓库的提交框不动);钩子或签名失败时以对话框显示 Git 的全部原话,不只说失败。提交同其他写操作排队。历史:`log --topo-order --exclude=refs/stash --all`,每页 2000 个,滚到距末尾 20 行内读下一页;每次状态重读后比对 `show-ref --head`,引用变了(含外部提交、切分支、取回)即从头重读;读取期间来的比对记下,读完再比;引用读不出时历史栏显示 Git 的原话,之后读通即重读并清掉;提交图由 `git::lanes` 按已读的全部提交算出;时间按本机时区写到分钟(不用相对时间,长列表不必随时刷新)。搜索:在“提交信息”(`--grep`)、“作者”(`--author`)、“改动的文字”(`-S`)中按字面、不分大小写找,或按提交号(`rev-parse --verify`,前缀可);搜索时不画图。选中一个提交:中间上方是摘要、说明、作者与提交者(不同时才列)、完整提交号、父提交(可点,转到父提交)、签名状态(`%G?` 按字母译出,带签名者)、引用;下方左列是相对第一个父提交改动的文件(`diff --name-status -M` 与 `--numstat`,根提交对空树,空树由 `hash-object -t tree` 算,不写死 SHA-1),右边是所选文件的差异(与改动区同一读法:Git 自己的差异,图片、二进制、非 UTF-8 同样处理,无块的纯重命名显示为无改动)。改动区差异与历史页所选文件的标题栏都有“文件历史”与“逐行追溯”(改动区限已跟踪的文件):文件历史为 `log --follow --numstat -z`,跨重命名,每个提交带当时的路径与行数,选中即显示该提交里这个文件的差异(状态按两侧是否存在判定);逐行追溯为工作树文件的 `blame --porcelain`,未提交的行写“尚未提交”,每段首行可按,转到历史页的该提交;条色按提交时间新旧。读取都在后台,按发起时的仓库、提交与文件核对;文件历史与逐行追溯每次打开有窗口内唯一的序号,迟到的结果即弃。提交详情的作者、提交者、提交号、签名、引用、摘要与说明在 AX 树里有文字。真窗口(macOS)经 AX:历史页、提交图、引用、提交详情、文件列表与差异。未验证:Windows 与 Linux;GPG 与 SSH 实际签名(只读设置,签名由用户的 Git 完成)。
|
||||
- 2026-10-07:ElyTree 差异与暂存(C13.2,TREE-03、04)。差异一律取 Git 自己的输出(`-c diff.suppressBlankEmpty=false diff --no-color --no-ext-diff --no-textconv -U3`,前缀写明 `a/`、`b/`,覆盖用户的 noprefix 等设置;空白的上下文行照样带前导空格):已暂存为 `--cached -M`(HEAD 对索引,即提交前的实际索引内容),改动为索引对工作树,未跟踪为 `--no-index` 对 `/dev/null`(退出码 1 即有差异);Git 的清理过滤(换行转换等)由它自己做,按块、按行暂存的补丁对得上索引。块之间未改动的行从旧侧的 blob 取(`cat-file blob`,已暂存为 `HEAD:旧路径`,改动为 `:路径`),可展开;行号按块头推算,旧文件比差异短即报错。改动的词按每对删去与加入的行标出(Unicode 词)。空白:精确、忽略空白变化(`-b`)、忽略全部空白(`-w`);行内与并排。按块或按行:只在精确比较、且是普通内容改动时(无新建、删除、重命名、复制、权限变化的头),已暂存区为取消暂存(`apply --cached --reverse`),改动区为暂存(`apply --cached`);未跟踪、重命名等整份暂存,界面写明。补丁由所选行切出:暂存时未选的删去行留作上下文、未选的加入行不写;取消暂存时反之;块头按“范围前的行数”换算,空范围照 Git 的写法;“无换行结尾”按切出后两侧的实际行尾:无换行的行在哪一侧后面还有行,那一侧就带上换行(所选的删去或加入行同样),两侧不同的上下文行拆成一删一加;`apply` 带 `--whitespace=nowarn`,用户的 `apply.whitespace` 不改所选的字节。写入前在后台重读同一条差异,与所选时读到的字节不同即拒绝(“文件在显示后改了,未暂存”),索引不动;同一仓库的写仍排队。所选行按住 Shift 取同一块内的范围;差异一变(任何重读得到不同的输出)所选即清空;每次读取有窗口内唯一的序号,换文件后旧文件迟到的读取即弃。图片(png、jpg、gif、webp、bmp、tiff、ico)以 `BeforeAfter` 左右拖动对比,一侧缺失时只显示另一侧;其他二进制文件,以及两侧任一侧不全是 UTF-8 的文件(差异以外的字节也算),显示两侧大小,整份暂存;文件与符号链接互换时 Git 为同一路径打出两段(删去与新增),两段都显示(不折叠:两段没有共同的旧文),整份暂存;工作树侧照 Git 读:符号链接读其自身的路径,不跟随;未跟踪的文件夹与冲突文件(冲突在 C13.4 处理)只说明。单击文件即在中间显示其差异(已忽略的文件以系统程序打开),标题栏另有“打开文件”。外部改动后状态重读,正显示的差异随之重读;文件不再列在原分区即收起。真窗口(macOS):改动区选两行暂存,索引恰为所选,已暂存区显示实际索引内容,并排视图;AX 树中各行、块按钮与分段可按。
|
||||
@@ -278,7 +261,6 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-09(用户报:ElyPDF 改字报“这种字体画不出 V”):文档自带的子集字体常无可用的 Unicode 表(LibreOffice 导出的子集表为占位,0x00 起逐一对应字形号),按 Unicode 找字形一律失败。现以“文档自带字体”写字时直接用页面上的那个字体对象(`shim/ely_pen.c`;`pdf_load_font` 按对象缓存,与文字层的字体同一对象),以它自己的编码写:按文字提取的读法(ToUnicode,缺项或坏值退回 cid_to_ucs,同 pdf-op-run.c)倒出 Unicode 到编码的表,排版取它的宽度(`pdf_lookup_hmtx`),原有的 ToUnicode 与宽度不动,两个字共用一个字形时各写各的编码(原先按字形号写、重建 ToUnicode,共用字形的字被改写,且 CMap 漏写条目数,PDFKit 读不出字)。删去原文字块后页面资源里可能已无该字体,故按对象号以新名字重新引用。只支持编码即 CID 的字体(简单字体与 Identity-H),其余拒绝并说明;页面上没有的字仍报缺字。排版按同一套宽度量出的框,比较时留 0.01 点的余量(原块宽度即这些宽度之和,浮点误差曾使末字折行)。
|
||||
- 2026-10-09:ElyTable 的自动筛选(R2.9)。内核会重新推算未命名的自动筛选区:每次打开筛选列表时向下扩到紧邻的数据行(`ExtendDataArea`、`ExtendBackColorArea`,为接上后来追加的行),命令从单格找到它时按连续数据区重算(`ScDocShell::GetDBData`:应用条件、关闭筛选及其撤销);选区下方紧邻的合计行因此进了列表、随数据一起被隐藏,存后重开的筛选区同样会扩。补丁 `0040-lok-calc-autofilter-keeps-its-rows.patch`:LOK 下已有的自动筛选区保持设定时的行(同 Excel),不再隐式扩展(光标在区内时;光标在同表另一块数据里即按那一块);单格开筛选时仍取整个数据区。代价:紧贴在筛选区下方追加的行不自动并入,需重新选定后再开筛选。
|
||||
- 2026-10-09:ElyTree 的差异按文件类型着色(R2.12)。组件库 7f2ac3e 至 b0dbfd4:`DiffViewer`(建立时按路径定下读法) 按路径的扩展名判定散文与纯文本(md、markdown、txt、rst、adoc、org、csv、tsv、log 等,无扩展名同,不着色),代码按语言着色(组件库 b0dbfd4 的 `forms::syntax`:Rust、JavaScript/TypeScript、类 C、Python、Ruby、Shell、YAML、INI、SQL、Lua、Haskell、Lisp、Erlang、TeX 与其他 `#` 语言各有行注释记号、关键字与常量;Shell 与 YAML 的 `#` 只在词首起注释;Makefile、Dockerfile、CMakeLists.txt 先按文件名认),不是完整的语法分析器(字符串插值、块注释、多行字符串照通用词法)(原先一律套 `//` 的通用词法,Markdown 的 `#`、Python 的注释都被当作代码上色)。同批带入上游的块编辑器跨块选择、浮层滚轮、输入法候选框位置、插入点按设备像素对齐与格式修正。
|
||||
- 2026-10-09:ElyMark 的表格(R2.13)。表格的空格与分隔行写法有许多种(`|a|b|`、`|---|`、对齐空格),逐字重现做不到,原先凡写不回原样的表格都成了原文块。现在一块的序列化与原文在把其中每张表格化成规范形式(各列对齐方式加各格的原文,`tables.js` 的 `canon`)后相同,即为可视块(`preserve.js` 的 `kept`),引用与列表里的表格同样;格内有 Milkdown 会改写的写法(`[[…]]`、引用式链接等)即不同,整块仍为原文块,与段落的规则一致。Milkdown 的列表项必须以段落开头,表格作列表项的第一块时无法放进编辑器,照原文块保留。未改的表格照旧逐字写回,改过的表格按文档多数表格的写法重写(`style.js` 按表格的行节点投票:单元格两侧有无空格、是否按列对齐,经 remark-gfm 的 `tableCellPadding` 与 `tablePipeAlign`;原先投票的解析器不认 GFM,表格从未计票),分隔行随之重写(如 `|---|` 成 `| - |`)。
|
||||
- 2026-10-09:ElyTable 新插图表的位置(R2.10)。内核按“定位区域”把图表放在其旁、并挤回当前可见区;带参数的请求(Ely 经 `FN_PARAM_5` 给区域)原把定位区域取为光标格,图表压在数据上或旁边的另一块数据上。补丁 `0041-lok-calc-chart-beside-its-data.patch`:LOK 下有请求区域时,图表放进数据区右侧(隔一列)第一块够大、无合并格的空单元格(逐列向右找,跨过旁边的数据块),右侧没有依次找下方、左侧、上方,再按行带找全表第一块够大的空处(尺寸从候选格起累加,表尾也快),不受可见区限制;空处按图表所在的工作表查,数据在别的表时从光标起找;整张表没有空处即插入失败、文档不动;`LokView` 在新选中对象(原先无选中)时把它滚入视口,拖动中的对象不滚动。
|
||||
- 2026-10-09:ElyPDF 编辑与缩放不闪白(R2.6)。旧图块不再随编辑或比例变化即刻丢弃:编辑落地时全部当前图块、比例变化时离开视图的同页图块,连同当时的页面尺寸进入“底图”(`apps/ely-pdf/src/backdrop.rs`),按其在页面中的比例位置画在该页新图块之下,直到该页在视口里的新图块到齐、或页面尺寸与旋转变了才释放;每页画什么由 `PdfView::pieces` 决定,绘制与测试同用它。只有改内容的编辑保留底图并沿用已测的页面尺寸(前 100 页以外不再被占位尺寸覆盖);增删、移动、旋转、裁剪页面与撤销重做(`PdfView::edit_pages`)清空底图,因同一序号可能已是另一页。关闭标签时底图随图块交给窗口释放。原先比较整份尺寸表(超过 100 页的混合纸张文档尺寸表后段为占位,比较失败即全部清空)且只认同比例的图块(编辑后右栏收起、比例变化即清空),都会闪白。
|
||||
- 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
|
||||
@@ -286,7 +268,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。
|
||||
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
|
||||
- 2026-10-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`);CEF 照 cef-dll-sys 放在可执行文件旁(Windows、Linux 都是),不另载入;`%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 的构建环境(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` 分装引擎;每个平台都上传本提交的源码包(`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 默认的浅黄),切换主题不再改文档,三端同此(`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 上恒失败)。
|
||||
@@ -294,7 +276,6 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-11:Linux 标题栏按钮(核查 Windows 按钮问题时查出)。macOS 的红绿灯由系统画,不受影响;Linux 为客户端装饰,按钮由组件库画在标题栏的拖动区里,拖动区对任何按下都准备移动窗口,按住按钮时指针动一像素即开始移动窗口,合成器接管指针,松开到不了按钮,点击落空。组件库 3eb4d34:`drag_region` 只在按下未被里面的按钮认领(按钮以 `prevent_default` 认领)时才准备移动;`shell/title_tests.rs` 核对晃动的按下点得动 Linux 的关闭、标题栏空处照样移动窗口(去掉修复时前者失败)。未在真 Linux 桌面上点过。
|
||||
- 2026-10-11(用户报:ElyTree 改动列表里文件名长时盖住行尾的打开按钮):组件库 bd01470:`ChangesList` 行里的文件名原为不缩的盒子,长名越过行宽压在按钮下;现可缩、以省略号结尾,先于按钮让位;`git/tests/narrow.rs` 核对 260 宽时文件名止于按钮之前(原布局时失败)。
|
||||
- 2026-10-11(用户定:ElyTree 状态栏去掉 Git 的版本与路径那一项):所用 Git 的版本与路径只记日志。同时查出 Windows 上 `std::fs::canonicalize` 给出 `\\?\C:\…` 形式,仓库根、Git 目录、工作树与文件的真实路径一律改用 `dunce::canonicalize`(能写成普通路径即普通路径,同一文件仍是同一真实路径),状态栏的仓库路径随之为普通形式;ElyTree 的单测在 Windows 上由 44 个失败降到 8 个,余下 8 个改前同样失败(如写入 Windows 不许的文件名),未处理。
|
||||
- 2026-10-11:ElyMark 的 AppImage(0.1.3 的 Linux 发布查出)。`linuxdeploy` 把可执行文件的 RUNPATH 改成 `$ORIGIN/../lib`,并把它找得到的 `libcef.so` 拷进 `usr/lib`:原先 `libcef.so` 与 CEF 的资料在 `usr/bin`,结果是两份 1.45 GB 的内核、程序载入 `usr/lib` 那份、CEF 在它旁边找不到 ICU 数据而退出(从未在 AppImage 里启动过)。现在 `package.sh` 把从 `engines/cef` 分装来的全部条目移进 `usr/lib`(按名字对照 CEF 文件夹,清单仍只在 `stage.sh`),以 `LD_LIBRARY_PATH=AppDir/usr/lib` 运行 `linuxdeploy`,`libcef.so` 只此一份、依赖照样带上,CEF 按所载 `libcef.so` 的文件夹取资料(`ely-web-content` 的 `open`)。ElyMark 的 AppImage 由 715 MB 降到 423 MB;WSL 里启动后 CEF 载入、窗口打开(WSL 里各应用都不出首帧,未在真桌面上验证)。
|
||||
- 2026-10-09(用户定):代码与发布从 GitHub 搬到自建的 Gitea(git.zacharyzhang.com;GitHub 账号被封,推送与发布都被拒)。公开仓库 `ZacharyZhang-NY/ElyOffice`(原样推上 main、缺陷清单分支与 v0.1.0、v0.1.1 标签),组件库为同站的 `ZacharyZhang-NY/Ely-GPUI-Components`(Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变)。v0.1.1 的六个 dmg 与两个源码包重新发在 Gitea 的 v0.1.1 发布里。官网的下载与源码链接指向 Gitea(`releases/latest` 会跳到最新发布页)。`.github/workflows` 删去(它们靠 GitHub 的 macOS 机器与 `gh`,发布改在本机以 `packaging/macos/release.sh` 构建、经 Gitea API 上传)。发布视频里写的地址已在源文件改为 Gitea,已发布的成片仍是旧地址,重渲染后才更新。上游项目(LibreOffice、MuPDF、git-lfs、tessdata 等)照旧从它们自己的 GitHub 地址取。
|
||||
- 2026-10-11(用户报:Omarchy 上六个 AppImage 都打不开):`gpui-pre-platform` 对 `gpui-pre-linux` 关了默认特性,Wayland 与 X11 只经它自己的 `wayland`、`x11` 特性打开;工作区原只开 `font-kit`,Linux 上 gpui 退成无头平台,日志照写“window open”,合成器里从无窗口。工作区的 `gpui_platform` 加开两者;`tests/scripts/linux-backends.sh` 核对特性,在 Hyprland 上核对 Wayland 与 X11 两路都映射出窗口、截图里有均匀的深色页面,tesseract 读出起始页的 “Open a repository”(不交缓冲的窗口 Hyprland 不映射;不画字形时读不出)。
|
||||
- 2026-10-11:Linux 的 inotify 把打开文件报成事件(notify 的 `Access`),macOS 的 FSEvents 不报;四处文件监视原把任何事件当作改动,重读本身又触发下一次:Omarchy 主题与通用设置在第一次改动后、ElyTree 打开仓库起(`git status` 读仓库),空闲时每 250 毫秒重读。四处都经 `ely_ui::watching::only_read` 略去读取(打开、读、只读关闭),写后关闭照算改动(经内存映射的写入只报 CLOSE_WRITE,不报 MODIFY);`tests/scripts/linux-idle.sh` 以 inotifywait 从外面数空闲时的读取与 git status 次数。
|
||||
@@ -303,4 +284,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。
|
||||
- 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。
|
||||
- 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。
|
||||
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。
|
||||
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
|
||||
@@ -1,89 +0,0 @@
|
||||
// Builds the page into the folder given; no network.
|
||||
import { build } from 'esbuild'
|
||||
import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const out = process.argv[2]
|
||||
if (!out) throw new Error('usage: node build.mjs <output folder>')
|
||||
await rm(out, { recursive: true, force: true })
|
||||
await mkdir(out, { recursive: true })
|
||||
|
||||
const built = await build({
|
||||
entryPoints: ['src/main.js', 'src/diagram.js', 'src/main.css'],
|
||||
outdir: out,
|
||||
bundle: true,
|
||||
minify: true,
|
||||
format: 'iife',
|
||||
target: 'chrome140',
|
||||
loader: { '.woff2': 'file', '.woff': 'file', '.ttf': 'file' },
|
||||
assetNames: 'fonts/[name]-[hash]',
|
||||
legalComments: 'none',
|
||||
metafile: true,
|
||||
logLevel: 'warning',
|
||||
})
|
||||
// Exports carry one stylesheet: fonts inline, woff2 alone.
|
||||
const exported = await build({
|
||||
entryPoints: ['src/export.css'],
|
||||
outdir: out,
|
||||
bundle: true,
|
||||
minify: true,
|
||||
loader: { '.woff2': 'dataurl', '.woff': 'empty', '.ttf': 'empty' },
|
||||
legalComments: 'none',
|
||||
metafile: true,
|
||||
logLevel: 'warning',
|
||||
})
|
||||
const sheet = join(out, 'export.css')
|
||||
// The dropped woff and ttf left empty sources.
|
||||
await writeFile(sheet, (await readFile(sheet, 'utf8')).replace(/,url\(\) format\("(?:woff|truetype)"\)/g, ''))
|
||||
await copyFile('index.html', join(out, 'index.html'))
|
||||
|
||||
/** Each bundled package's folder under node_modules. */
|
||||
function packages(inputs) {
|
||||
const found = new Set()
|
||||
for (const input of inputs) {
|
||||
const at = input.lastIndexOf('node_modules/')
|
||||
if (at < 0) continue
|
||||
const parts = input.slice(at + 13).split('/')
|
||||
const name = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]
|
||||
found.add(input.slice(0, at + 13) + name)
|
||||
}
|
||||
return [...found].sort()
|
||||
}
|
||||
|
||||
/** Name, version and license; the file, else the manifest's. */
|
||||
async function notice(folder) {
|
||||
const manifest = JSON.parse(await readFile(join(folder, 'package.json'), 'utf8'))
|
||||
const head = `${manifest.name} ${manifest.version} (${manifest.license})`
|
||||
const file = (await readdir(folder)).find((name) => /^(licen[cs]e|copying)/i.test(name))
|
||||
if (file) return `${head}\n\n${(await readFile(join(folder, file), 'utf8')).trim()}\n`
|
||||
const author = manifest.author?.name ?? manifest.author
|
||||
if (manifest.license !== 'MIT' || !author) throw new Error(`${manifest.name}: no license file`)
|
||||
console.log(`${manifest.name} ships no license file; MIT from its manifest`)
|
||||
return `${head}\n\nCopyright (c) ${author}\n\n${MIT}\n`
|
||||
}
|
||||
|
||||
const MIT = `Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.`
|
||||
|
||||
const folders = packages([
|
||||
...Object.keys(built.metafile.inputs),
|
||||
...Object.keys(exported.metafile.inputs),
|
||||
])
|
||||
const notices = await Promise.all(folders.map(notice))
|
||||
await writeFile(join(out, 'THIRD-PARTY-NOTICES.txt'), notices.join('\n' + '-'.repeat(72) + '\n\n'))
|
||||
console.log(`built ${folders.length} packages into ${out}`)
|
||||
@@ -1,11 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="main.css">
|
||||
<script src="main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main id="document"></main>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-4871
File diff suppressed because it is too large.
Load diff
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "ely-mark-content",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-markdown": "6.5.2",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/language-data": "6.5.2",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.13",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@milkdown/kit": "7.22.2",
|
||||
"codemirror": "6.0.2",
|
||||
"hast-util-from-dom": "5.0.1",
|
||||
"hast-util-to-text": "4.0.2",
|
||||
"katex": "0.19.0",
|
||||
"mermaid": "12.1.0",
|
||||
"prosemirror-search": "1.1.1",
|
||||
"rehype-katex": "7.0.1",
|
||||
"rehype-stringify": "10.0.1",
|
||||
"remark-gfm": "4.0.1",
|
||||
"remark-math": "6.0.0",
|
||||
"remark-parse": "11.0.0",
|
||||
"remark-rehype": "11.1.2",
|
||||
"unified": "11.0.5",
|
||||
"unist-util-visit": "5.1.0",
|
||||
"vfile": "6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.2"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/** One request to the app, as a promise. */
|
||||
export function ask(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
window.elyQuery({
|
||||
request: JSON.stringify(request),
|
||||
onSuccess: resolve,
|
||||
onFailure: (code, message) => reject(new Error(`${code}: ${message}`)),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Code blocks in CodeMirror; LaTeX and Mermaid preview.
|
||||
import { basicSetup, EditorView } from 'codemirror'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { keymap } from '@codemirror/view'
|
||||
import { indentWithTab } from '@codemirror/commands'
|
||||
import { languages } from '@codemirror/language-data'
|
||||
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
|
||||
import { theme } from './theme.js'
|
||||
import { icons } from './icons.js'
|
||||
import { render } from './math.js'
|
||||
|
||||
/** Previews for one editor's code blocks. */
|
||||
function previews(owner) {
|
||||
return (language, source, apply) => {
|
||||
const kind = source.length === 0 ? '' : language.toLowerCase()
|
||||
switch (kind) {
|
||||
case 'latex':
|
||||
return render(source, true, document.createElement('div'))
|
||||
case 'mermaid':
|
||||
return owner.show(source, apply)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Milkdown's code block config; read-only in preview. */
|
||||
export function code(texts, editable, owner) {
|
||||
const locked = editable ? [] : [EditorState.readOnly.of(true), EditorView.editable.of(false)]
|
||||
return (ctx) =>
|
||||
ctx.update(codeBlockConfig.key, (prev) => ({
|
||||
...prev,
|
||||
...icons,
|
||||
extensions: [basicSetup, keymap.of([indentWithTab]), theme, ...locked],
|
||||
languages,
|
||||
searchPlaceholder: texts.searchLanguage,
|
||||
noResultText: texts.noResult,
|
||||
copyText: texts.copy,
|
||||
previewLabel: texts.preview,
|
||||
previewLoading: texts.loading,
|
||||
previewToggleButton: (only) => (only ? texts.edit : texts.hide),
|
||||
renderPreview: previews(owner),
|
||||
}))
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// Mermaid, loaded on first use; only saved source renders.
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
let count = 0
|
||||
|
||||
/** The theme from the page's Ely tokens. */
|
||||
function variables() {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const token = (name) => style.getPropertyValue(`--ely-${name}`).trim()
|
||||
return {
|
||||
background: token('bg'),
|
||||
primaryColor: token('surface'),
|
||||
primaryTextColor: token('fg'),
|
||||
primaryBorderColor: token('border'),
|
||||
lineColor: token('fg-muted'),
|
||||
textColor: token('fg'),
|
||||
fontFamily: token('font'),
|
||||
}
|
||||
}
|
||||
|
||||
/** `source` as SVG; `paper` draws Mermaid's light theme for export. */
|
||||
window.elyDiagram = async (source, look) => {
|
||||
const paper = look === 'paper'
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
suppressErrorRendering: true,
|
||||
theme: paper ? 'default' : 'base',
|
||||
themeVariables: paper ? {} : variables(),
|
||||
})
|
||||
count += 1
|
||||
const { svg } = await mermaid.render(`ely-diagram-${count}`, source)
|
||||
return svg
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// Mermaid previews; each editor owns its records.
|
||||
import { failure } from './failure.js'
|
||||
|
||||
// Beside this script; the page's base is the document's folder.
|
||||
const here = document.currentScript.src
|
||||
|
||||
/** The Mermaid bundle, added once as a classic script. */
|
||||
let loading
|
||||
export function bundle() {
|
||||
loading ??= new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = new URL('diagram.js', here).href
|
||||
script.onload = () => resolve(window.elyDiagram)
|
||||
script.onerror = () => reject(new Error('diagram.js did not load'))
|
||||
document.head.append(script)
|
||||
})
|
||||
return loading
|
||||
}
|
||||
|
||||
let queue = Promise.resolve()
|
||||
let count = 0
|
||||
const owners = new Set()
|
||||
|
||||
// New tokens mean a new theme; every owner redraws.
|
||||
new MutationObserver(() => {
|
||||
for (const owner of owners) owner.redraw()
|
||||
}).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
|
||||
|
||||
/** A block's diagram, by its id on the page. */
|
||||
const box = (id, inner) => `<div class="ely-diagram" data-diagram="${id}">${inner}</div>`
|
||||
const placed = (id) => document.querySelector(`[data-diagram="${id}"]`) !== null
|
||||
// After the code block's next render.
|
||||
const settle = () => new Promise((done) => setTimeout(done))
|
||||
|
||||
/** One editor's diagrams; a result lands only on its placeholder. */
|
||||
export function diagrams() {
|
||||
const records = new Map()
|
||||
|
||||
/** Drops diagrams whose placeholder has left the page. */
|
||||
const sweep = () => {
|
||||
for (const [id, record] of records) {
|
||||
if (record.settled && !placed(id)) records.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Each draw has a turn; a later draw voids earlier. */
|
||||
const draw = (id, record) => {
|
||||
const turn = ++record.turn
|
||||
const live = () => records.get(id) === record && record.turn === turn && placed(id)
|
||||
queue = queue.then(async () => {
|
||||
await settle()
|
||||
if (!live()) return
|
||||
try {
|
||||
const render = await bundle()
|
||||
if (!live()) return
|
||||
const svg = await render(record.source)
|
||||
await settle()
|
||||
if (live()) record.apply(box(id, svg))
|
||||
} catch (error) {
|
||||
await settle()
|
||||
if (!live()) return
|
||||
records.delete(id)
|
||||
record.apply(failure(record.source, error.message))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const owner = {
|
||||
/** The placeholder for `source`; its diagram follows. */
|
||||
show(source, apply) {
|
||||
sweep()
|
||||
const id = `${++count}`
|
||||
const record = { source, apply, settled: false, turn: 0 }
|
||||
records.set(id, record)
|
||||
settle().then(() => (record.settled = true))
|
||||
draw(id, record)
|
||||
return box(id, '')
|
||||
},
|
||||
redraw() {
|
||||
sweep()
|
||||
for (const [id, record] of records) draw(id, record)
|
||||
},
|
||||
release() {
|
||||
records.clear()
|
||||
owners.delete(owner)
|
||||
},
|
||||
}
|
||||
owners.add(owner)
|
||||
return owner
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// Myers' diff over two lists.
|
||||
|
||||
/** Equal pairs between `a` and `b`, in order. */
|
||||
export function common(a, b, same) {
|
||||
const n = a.length
|
||||
const m = b.length
|
||||
const max = n + m
|
||||
const reach = new Map([[1, 0]])
|
||||
const trace = []
|
||||
for (let d = 0; d <= max; d++) {
|
||||
trace.push(new Map(reach))
|
||||
for (let k = -d; k <= d; k += 2) {
|
||||
const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1))
|
||||
let x = down ? reach.get(k + 1) : reach.get(k - 1) + 1
|
||||
let y = x - k
|
||||
while (x < n && y < m && same(a[x], b[y])) {
|
||||
x += 1
|
||||
y += 1
|
||||
}
|
||||
reach.set(k, x)
|
||||
if (x >= n && y >= m) return walk(trace, n, m)
|
||||
}
|
||||
}
|
||||
throw new Error('diff did not end')
|
||||
}
|
||||
|
||||
/** Back through the trace: each equal pair, in order. */
|
||||
function walk(trace, n, m) {
|
||||
const pairs = []
|
||||
let x = n
|
||||
let y = m
|
||||
for (let d = trace.length - 1; d > 0; d--) {
|
||||
const reach = trace[d]
|
||||
const k = x - y
|
||||
const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1))
|
||||
const from = down ? k + 1 : k - 1
|
||||
const fromX = reach.get(from)
|
||||
const fromY = fromX - from
|
||||
while (x > fromX + (down ? 0 : 1) && y > fromY + (down ? 1 : 0)) {
|
||||
x -= 1
|
||||
y -= 1
|
||||
pairs.push([x, y])
|
||||
}
|
||||
x = fromX
|
||||
y = fromY
|
||||
}
|
||||
while (x > 0 && y > 0) {
|
||||
x -= 1
|
||||
y -= 1
|
||||
pairs.push([x, y])
|
||||
}
|
||||
return pairs.reverse()
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/* Exports: KaTeX with its fonts inline, on light paper. */
|
||||
@import 'katex/dist/katex.min.css';
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 auto;
|
||||
max-width: 48rem;
|
||||
padding: 2rem 1.5rem;
|
||||
background: #fff;
|
||||
color: #1f2328;
|
||||
font: 16px / 1.6 -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0969da;
|
||||
}
|
||||
|
||||
pre,
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0;
|
||||
padding: 0 1rem;
|
||||
border-left: 3px solid #d0d7de;
|
||||
color: #59636e;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 1px solid #d0d7de;
|
||||
}
|
||||
|
||||
img,
|
||||
figure.diagram svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
figure.diagram {
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
pre,
|
||||
table,
|
||||
figure {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/** The source as saved, and why it did not render. */
|
||||
export function failure(source, message, at) {
|
||||
const box = document.createElement('div')
|
||||
box.className = 'ely-failure'
|
||||
const why = document.createElement('p')
|
||||
why.textContent = at === undefined ? message : `${message} (${at})`
|
||||
const code = document.createElement('pre')
|
||||
code.textContent = source
|
||||
box.append(why, code)
|
||||
return box
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
// Find and replace in either editor (MARK-06).
|
||||
import {
|
||||
SearchQuery as TextQuery,
|
||||
findNext as nextText,
|
||||
findPrevious as previousText,
|
||||
replaceAll as allText,
|
||||
replaceNext as oneText,
|
||||
setSearchQuery,
|
||||
} from '@codemirror/search'
|
||||
import {
|
||||
SearchQuery as DocQuery,
|
||||
findNext as nextDoc,
|
||||
findPrev as previousDoc,
|
||||
replaceAll as allDoc,
|
||||
replaceNext as oneDoc,
|
||||
setSearchState,
|
||||
} from 'prosemirror-search'
|
||||
import { TextSelection } from '@milkdown/kit/prose/state'
|
||||
|
||||
/** Why a pattern fails under the editors' `gmu` flags. */
|
||||
function broken(spec) {
|
||||
if (!spec.regexp || !spec.search) return null
|
||||
try {
|
||||
new RegExp(spec.search, 'gmu')
|
||||
return null
|
||||
} catch (error) {
|
||||
return error.message
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether two specs find the same hits. */
|
||||
export function sameQuery(a, b) {
|
||||
return ['search', 'caseSensitive', 'regexp', 'wholeWord'].every((key) => a[key] === b[key])
|
||||
}
|
||||
|
||||
/** One character's width at `pos`: two for a surrogate pair. */
|
||||
function width(doc, pos) {
|
||||
const next = doc.textBetween(pos, Math.min(pos + 2, doc.content.size), '\n', '\n')
|
||||
return next.codePointAt(0) > 0xffff ? 2 : 1
|
||||
}
|
||||
|
||||
/** Where the selection stands among `ranges`. */
|
||||
function standing(ranges, from, to) {
|
||||
const at = ranges.findIndex((range) => range.from === from && range.to === to)
|
||||
return { current: at < 0 ? null : at, total: ranges.length }
|
||||
}
|
||||
|
||||
/** Find in Milkdown's view; `prosemirror-search` is installed there. */
|
||||
export function docFinder(view) {
|
||||
let spec = { search: '' }
|
||||
const query = () => new DocQuery(spec)
|
||||
const ranges = () => {
|
||||
const found = []
|
||||
const asked = query()
|
||||
if (!asked.valid) return found
|
||||
const { doc } = view.state
|
||||
for (let at = 0; ;) {
|
||||
const hit = asked.findNext(view.state, at)
|
||||
// Mid-pair, a Unicode pattern answers before `at`.
|
||||
if (!hit || hit.from < at) return found
|
||||
found.push(hit)
|
||||
at = hit.to > hit.from ? hit.to : hit.to + width(doc, hit.to)
|
||||
}
|
||||
}
|
||||
const status = () => {
|
||||
const error = broken(spec)
|
||||
if (error) return { error }
|
||||
const { from, to } = view.state.selection
|
||||
return standing(ranges(), from, to)
|
||||
}
|
||||
const run = (command) => command(view.state, view.dispatch)
|
||||
return {
|
||||
seek(next) {
|
||||
spec = next
|
||||
const { from } = view.state.selection
|
||||
const tr = setSearchState(view.state.tr, query())
|
||||
// From the hit's start: the hit stays while typing.
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(from)))
|
||||
view.dispatch(tr)
|
||||
if (query().valid) run(nextDoc)
|
||||
return status()
|
||||
},
|
||||
step(forward) {
|
||||
if (query().valid) run(forward ? nextDoc : previousDoc)
|
||||
return status()
|
||||
},
|
||||
replace(all) {
|
||||
if (query().valid) run(all ? allDoc : oneDoc)
|
||||
return status()
|
||||
},
|
||||
clear() {
|
||||
spec = { search: '' }
|
||||
view.dispatch(setSearchState(view.state.tr, query()))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Find in CodeMirror; its `search()` extension is installed there. */
|
||||
export function textFinder(view) {
|
||||
let spec = { search: '' }
|
||||
const valid = () => new TextQuery(spec).valid
|
||||
const ranges = () => {
|
||||
const found = []
|
||||
const asked = new TextQuery(spec)
|
||||
if (!asked.valid) return found
|
||||
const cursor = asked.getCursor(view.state)
|
||||
for (let next = cursor.next(); !next.done; next = cursor.next()) found.push(next.value)
|
||||
return found
|
||||
}
|
||||
const status = () => {
|
||||
const error = broken(spec)
|
||||
if (error) return { error }
|
||||
const { from, to } = view.state.selection.main
|
||||
return standing(ranges(), from, to)
|
||||
}
|
||||
return {
|
||||
seek(next) {
|
||||
spec = next
|
||||
const selection = { anchor: view.state.selection.main.from }
|
||||
view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)), selection })
|
||||
// CodeMirror opens its own panel for invalid queries.
|
||||
if (valid()) nextText(view)
|
||||
return status()
|
||||
},
|
||||
step(forward) {
|
||||
if (valid()) (forward ? nextText : previousText)(view)
|
||||
return status()
|
||||
},
|
||||
replace(all) {
|
||||
if (valid()) (all ? allText : oneText)(view)
|
||||
return status()
|
||||
},
|
||||
clear() {
|
||||
spec = { search: '' }
|
||||
view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)) })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// Code block icons, drawn in the text color.
|
||||
const icon = (path) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
|
||||
|
||||
export const icons = {
|
||||
expandIcon: icon('<path d="m6 9 6 6 6-6"/>'),
|
||||
searchIcon: icon('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
|
||||
clearSearchIcon: icon('<path d="M18 6 6 18M6 6l12 12"/>'),
|
||||
copyIcon: icon(
|
||||
'<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
|
||||
),
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// Untitled images: remark gives null, the schema wants text.
|
||||
import { imageSchema } from '@milkdown/kit/preset/commonmark'
|
||||
|
||||
export const image = imageSchema.extendSchema((prev) => (ctx) => {
|
||||
const base = prev(ctx)
|
||||
return {
|
||||
...base,
|
||||
parseMarkdown: {
|
||||
match: base.parseMarkdown.match,
|
||||
runner: (state, node, type) =>
|
||||
state.addNode(type, { src: node.url, alt: node.alt ?? '', title: node.title ?? '' }),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
// The writing view: focus, typewriter, width, font, size (MARK-09).
|
||||
|
||||
let typewriter = false
|
||||
|
||||
/** Applies `look`; the Markdown never changes. */
|
||||
export function apply(look) {
|
||||
const root = document.documentElement
|
||||
root.dataset.focus = look.focus
|
||||
root.dataset.width = look.width
|
||||
root.dataset.font = look.font
|
||||
root.dataset.typewriter = look.typewriter
|
||||
typewriter = look.typewriter
|
||||
const sheet =
|
||||
document.getElementById('ely-look') ??
|
||||
document.head.appendChild(Object.assign(document.createElement('style'), { id: 'ely-look' }))
|
||||
sheet.textContent = `:root { --ely-text-scale: ${look.size / 100}; }`
|
||||
}
|
||||
|
||||
/** Typewriter scrolling: the caret's line stays mid-view. */
|
||||
export function follow(editor) {
|
||||
if (typewriter) editor?.center?.()
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
@import 'katex/dist/katex.min.css';
|
||||
@import '@milkdown/kit/prose/view/style/prosemirror.css';
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--ely-bg);
|
||||
color: var(--ely-fg);
|
||||
font: calc(16px * var(--ely-font-scale, 1) * var(--ely-text-scale, 1)) / 1.6 var(--ely-font),
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--ely-selection);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--ely-link);
|
||||
}
|
||||
|
||||
#document {
|
||||
box-sizing: border-box;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
:root[data-width='narrow'] #document {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
:root[data-width='wide'] #document {
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
:root[data-font='serif'] #document:not([data-mode='source']) {
|
||||
font-family: ui-serif, Georgia, serif;
|
||||
}
|
||||
|
||||
:root[data-font='mono'] #document:not([data-mode='source']) {
|
||||
font-family: var(--ely-mono), monospace;
|
||||
}
|
||||
|
||||
/* Typewriter: room to center the first and last lines. */
|
||||
:root[data-typewriter='true'] #document:not([data-mode='source']) {
|
||||
padding-block: 50vh;
|
||||
}
|
||||
|
||||
:root[data-typewriter='true'] .cm-content {
|
||||
padding-block: 50vh;
|
||||
}
|
||||
|
||||
/* Focus mode: the caret's block, or line, stays bright. */
|
||||
:root[data-focus='true'] .milkdown .ProseMirror > :not(.ely-current),
|
||||
:root[data-focus='true'] .cm-line:not(.cm-activeLine) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.milkdown .ProseMirror > *,
|
||||
.cm-line {
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
}
|
||||
|
||||
#document[data-mode='source'] {
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#document[data-mode='source'] > div,
|
||||
#document[data-mode='source'] .cm-editor {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.milkdown .ProseMirror {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.milkdown blockquote {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
border-left: 3px solid var(--ely-border);
|
||||
color: var(--ely-fg-muted);
|
||||
}
|
||||
|
||||
.milkdown code {
|
||||
font-family: var(--ely-mono), monospace;
|
||||
font-size: 0.875em;
|
||||
background: var(--ely-sunken);
|
||||
border-radius: 4px;
|
||||
padding: 0 0.25em;
|
||||
}
|
||||
|
||||
.milkdown table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.milkdown th,
|
||||
.milkdown td {
|
||||
border: 1px solid var(--ely-border);
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.milkdown li > p,
|
||||
.milkdown th > p,
|
||||
.milkdown td > p {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.milkdown li[data-item-type='task'] {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* A text-sized box, centred on the first line. */
|
||||
.milkdown li[data-item-type='task']::before {
|
||||
content: '';
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-top: calc(0.25em + (1lh - 1em) / 2);
|
||||
border: 0.09em solid var(--ely-fg-muted);
|
||||
border-radius: 0.2em;
|
||||
}
|
||||
|
||||
.milkdown li[data-item-type='task'][data-checked='true']::before {
|
||||
border-color: var(--ely-link);
|
||||
background: var(--ely-link);
|
||||
box-shadow: inset 0 0 0 0.18em var(--ely-bg);
|
||||
}
|
||||
|
||||
.milkdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--ely-border);
|
||||
}
|
||||
|
||||
.milkdown img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.milkdown-code-block {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
background: var(--ely-surface);
|
||||
border: 1px solid var(--ely-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.milkdown-code-block.selected {
|
||||
outline: 1px solid var(--ely-focus);
|
||||
}
|
||||
|
||||
.milkdown-code-block .hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.milkdown-code-block .tools {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.milkdown-code-block button,
|
||||
.milkdown-code-block .language-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--ely-border);
|
||||
border-radius: 4px;
|
||||
background: var(--ely-sunken);
|
||||
color: var(--ely-fg-muted);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-mode='preview'] .milkdown-code-block .language-button {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.milkdown-code-block .tools-button-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.milkdown-code-block button:hover,
|
||||
.milkdown-code-block .language-button:hover {
|
||||
background: var(--ely-hover);
|
||||
}
|
||||
|
||||
.milkdown-code-block .tools svg,
|
||||
.milkdown-code-block .language-picker svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.ely-diagram svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.milkdown-code-block .language-picker {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.milkdown-code-block .list-wrapper {
|
||||
width: 15rem;
|
||||
padding-top: 0.5rem;
|
||||
background: var(--ely-surface);
|
||||
border: 1px solid var(--ely-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.milkdown-code-block .search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0.5rem 0.5rem;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--ely-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.milkdown-code-block .search-box input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--ely-fg);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.milkdown-code-block .search-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.milkdown-code-block .language-list {
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.milkdown-code-block .language-list-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 2px 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.milkdown-code-block .language-list-item:hover,
|
||||
.milkdown-code-block .language-list-item:focus-visible {
|
||||
outline: none;
|
||||
background: var(--ely-hover);
|
||||
}
|
||||
|
||||
.milkdown-code-block .language-list-item.no-result {
|
||||
cursor: default;
|
||||
color: var(--ely-fg-subtle);
|
||||
}
|
||||
|
||||
.milkdown-code-block .preview-panel .preview-divider {
|
||||
height: 1px;
|
||||
margin: 0.5rem 0;
|
||||
background: var(--ely-border);
|
||||
}
|
||||
|
||||
.milkdown-code-block .preview-panel .preview-label {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ely-fg-muted);
|
||||
}
|
||||
|
||||
.milkdown-code-block .preview {
|
||||
overflow-x: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ely-failure {
|
||||
text-align: left;
|
||||
color: var(--ely-danger);
|
||||
}
|
||||
|
||||
.ely-failure pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
color: var(--ely-fg);
|
||||
font-family: var(--ely-mono), monospace;
|
||||
}
|
||||
|
||||
.ely-front {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--ely-border);
|
||||
border-radius: 6px;
|
||||
color: var(--ely-fg-muted);
|
||||
font-family: var(--ely-mono), monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ely-raw {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--ely-sunken);
|
||||
border-radius: 4px;
|
||||
font-family: var(--ely-mono), monospace;
|
||||
font-size: 0.875em;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ProseMirror-search-match,
|
||||
.cm-searchMatch {
|
||||
background: var(--ely-selection);
|
||||
}
|
||||
|
||||
.ProseMirror-active-search-match,
|
||||
.cm-searchMatch-selected {
|
||||
outline: 1px solid var(--ely-focus);
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
// The page the app drives: one document, three modes.
|
||||
import { visual } from './visual.js'
|
||||
import { source } from './source.js'
|
||||
import { ask } from './bridge.js'
|
||||
import { sameQuery } from './find.js'
|
||||
import { apply, follow } from './look.js'
|
||||
import { html, text } from './render.js'
|
||||
|
||||
let texts = {}
|
||||
let latest = 0
|
||||
let version = 0
|
||||
// The app's count of page loads; reports carry it.
|
||||
let generation = 0
|
||||
let timer
|
||||
let pending = null
|
||||
/** The editor shown. */
|
||||
let current = null
|
||||
// A mode asked for while an editor builds.
|
||||
let asked = null
|
||||
// The text no editor could show; a mode retries it.
|
||||
let broken = null
|
||||
// A place to show once the editor is built.
|
||||
let revealing = null
|
||||
// The search in force, kept across modes.
|
||||
let seeking = null
|
||||
/** The file's texts where a mode with edits ended (§14.3). */
|
||||
let times = []
|
||||
let at = 0
|
||||
|
||||
/** YAML front matter, kept out of the visual editor. */
|
||||
function split(text) {
|
||||
const front = /^---\n[\s\S]*?\n(?:---|\.\.\.)[ \t]*(?:\n|$)/.exec(text)
|
||||
return front ? [front[0], text.slice(front[0].length)] : ['', text]
|
||||
}
|
||||
|
||||
const tell = (said) => ask(said).catch((error) => console.error(`ely: ${error.message}`))
|
||||
|
||||
/** Sends the waiting text now. */
|
||||
function flush() {
|
||||
clearTimeout(timer)
|
||||
if (pending) {
|
||||
tell(pending())
|
||||
outline()
|
||||
}
|
||||
pending = null
|
||||
}
|
||||
|
||||
/** A link the user opens; the app decides. */
|
||||
const linked = (href) => tell({ kind: 'link', generation, href })
|
||||
|
||||
/** The shown editor's headings, for the app's outline. */
|
||||
function outline() {
|
||||
if (current) tell({ kind: 'outline', generation, items: current.outline() })
|
||||
}
|
||||
|
||||
/** A new text: told now, its words once typing rests. */
|
||||
function revised(text) {
|
||||
version += 1
|
||||
const now = version
|
||||
tell({ kind: 'changed', version: now, generation })
|
||||
clearTimeout(timer)
|
||||
pending = () => ({ kind: 'text', version: now, generation, text: text() })
|
||||
timer = setTimeout(flush, 300)
|
||||
}
|
||||
|
||||
/** An edit: later texts in the timeline are gone. */
|
||||
function changed(text) {
|
||||
times.length = at + 1
|
||||
revised(text)
|
||||
}
|
||||
|
||||
/** An editor for `mode`; a failed one leaves no holder. */
|
||||
async function build(text, mode) {
|
||||
const holder = document.createElement('div')
|
||||
holder.hidden = true
|
||||
document.getElementById('document').append(holder)
|
||||
try {
|
||||
return await fill(holder, text, mode)
|
||||
} catch (error) {
|
||||
holder.remove()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds in `holder`; visual falls back to source. */
|
||||
async function fill(holder, text, mode) {
|
||||
const entry = { mode, holder, fallback: false }
|
||||
if (mode !== 'source') {
|
||||
const [front, body] = split(text)
|
||||
if (front) {
|
||||
const raw = document.createElement('pre')
|
||||
raw.className = 'ely-front'
|
||||
raw.textContent = front
|
||||
holder.append(raw)
|
||||
}
|
||||
const place = document.createElement('div')
|
||||
holder.append(place)
|
||||
const edited = (write) => changed(() => front + write())
|
||||
let made
|
||||
try {
|
||||
made = await visual(place, body, mode === 'visual', texts, edited, edge, linked)
|
||||
} catch (error) {
|
||||
// A broken editor falls back to source and says so.
|
||||
entry.broke = error.name
|
||||
}
|
||||
if (made) {
|
||||
// Lines count from the file's top, front matter included.
|
||||
const above = front ? front.split('\n').length - 1 : 0
|
||||
const line = (n, query) => {
|
||||
if (n > above) return made.line(n - above, query)
|
||||
holder.firstChild.scrollIntoView({ block: 'center' })
|
||||
return true
|
||||
}
|
||||
const redone = () => {
|
||||
const ahead = made.redone()
|
||||
return ahead === null ? null : front + ahead
|
||||
}
|
||||
const where = () => {
|
||||
const { line, column } = made.where()
|
||||
return { line: above + line, column }
|
||||
}
|
||||
const at = (n, column) => (n > above ? made.at(n - above, column) : line(n, ''))
|
||||
return { ...entry, ...made, text: () => front + made.text(), redone, line, where, at }
|
||||
}
|
||||
// Blocks the editor cannot map open as source.
|
||||
holder.replaceChildren()
|
||||
Object.assign(entry, { mode: 'source', fallback: true })
|
||||
}
|
||||
return { ...entry, ...source(holder, text, changed, edge) }
|
||||
}
|
||||
|
||||
function drop(entry) {
|
||||
entry.close()
|
||||
entry.holder.remove()
|
||||
}
|
||||
|
||||
/** Shows `text` as `want`; the newest request alone lands. */
|
||||
async function show(text, want) {
|
||||
const turn = ++latest
|
||||
asked = null
|
||||
broken = null
|
||||
if (current) drop(current)
|
||||
current = null
|
||||
let entry
|
||||
try {
|
||||
entry = await build(text, want)
|
||||
} catch (error) {
|
||||
if (turn !== latest) return
|
||||
// Source failed too: tell the app, keep the text.
|
||||
broken = text
|
||||
tell({ kind: 'failed', generation, what: error.name })
|
||||
const next = asked
|
||||
asked = null
|
||||
return next && mode(next)
|
||||
}
|
||||
if (turn !== latest) return drop(entry)
|
||||
current = entry
|
||||
entry.holder.hidden = false
|
||||
document.getElementById('document').dataset.mode = entry.mode
|
||||
entry.focus()
|
||||
await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback, broke: entry.broke ?? null })
|
||||
// A newer request owns what is queued now.
|
||||
if (turn !== latest) return
|
||||
outline()
|
||||
if (seeking) found(entry.find.seek(seeking))
|
||||
const place = revealing
|
||||
revealing = null
|
||||
if (place) reveal(place)
|
||||
const next = asked
|
||||
asked = null
|
||||
if (next && next !== entry.mode) return mode(next)
|
||||
}
|
||||
|
||||
/** Undo or redo past an editor's own history. */
|
||||
function edge(direction) {
|
||||
const to = direction === 'undo' ? at - 1 : at + 1
|
||||
if (!current || to < 0 || to >= times.length) return false
|
||||
flush()
|
||||
at = to
|
||||
const text = times[at]
|
||||
revised(() => text)
|
||||
show(text, current.mode)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Relative links and images resolve under `href`. */
|
||||
function base(href) {
|
||||
const link =
|
||||
document.querySelector('base') ?? document.head.appendChild(document.createElement('base'))
|
||||
link.href = href
|
||||
}
|
||||
|
||||
/** Puts the caret on `place.line`, the page at `place.scroll`. */
|
||||
function settle(place) {
|
||||
if (!current || !place) return
|
||||
current.at(place.line, place.column)
|
||||
document.scrollingElement.scrollTop = place.scroll
|
||||
}
|
||||
|
||||
/** A document, after each page load; `place` restores a session. */
|
||||
async function open(text, want, words, load, folder, place) {
|
||||
texts = words
|
||||
generation = load
|
||||
times = [text]
|
||||
at = 0
|
||||
base(folder)
|
||||
await show(text, want)
|
||||
settle(place)
|
||||
}
|
||||
|
||||
/** Changed on disk: the caret and scroll stay near. */
|
||||
async function refresh(text, load) {
|
||||
const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop }
|
||||
clearTimeout(timer)
|
||||
pending = null
|
||||
generation = load
|
||||
version = 0
|
||||
times = [text]
|
||||
at = 0
|
||||
await show(text, current?.mode ?? 'visual')
|
||||
settle(place)
|
||||
}
|
||||
|
||||
/** `text` in place of the editor's, as one edit. */
|
||||
function adopt(text) {
|
||||
if (current) flush()
|
||||
times.length = at + 1
|
||||
times.push(text)
|
||||
at += 1
|
||||
revised(() => text)
|
||||
return show(text, current?.mode ?? 'visual')
|
||||
}
|
||||
|
||||
let placing
|
||||
/** The caret's line and the scroll, once they rest. */
|
||||
function placed() {
|
||||
clearTimeout(placing)
|
||||
placing = setTimeout(() => {
|
||||
if (!current) return
|
||||
const scroll = document.scrollingElement.scrollTop
|
||||
tell({ kind: 'place', generation, ...current.where(), scroll })
|
||||
}, 500)
|
||||
}
|
||||
document.addEventListener('selectionchange', () => {
|
||||
placed()
|
||||
follow(current)
|
||||
})
|
||||
window.addEventListener('scroll', placed, { passive: true })
|
||||
|
||||
/** Another mode for the same text; edits are sent first. */
|
||||
function mode(next) {
|
||||
if (!current) {
|
||||
if (broken !== null) return show(broken, next)
|
||||
asked = next
|
||||
return
|
||||
}
|
||||
const text = current.text()
|
||||
const ahead = current.redone?.() ?? null
|
||||
flush()
|
||||
// Leaving a mode with edits marks the timeline.
|
||||
if (text !== times[at]) {
|
||||
times.length = at + 1
|
||||
times.push(text)
|
||||
at += 1
|
||||
}
|
||||
// Undone steps stay ahead, for redo.
|
||||
if (ahead !== null && ahead !== text) {
|
||||
times.length = at + 1
|
||||
times.push(ahead)
|
||||
}
|
||||
return show(text, next)
|
||||
}
|
||||
|
||||
/** Shows `{ heading }` or `{ line, query }`. */
|
||||
function reveal(place) {
|
||||
if (!current) {
|
||||
revealing = place
|
||||
return
|
||||
}
|
||||
const shown =
|
||||
'heading' in place ? current.heading(place.heading) : current.line(place.line, place.query)
|
||||
// The app logs a line number, never the query.
|
||||
if (shown === false) tell({ kind: 'missed', generation, line: place.line ?? null })
|
||||
}
|
||||
|
||||
/** The app's find box hears where the search stands. */
|
||||
const found = (status) => tell({ kind: 'found', generation, ...status })
|
||||
|
||||
/** Searches for `spec`: search, caseSensitive, regexp, wholeWord, replace. */
|
||||
function find(spec) {
|
||||
seeking = spec
|
||||
if (current) found(current.find.seek(spec))
|
||||
}
|
||||
|
||||
/** The box's spec rules; a new query starts afresh. */
|
||||
function step({ spec, forward }) {
|
||||
const fresh = !seeking || !sameQuery(seeking, spec)
|
||||
find(spec)
|
||||
if (current && !fresh) found(current.find.step(forward))
|
||||
}
|
||||
|
||||
function replace({ spec, all }) {
|
||||
find(spec)
|
||||
if (current && current.mode !== 'preview') found(current.find.replace(all))
|
||||
}
|
||||
|
||||
function unfind() {
|
||||
seeking = null
|
||||
current?.find.clear()
|
||||
}
|
||||
|
||||
/** The writing view's settings (MARK-09). */
|
||||
function look(settings) {
|
||||
apply(settings)
|
||||
follow(current)
|
||||
}
|
||||
|
||||
/** The whole text as `kind`: html or text, tagged `ask`. */
|
||||
async function render({ kind, ask: asked }) {
|
||||
try {
|
||||
if (!current) throw new Error('no document is shown')
|
||||
const [, body] = split(current.text())
|
||||
const result = kind === 'text' ? { text: await text(body) } : await html(body)
|
||||
tell({ kind: 'rendered', generation, ask: asked, ...result })
|
||||
} catch (error) {
|
||||
tell({ kind: 'rendered', generation, ask: asked, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
/** A table step; the app hears whether it ran. */
|
||||
function table(op) {
|
||||
const done = current?.table?.(op) ?? false
|
||||
tell({ kind: 'table', generation, op, done })
|
||||
}
|
||||
|
||||
/** Image links at the caret; preview takes none. */
|
||||
function insert(images) {
|
||||
const done = current?.insert?.(images) ?? false
|
||||
tell({ kind: 'inserted', generation, done })
|
||||
}
|
||||
|
||||
window.ely = { open, refresh, adopt, look, render, mode, base, reveal, find, step, replace, unfind, table, insert }
|
||||
@@ -1,100 +0,0 @@
|
||||
// KaTeX math, from Milkdown Crepe's latex feature.
|
||||
import katex from 'katex'
|
||||
import remarkMath from 'remark-math'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { $nodeSchema, $remark } from '@milkdown/kit/utils'
|
||||
import { codeBlockSchema } from '@milkdown/kit/preset/commonmark'
|
||||
import { failure } from './failure.js'
|
||||
|
||||
/** Saved source as HTML; a bad one shows where. */
|
||||
export function render(source, display, dom) {
|
||||
try {
|
||||
katex.render(source, dom, { displayMode: display, throwOnError: true })
|
||||
} catch (error) {
|
||||
const at = error instanceof katex.ParseError ? error.position : undefined
|
||||
dom.replaceChildren(failure(source, error.message, at))
|
||||
}
|
||||
return dom
|
||||
}
|
||||
|
||||
// remark-math, plus each inline formula's `$` count from the source.
|
||||
const remark = $remark('remarkMath', () => function (options) {
|
||||
remarkMath.call(this, options)
|
||||
return (tree, file) => {
|
||||
const text = String(file.value)
|
||||
visit(tree, 'inlineMath', (node) => {
|
||||
let at = node.position?.start.offset ?? 0
|
||||
let fence = 0
|
||||
while (text[at++] === '$') fence++
|
||||
node.data = { ...node.data, fence: Math.max(fence, 1) }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Block math becomes a code block in language LaTeX.
|
||||
const blocks = $remark(
|
||||
'remarkMathBlock',
|
||||
() => () => (tree) =>
|
||||
visit(tree, 'math', (node, index, parent) => {
|
||||
parent.children.splice(index, 1, {
|
||||
type: 'code',
|
||||
lang: 'LaTeX',
|
||||
value: node.value,
|
||||
position: node.position,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const block = codeBlockSchema.extendSchema((prev) => (ctx) => {
|
||||
const base = prev(ctx)
|
||||
return {
|
||||
...base,
|
||||
toMarkdown: {
|
||||
match: base.toMarkdown.match,
|
||||
runner: (state, node) => {
|
||||
if ((node.attrs.language ?? '').toLowerCase() !== 'latex') {
|
||||
return base.toMarkdown.runner(state, node)
|
||||
}
|
||||
state.addNode('math', undefined, node.content.firstChild?.text || '')
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const inline = $nodeSchema('math_inline', () => ({
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
attrs: { value: { default: '' }, fence: { default: 1 } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'span[data-type="math_inline"]',
|
||||
getAttrs: (dom) => ({ value: dom.dataset.value ?? '', fence: Number(dom.dataset.fence ?? 1) }),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => {
|
||||
const dom = document.createElement('span')
|
||||
dom.dataset.type = 'math_inline'
|
||||
dom.dataset.value = node.attrs.value
|
||||
dom.dataset.fence = node.attrs.fence
|
||||
// `$$…$$` displays, as GitHub shows it.
|
||||
return render(node.attrs.value, node.attrs.fence > 1, dom)
|
||||
},
|
||||
parseMarkdown: {
|
||||
match: (node) => node.type === 'inlineMath',
|
||||
runner: (state, node, type) =>
|
||||
state.addNode(type, { value: node.value, fence: node.data?.fence ?? 1 }),
|
||||
},
|
||||
toMarkdown: {
|
||||
match: (node) => node.type.name === 'math_inline',
|
||||
runner: (state, node) => {
|
||||
const { value, fence } = node.attrs
|
||||
if (fence === 1) return state.addNode('inlineMath', undefined, value)
|
||||
// The stringifier knows one `$`; the source's own count stays.
|
||||
const dollars = '$'.repeat(fence)
|
||||
state.addNode('html', undefined, `${dollars}${value}${dollars}`)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
export const math = [remark, blocks, block, inline].flat()
|
||||
@@ -1,149 +0,0 @@
|
||||
// Blocks Milkdown cannot write back stay raw text (§14.3).
|
||||
import { editorViewCtx, remarkCtx, schemaCtx, serializerCtx } from '@milkdown/kit/core'
|
||||
import { canon } from './tables.js'
|
||||
import { Fragment } from '@milkdown/kit/prose/model'
|
||||
import { Selection } from '@milkdown/kit/prose/state'
|
||||
import { $nodeSchema } from '@milkdown/kit/utils'
|
||||
import { common } from './diff.js'
|
||||
|
||||
/** Source shown and written as it is. */
|
||||
export const raw = $nodeSchema('ely_raw', () => ({
|
||||
group: 'block',
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
code: true,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
parseDOM: [{ tag: 'pre.ely-raw', preserveWhitespace: 'full' }],
|
||||
toDOM: () => ['pre', { class: 'ely-raw' }, 0],
|
||||
parseMarkdown: { match: () => false, runner: () => {} },
|
||||
toMarkdown: {
|
||||
match: (node) => node.type.name === 'ely_raw',
|
||||
runner: (state, node) => state.addNode('html', undefined, node.textContent),
|
||||
},
|
||||
}))
|
||||
|
||||
/** Each block's span, as Milkdown reads it; null if unplaced. */
|
||||
function spans(ctx, text) {
|
||||
const remark = ctx.get(remarkCtx)
|
||||
const tree = remark.runSync(remark.parse(text), text)
|
||||
const at = tree.children.map(
|
||||
(node) => node.position && [node.position.start.offset, node.position.end.offset],
|
||||
)
|
||||
return at.every(Boolean) ? at : null
|
||||
}
|
||||
|
||||
/** The text in `from`..`to` past its outer whitespace. */
|
||||
function inner(text, from, to) {
|
||||
const piece = text.slice(from, to)
|
||||
const start = piece.search(/\S/)
|
||||
if (start < 0) return null
|
||||
return [from + start, from + piece.trimEnd().length]
|
||||
}
|
||||
|
||||
/** Raw blocks in place; edits then rewrite only their blocks. */
|
||||
export function faithful(ctx, original) {
|
||||
const view = ctx.get(editorViewCtx)
|
||||
const parsed = view.state.doc
|
||||
const at = spans(ctx, original)
|
||||
// No blocks: Milkdown still holds one empty paragraph.
|
||||
const blank = at?.length === 0 && parsed.childCount === 1 && parsed.textContent === ''
|
||||
if (!at || (at.length !== parsed.childCount && !blank)) return null
|
||||
const schema = ctx.get(schemaCtx)
|
||||
const serializer = ctx.get(serializerCtx)
|
||||
const written = (nodes) => serializer(schema.topNodeType.create(null, nodes)).replace(/\n+$/, '')
|
||||
// Table spacing is free; every cell's source must match.
|
||||
const kept = (node, text) => {
|
||||
const own = written([node])
|
||||
return own === text || canon(own) === canon(text)
|
||||
}
|
||||
const rawOf = ([from, to]) => {
|
||||
const text = original.slice(from, to)
|
||||
return schema.nodes.ely_raw.create(null, text ? schema.text(text) : null)
|
||||
}
|
||||
const nodes = []
|
||||
const places = []
|
||||
// Text Milkdown drops between blocks, as raw blocks.
|
||||
const gap = (from, to) => {
|
||||
const place = inner(original, from, to)
|
||||
if (!place) return
|
||||
nodes.push(rawOf(place))
|
||||
places.push(place)
|
||||
}
|
||||
let last = 0
|
||||
if (!blank)
|
||||
parsed.forEach((node, _, index) => {
|
||||
gap(last, at[index][0])
|
||||
nodes.push(kept(node, original.slice(...at[index])) ? node : rawOf(at[index]))
|
||||
places.push(at[index])
|
||||
last = at[index][1]
|
||||
})
|
||||
gap(last, original.length)
|
||||
if (nodes.length) {
|
||||
const swap = view.state.tr.replaceWith(0, parsed.content.size, Fragment.from(nodes))
|
||||
swap.setSelection(Selection.atStart(swap.doc))
|
||||
view.dispatch(swap.setMeta('addToHistory', false))
|
||||
}
|
||||
const first = view.state.doc
|
||||
const before = []
|
||||
first.forEach((node) => before.push(node))
|
||||
/** Where each fresh block sits in `text` from `at`. */
|
||||
const found = (nodes, text, at, spans) => {
|
||||
let from = 0
|
||||
for (const node of nodes) {
|
||||
const own = written([node])
|
||||
const place = text.indexOf(own, from)
|
||||
spans.push(place < 0 ? null : [at + place, at + place + own.length])
|
||||
if (place >= 0) from = place + own.length
|
||||
}
|
||||
}
|
||||
// The text and each block's span in it.
|
||||
return (doc) => {
|
||||
if (doc.eq(first)) return { text: original, spans: places }
|
||||
const after = []
|
||||
doc.forEach((node) => after.push(node))
|
||||
if (!places.length) {
|
||||
const fresh = written(after)
|
||||
const spans = []
|
||||
found(after, fresh, original.length, spans)
|
||||
return { text: `${original}${fresh}\n`, spans }
|
||||
}
|
||||
const pairs = common(before, after, (x, y) => x === y || x.eq(y))
|
||||
pairs.push([before.length, after.length])
|
||||
let out = ''
|
||||
let cursor = 0
|
||||
let [i, j] = [0, 0]
|
||||
const spans = []
|
||||
for (const [x, y] of pairs) {
|
||||
if (x > i || y > j) {
|
||||
const fresh = y > j ? written(after.slice(j, y)) : ''
|
||||
let start, end, text
|
||||
if (x > i && fresh) {
|
||||
;[start, end, text] = [places[i][0], places[x - 1][1], fresh]
|
||||
} else if (x > i && i > 0) {
|
||||
// Removed blocks take the gap before them.
|
||||
;[start, end, text] = [places[i - 1][1], places[x - 1][1], '']
|
||||
} else if (x > i && x < places.length) {
|
||||
;[start, end, text] = [places[0][0], places[x][0], '']
|
||||
} else if (x > i) {
|
||||
;[start, end, text] = [places[0][0], places[x - 1][1], '']
|
||||
} else if (i > 0) {
|
||||
;[start, end, text] = [places[i - 1][1], places[i - 1][1], `\n\n${fresh}`]
|
||||
} else {
|
||||
;[start, end, text] = [places[0][0], places[0][0], `${fresh}\n\n`]
|
||||
}
|
||||
out += original.slice(cursor, start)
|
||||
found(after.slice(j, y), text, out.length, spans)
|
||||
out += text
|
||||
cursor = end
|
||||
}
|
||||
// Unchanged blocks keep their bytes, shifted.
|
||||
if (x < places.length) {
|
||||
const shift = out.length - cursor
|
||||
spans.push([places[x][0] + shift, places[x][1] + shift])
|
||||
}
|
||||
;[i, j] = [x + 1, y + 1]
|
||||
}
|
||||
return { text: out + original.slice(cursor), spans }
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// The whole document as one page or as text (MARK-11).
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import remarkRehype from 'remark-rehype'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import { fromDom } from 'hast-util-from-dom'
|
||||
import rehypeStringify from 'rehype-stringify'
|
||||
import { toText } from 'hast-util-to-text'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { VFile } from 'vfile'
|
||||
import { bundle } from './diagrams.js'
|
||||
|
||||
const markdown = () => unified().use(remarkParse).use(remarkGfm).use(remarkMath)
|
||||
|
||||
/** Mermaid drawn for paper; a broken one stays code. */
|
||||
async function draw(tree) {
|
||||
const blocks = []
|
||||
visit(tree, 'code', (node) => {
|
||||
if (node.lang === 'mermaid') blocks.push(node)
|
||||
})
|
||||
const failed = []
|
||||
if (!blocks.length) return failed
|
||||
const paint = await bundle()
|
||||
for (const node of blocks) {
|
||||
try {
|
||||
const svg = await paint(node.value, 'paper')
|
||||
Object.assign(node, { type: 'html', value: `<figure class="diagram">${svg}</figure>` })
|
||||
} catch {
|
||||
// Lines only: Mermaid's message quotes the document.
|
||||
failed.push(node.position.start.line)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
/** A `srcset`'s first URL, split as the spec does. */
|
||||
function first(srcset) {
|
||||
const url = srcset.replace(/^[\s,]+/, '').split(/\s/)[0]
|
||||
return url.replace(/,+$/, '')
|
||||
}
|
||||
|
||||
/** One `src` per image, swapped for the app's mark. */
|
||||
function marked(html) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
const token = `ely-image-${crypto.randomUUID()}-`
|
||||
// Responsive sources go: the page carries one file per image.
|
||||
for (const source of doc.querySelectorAll('picture > source')) source.remove()
|
||||
const images = []
|
||||
for (const image of doc.querySelectorAll('img')) {
|
||||
const srcset = image.getAttribute('srcset')
|
||||
image.removeAttribute('srcset')
|
||||
image.removeAttribute('sizes')
|
||||
const src = image.getAttribute('src') || (srcset && first(srcset))
|
||||
if (!src) continue
|
||||
images.push(src)
|
||||
image.setAttribute('src', token + (images.length - 1))
|
||||
}
|
||||
return { html: doc.body.innerHTML, images, token }
|
||||
}
|
||||
|
||||
/** KaTeX's failures: line and its message, never logged. */
|
||||
function formulas(file) {
|
||||
return file.messages
|
||||
.filter((message) => message.source === 'rehype-katex')
|
||||
.map((message) => ({
|
||||
line: message.line,
|
||||
error: message.cause?.rawMessage ?? message.cause?.name ?? message.reason
|
||||
}))
|
||||
}
|
||||
|
||||
/** `body` as HTML, images marked; the failed diagrams and formulas. */
|
||||
export async function html(body) {
|
||||
const tree = markdown().parse(body)
|
||||
displayed(tree, body)
|
||||
const failed = await draw(tree)
|
||||
const page = unified()
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeKatex)
|
||||
.use(rehypeStringify, { allowDangerousHtml: true })
|
||||
const file = new VFile()
|
||||
const done = page.stringify(await page.run(tree, file), file)
|
||||
return { ...marked(done), failed, formulas: formulas(file) }
|
||||
}
|
||||
|
||||
/** `$$…$$` inline displays, as in the editor. */
|
||||
function displayed(tree, body) {
|
||||
visit(tree, 'inlineMath', (node) => {
|
||||
const at = node.position?.start.offset ?? 0
|
||||
if (body.startsWith('$$', at)) {
|
||||
node.data.hProperties = { className: ['language-math', 'math-display'] }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** `body` as plain text: blocks on lines, math as TeX. */
|
||||
export async function text(body) {
|
||||
const page = unified().use(remarkRehype, { allowDangerousHtml: true })
|
||||
const hast = await page.run(markdown().parse(body))
|
||||
// Raw HTML alone goes through the DOM; blocks keep lines.
|
||||
visit(hast, 'raw', (node, index, parent) => {
|
||||
const parsed = new DOMParser().parseFromString(node.value, 'text/html').body
|
||||
const shown = fromDom(parsed).children
|
||||
parent.children.splice(index, 1, ...shown)
|
||||
return index + shown.length
|
||||
})
|
||||
return toText(hast)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// CodeMirror 6: the Markdown source.
|
||||
import { basicSetup, EditorView } from 'codemirror'
|
||||
import { Prec } from '@codemirror/state'
|
||||
import { indentWithTab, redo, redoDepth, undoDepth } from '@codemirror/commands'
|
||||
import { keymap } from '@codemirror/view'
|
||||
import { markdown as language } from '@codemirror/lang-markdown'
|
||||
import { languages } from '@codemirror/language-data'
|
||||
import { ensureSyntaxTree } from '@codemirror/language'
|
||||
import { theme } from './theme.js'
|
||||
import { search } from '@codemirror/search'
|
||||
import { textFinder } from './find.js'
|
||||
|
||||
/** Alt text that cannot close its brackets. */
|
||||
const escape = (alt) => alt.replace(/[\\[\]]/g, '\\$&')
|
||||
|
||||
export function source(root, markdown, changed, edge) {
|
||||
const view = new EditorView({
|
||||
doc: markdown,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
language({ codeLanguages: languages }),
|
||||
EditorView.lineWrapping,
|
||||
theme,
|
||||
search(),
|
||||
// Tab indents; it never leaves the editor.
|
||||
keymap.of([indentWithTab]),
|
||||
// Past their history, undo and redo reach the page.
|
||||
Prec.low(
|
||||
keymap.of([
|
||||
{ key: 'Mod-z', run: (view) => undoDepth(view.state) === 0 && edge('undo') },
|
||||
{ key: 'Mod-Shift-z', run: (view) => redoDepth(view.state) === 0 && edge('redo') },
|
||||
{ key: 'Mod-y', run: (view) => redoDepth(view.state) === 0 && edge('redo') },
|
||||
]),
|
||||
),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) changed(() => update.state.doc.toString())
|
||||
}),
|
||||
],
|
||||
parent: root,
|
||||
})
|
||||
// ATX and setext headings from the parse, with their starts.
|
||||
const headings = () => {
|
||||
const found = []
|
||||
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 200)
|
||||
tree?.iterate({
|
||||
enter: ({ name, from, to }) => {
|
||||
const level = /^(?:ATX|Setext)Heading(\d)$/.exec(name)?.[1]
|
||||
if (!level) return
|
||||
const head = view.state.doc.sliceString(from, to).split('\n')[0]
|
||||
const title = head.replace(/^#{1,6}[ \t]*/, '').replace(/[ \t]+#+[ \t]*$/, '')
|
||||
found.push({ level: Number(level), title: title.trim(), from })
|
||||
return false
|
||||
},
|
||||
})
|
||||
return found
|
||||
}
|
||||
const reveal = (from) => {
|
||||
view.dispatch({ selection: { anchor: from }, scrollIntoView: true })
|
||||
view.focus()
|
||||
}
|
||||
return {
|
||||
// Images at the caret, as Markdown.
|
||||
insert: (images) => {
|
||||
const text = images.map(({ src, alt }) => ``).join('\n')
|
||||
view.dispatch(view.state.replaceSelection(text))
|
||||
view.focus()
|
||||
return true
|
||||
},
|
||||
find: textFinder(view),
|
||||
outline: () => headings().map(({ level, title }) => ({ level, title })),
|
||||
heading: (key) => {
|
||||
const found = headings()[key]
|
||||
if (found) reveal(found.from)
|
||||
return Boolean(found)
|
||||
},
|
||||
line: (n, query) => {
|
||||
if (n < 1 || n > view.state.doc.lines) return false
|
||||
const line = view.state.doc.line(n)
|
||||
const column = line.text.toLowerCase().indexOf(query.toLowerCase())
|
||||
reveal(line.from + Math.max(0, column))
|
||||
return true
|
||||
},
|
||||
close: () => view.destroy(),
|
||||
center: () => {
|
||||
const { head } = view.state.selection.main
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(head, { y: 'center' }) })
|
||||
},
|
||||
where: () => {
|
||||
const { head } = view.state.selection.main
|
||||
const line = view.state.doc.lineAt(head)
|
||||
return { line: line.number, column: head - line.from }
|
||||
},
|
||||
at: (n, column) => {
|
||||
if (n < 1 || n > view.state.doc.lines) return false
|
||||
const line = view.state.doc.line(n)
|
||||
reveal(line.from + Math.min(column, line.length))
|
||||
return true
|
||||
},
|
||||
text: () => view.state.doc.toString(),
|
||||
// The text once every undone step is redone.
|
||||
redone: () => {
|
||||
let state = view.state
|
||||
if (!redoDepth(state)) return null
|
||||
while (redoDepth(state)) redo({ state, dispatch: (tr) => (state = tr.state) })
|
||||
return state.doc.toString()
|
||||
},
|
||||
focus: () => view.focus(),
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// The document's own markers, so blocks write back unchanged.
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
/** Writing options most of `text` already uses; tables' for remark-gfm. */
|
||||
export function style(text) {
|
||||
const votes = {}
|
||||
const vote = (key, value) => {
|
||||
votes[key] ??= new Map()
|
||||
votes[key].set(value, (votes[key].get(value) ?? 0) + 1)
|
||||
}
|
||||
visit(unified().use(remarkParse).use(remarkGfm).parse(text), (node) => {
|
||||
const at = node.position?.start.offset
|
||||
if (at === undefined) return
|
||||
const head = text.slice(at, at + 12)
|
||||
switch (node.type) {
|
||||
case 'listItem': {
|
||||
const ordered = /^\d+([.)])/.exec(head)
|
||||
if (ordered) vote('bulletOrdered', ordered[1])
|
||||
else if (/^[*+-]/.test(head)) vote('bullet', head[0])
|
||||
break
|
||||
}
|
||||
case 'emphasis':
|
||||
case 'strong':
|
||||
vote(node.type, head[0])
|
||||
break
|
||||
case 'code':
|
||||
if (/^[`~]/.test(head)) vote('fence', head[0])
|
||||
break
|
||||
case 'thematicBreak':
|
||||
vote('rule', head[0])
|
||||
break
|
||||
case 'heading':
|
||||
if (node.depth <= 2) vote('setext', head[0] !== '#')
|
||||
break
|
||||
case 'table': {
|
||||
// Row nodes leave out quote and list prefixes.
|
||||
const rows = node.children.map((row) => text.slice(row.position.start.offset, row.position.end.offset).trim())
|
||||
const padded = /\| /.test(rows[0])
|
||||
const cells = rows.flatMap((row) => row.replace(/^\||\|$/g, '').split('|'))
|
||||
const inner = (cell) => (padded ? cell.replace(/^ | $/g, '') : cell)
|
||||
vote('tableCellPadding', padded)
|
||||
// Aligned cells carry spaces beyond the padding.
|
||||
vote('tablePipeAlign', cells.some((cell) => /^\s|\s$/.test(inner(cell))))
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
const most = (key) => [...votes[key].entries()].sort((a, b) => b[1] - a[1])[0][0]
|
||||
return Object.fromEntries(Object.keys(votes).map((key) => [key, most(key)]))
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// Tables compare by alignment and cell source, not spacing.
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { SKIP, visit } from 'unist-util-visit'
|
||||
|
||||
const reader = unified().use(remarkParse).use(remarkGfm)
|
||||
|
||||
/** A cell's own source, without its padding. */
|
||||
function cell(text, node) {
|
||||
const kids = node.children
|
||||
return kids.length ? text.slice(kids[0].position.start.offset, kids.at(-1).position.end.offset) : ''
|
||||
}
|
||||
|
||||
/** `text` with each table's spacing made canonical. */
|
||||
export function canon(text) {
|
||||
const tables = []
|
||||
visit(reader.parse(text), 'table', (node) => {
|
||||
tables.push(node)
|
||||
return SKIP
|
||||
})
|
||||
let out = text
|
||||
// Later tables first, so earlier offsets hold.
|
||||
for (const table of tables.reverse()) {
|
||||
const width = table.children[0].children.length
|
||||
// GFM fills a short row with empty cells.
|
||||
const rows = table.children.map((row) => {
|
||||
const cells = row.children.map((node) => cell(text, node))
|
||||
return [...cells, ...Array(Math.max(0, width - cells.length)).fill('')].join('|')
|
||||
})
|
||||
const shape = [table.align.join(','), ...rows].join('\n')
|
||||
out = `${out.slice(0, table.position.start.offset)}\u0000${shape}\u0000${out.slice(table.position.end.offset)}`
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// CodeMirror colors from the page's Ely tokens.
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { tags } from '@lezer/highlight'
|
||||
|
||||
const token = (name) => `var(--ely-${name})`
|
||||
|
||||
const colors = HighlightStyle.define([
|
||||
{ tag: [tags.keyword, tags.operatorKeyword, tags.modifier], color: token('keyword') },
|
||||
{ tag: [tags.string, tags.regexp, tags.special(tags.string)], color: token('string') },
|
||||
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: token('number') },
|
||||
{ tag: [tags.comment, tags.meta], color: token('comment') },
|
||||
{
|
||||
tag: [tags.function(tags.variableName), tags.function(tags.propertyName)],
|
||||
color: token('function'),
|
||||
},
|
||||
{ tag: [tags.typeName, tags.className, tags.namespace], color: token('type') },
|
||||
{ tag: [tags.tagName, tags.heading], color: token('tag'), fontWeight: 'bold' },
|
||||
{ tag: [tags.attributeName, tags.propertyName], color: token('attribute') },
|
||||
{
|
||||
tag: [tags.punctuation, tags.bracket, tags.processingInstruction],
|
||||
color: token('punctuation'),
|
||||
},
|
||||
{ tag: [tags.link, tags.url], color: token('link') },
|
||||
{ tag: tags.emphasis, fontStyle: 'italic' },
|
||||
{ tag: tags.strong, fontWeight: 'bold' },
|
||||
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
||||
])
|
||||
|
||||
const frame = EditorView.theme({
|
||||
'&': { color: token('fg'), backgroundColor: 'transparent' },
|
||||
'.cm-content': { caretColor: token('fg'), fontFamily: 'var(--ely-mono), monospace' },
|
||||
'.cm-cursor': { borderLeftColor: token('fg') },
|
||||
'.cm-gutters': { backgroundColor: 'transparent', color: token('fg-subtle'), border: 'none' },
|
||||
'.cm-activeLine, .cm-activeLineGutter': { backgroundColor: token('hover') },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
|
||||
backgroundColor: token('selection'),
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
})
|
||||
|
||||
export const theme = [frame, syntaxHighlighting(colors)]
|
||||
@@ -1,339 +0,0 @@
|
||||
// Milkdown: visual editing, or read-only preview.
|
||||
import {
|
||||
Editor,
|
||||
defaultValueCtx,
|
||||
remarkStringifyOptionsCtx,
|
||||
editorViewCtx,
|
||||
editorViewOptionsCtx,
|
||||
parserCtx,
|
||||
remarkCtx,
|
||||
rootCtx,
|
||||
} from '@milkdown/kit/core'
|
||||
import { commonmark } from '@milkdown/kit/preset/commonmark'
|
||||
import { gfm } from '@milkdown/kit/preset/gfm'
|
||||
import { history } from '@milkdown/kit/plugin/history'
|
||||
import { codeBlockComponent } from '@milkdown/kit/component/code-block'
|
||||
import { Plugin, TextSelection } from '@milkdown/kit/prose/state'
|
||||
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
|
||||
import { keymap } from '@milkdown/kit/prose/keymap'
|
||||
import { liftListItem, sinkListItem } from '@milkdown/kit/prose/schema-list'
|
||||
import { redo, redoDepth, undoDepth } from '@milkdown/kit/prose/history'
|
||||
import { $prose, callCommand } from '@milkdown/kit/utils'
|
||||
import { insertTableCommand, remarkGFMPlugin } from '@milkdown/kit/preset/gfm'
|
||||
import {
|
||||
addColumnAfter,
|
||||
addColumnBefore,
|
||||
addRowAfter,
|
||||
addRowBefore,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
} from '@milkdown/kit/prose/tables'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { image } from './image.js'
|
||||
import { math } from './math.js'
|
||||
import { code } from './code.js'
|
||||
import { diagrams } from './diagrams.js'
|
||||
import { faithful, raw } from './preserve.js'
|
||||
import { docFinder } from './find.js'
|
||||
import { search } from 'prosemirror-search'
|
||||
import { style } from './style.js'
|
||||
|
||||
/** An editor over `markdown`; null when blocks cannot map. */
|
||||
export async function visual(root, markdown, editable, texts, changed, edge, linked) {
|
||||
const owner = diagrams()
|
||||
let write = () => ({ text: markdown, spans: [] })
|
||||
// Setting up raw blocks is no edit.
|
||||
let ready = false
|
||||
const watch = $prose(
|
||||
() =>
|
||||
new Plugin({
|
||||
view: () => ({
|
||||
update: (view, before) => {
|
||||
if (ready && !view.state.doc.eq(before.doc)) changed(() => write(view.state.doc).text)
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
// Focus mode dims all blocks but the caret's.
|
||||
const current = $prose(
|
||||
() =>
|
||||
new Plugin({
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
const { $from } = state.selection
|
||||
if ($from.depth < 1) return null
|
||||
const start = $from.before(1)
|
||||
const block = Decoration.node(start, start + $from.node(1).nodeSize, {
|
||||
class: 'ely-current',
|
||||
})
|
||||
return DecorationSet.create(state.doc, [block])
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
// Preview: a click opens. Editor: Cmd (Ctrl elsewhere).
|
||||
const mac = navigator.platform.startsWith('Mac')
|
||||
const links = $prose(
|
||||
() =>
|
||||
new Plugin({
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
click: (view, event) => {
|
||||
const anchor = event.target.closest?.('a')
|
||||
if (!anchor || !view.dom.contains(anchor)) return false
|
||||
event.preventDefault()
|
||||
if (editable && !(mac ? event.metaKey : event.ctrlKey)) return true
|
||||
// The DOM href is sanitized; the mark is not.
|
||||
const node = view.state.doc.nodeAt(view.posAtDOM(anchor, 0))
|
||||
const mark = node?.marks.find((mark) => mark.type.name === 'link')
|
||||
if (!mark) throw new Error('ely: a link without its mark')
|
||||
linked(mark.attrs.href)
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
// Past their history, undo and redo reach the page.
|
||||
const ends = $prose(() =>
|
||||
keymap({
|
||||
'Mod-z': (state) => undoDepth(state) === 0 && edge('undo'),
|
||||
'Mod-Shift-z': (state) => redoDepth(state) === 0 && edge('redo'),
|
||||
'Mod-y': (state) => redoDepth(state) === 0 && edge('redo'),
|
||||
// Tab moves list items; it never leaves the editor.
|
||||
Tab: (state, dispatch) => sinkListItem(state.schema.nodes.list_item)(state, dispatch) || true,
|
||||
'Shift-Tab': (state, dispatch) =>
|
||||
liftListItem(state.schema.nodes.list_item)(state, dispatch) || true,
|
||||
}),
|
||||
)
|
||||
const made = Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, root)
|
||||
ctx.set(defaultValueCtx, markdown)
|
||||
const { tableCellPadding, tablePipeAlign, ...marks } = style(markdown)
|
||||
ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...marks }))
|
||||
ctx.set(remarkGFMPlugin.options.key, { tableCellPadding, tablePipeAlign })
|
||||
ctx.update(editorViewOptionsCtx, (prev) => ({ ...prev, editable: () => editable }))
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(image)
|
||||
.use(math)
|
||||
.use(raw)
|
||||
.config(code(texts, editable, owner))
|
||||
.use(codeBlockComponent)
|
||||
.use(watch)
|
||||
.use(current)
|
||||
.use(ends)
|
||||
.use(links)
|
||||
.use($prose(() => search()))
|
||||
.create()
|
||||
const editor = await made.catch((error) => {
|
||||
owner.release()
|
||||
throw error
|
||||
})
|
||||
const view = editor.action((ctx) => ctx.get(editorViewCtx))
|
||||
const close = () => {
|
||||
editor.destroy()
|
||||
owner.release()
|
||||
}
|
||||
const keep = editor.action((ctx) => faithful(ctx, markdown))
|
||||
if (!keep) {
|
||||
close()
|
||||
return null
|
||||
}
|
||||
write = keep
|
||||
ready = true
|
||||
// The text once every undone step is redone.
|
||||
const redone = () => {
|
||||
let state = view.state
|
||||
if (!redoDepth(state)) return null
|
||||
while (redoDepth(state)) redo(state, (tr) => (state = state.apply(tr)))
|
||||
return write(state.doc).text
|
||||
}
|
||||
// Headings, raw ones too, by the title their source gives.
|
||||
const [remark, parse] = editor.action((ctx) => [ctx.get(remarkCtx), ctx.get(parserCtx)])
|
||||
/** An mdast node's words. */
|
||||
const words = (node) => node.value ?? (node.children ?? []).map(words).join('')
|
||||
// Headings; inside raw blocks, as remark reads their source.
|
||||
const headings = () => {
|
||||
const found = []
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'heading')
|
||||
found.push({ level: node.attrs.level, title: node.textContent, pos })
|
||||
if (node.type.name !== 'ely_raw') return true
|
||||
visit(remark.parse(node.textContent), 'heading', (heading) => {
|
||||
const start = (heading.children[0] ?? heading).position.start.offset
|
||||
found.push({ level: heading.depth, title: words(heading), pos, at: pos + 1 + start })
|
||||
})
|
||||
return false
|
||||
})
|
||||
return found
|
||||
}
|
||||
// `exact`: the caret at `pos`; else in that block.
|
||||
const reveal = (pos, exact) => {
|
||||
if (editable) {
|
||||
const doc = view.state.doc
|
||||
const place = exact
|
||||
? TextSelection.create(doc, pos)
|
||||
: TextSelection.near(doc.resolve(pos + 1))
|
||||
view.dispatch(view.state.tr.setSelection(place).scrollIntoView())
|
||||
return view.focus()
|
||||
}
|
||||
const { node } = view.domAtPos(pos + 1)
|
||||
;(node.nodeType === 1 ? node : node.parentElement).scrollIntoView({ block: 'center' })
|
||||
}
|
||||
/** `node`'s text, and where a character of it sits. */
|
||||
const reading = (node, start) => {
|
||||
const chunks = []
|
||||
// Text and line breaks, as `textContent` reads them.
|
||||
node.descendants((child, at) => {
|
||||
const words = child.isText
|
||||
? child.text
|
||||
: child.isLeaf
|
||||
? child.type.spec.leafText?.(child)
|
||||
: null
|
||||
if (words) chunks.push({ text: words.toLowerCase(), at: start + 1 + at })
|
||||
})
|
||||
const at = (offset) => {
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
const last = index === chunks.length - 1
|
||||
if (offset < chunk.text.length || (last && offset === chunk.text.length))
|
||||
return chunk.at + offset
|
||||
offset -= chunk.text.length
|
||||
}
|
||||
return null
|
||||
}
|
||||
return { joined: chunks.map((chunk) => chunk.text).join(''), at }
|
||||
}
|
||||
// Non-overlapping hits, the same rule as `split`.
|
||||
const hits = (text, needle) => text.split(needle).length - 1
|
||||
/** The block holding file line `n`: its index and start. */
|
||||
const blockOf = (n) => {
|
||||
const { text, spans } = write(view.state.doc)
|
||||
let offset = 0
|
||||
for (let k = 1; k < n && offset >= 0; k++) offset = text.indexOf('\n', offset) + 1
|
||||
if (n < 1 || offset < 0) return null
|
||||
const index = spans.findIndex((span) => span && span[1] >= offset)
|
||||
if (index < 0) return null
|
||||
let pos = 0
|
||||
for (let k = 0; k < index; k++) pos += view.state.doc.child(k).nodeSize
|
||||
return { text, spans, offset, index, pos }
|
||||
}
|
||||
/** Line `n`: the query's place in its block. */
|
||||
const line = (n, query) => {
|
||||
const block = blockOf(n)
|
||||
if (!block) return false
|
||||
const { text, spans, offset, index, pos } = block
|
||||
const needle = query.toLowerCase()
|
||||
const node = view.state.doc.child(index)
|
||||
// Text before and through the line, as shown.
|
||||
const shown = (end) => {
|
||||
const source = text.slice(spans[index][0], Math.max(spans[index][0], end))
|
||||
return (node.type.name === 'ely_raw' ? source : parse(source).textContent).toLowerCase()
|
||||
}
|
||||
const ends = text.indexOf('\n', offset)
|
||||
const before = shown(offset)
|
||||
const through = shown(ends < 0 ? text.length : ends)
|
||||
const { joined, at } = reading(node, pos)
|
||||
const k = needle ? hits(before, needle) : 0
|
||||
let found = -1
|
||||
if (needle && hits(through, needle) > k) {
|
||||
let from = 0
|
||||
for (let seen = 0; seen <= k; seen++) {
|
||||
found = joined.indexOf(needle, from)
|
||||
if (found < 0) break
|
||||
from = found + needle.length
|
||||
}
|
||||
}
|
||||
// A hit hidden, as in a link: the line's start.
|
||||
const place = found >= 0 ? at(found) : at(before.length)
|
||||
reveal(place ?? pos, place !== null)
|
||||
return true
|
||||
}
|
||||
/** The caret: its block's file line, and how far in. */
|
||||
const where = () => {
|
||||
const { text, spans } = write(view.state.doc)
|
||||
const { from } = view.state.selection
|
||||
let [start, pos] = [0, 0]
|
||||
view.state.doc.forEach((node, offset, index) => {
|
||||
if (offset <= from && spans[index]) [start, pos] = [spans[index][0], offset]
|
||||
})
|
||||
return { line: text.slice(0, start).split('\n').length, column: from - pos }
|
||||
}
|
||||
/** The caret back where `where` read it. */
|
||||
const at = (n, column) => {
|
||||
const block = blockOf(n)
|
||||
if (!block) return false
|
||||
const size = view.state.doc.child(block.index).nodeSize
|
||||
reveal(block.pos + Math.min(Math.max(column, 1), size - 1) - 1, false)
|
||||
return true
|
||||
}
|
||||
// Rows and columns; false when the caret is outside tables.
|
||||
const tables = {
|
||||
'row-above': addRowBefore,
|
||||
'row-below': addRowAfter,
|
||||
'column-left': addColumnBefore,
|
||||
'column-right': addColumnAfter,
|
||||
'delete-row': deleteRow,
|
||||
'delete-column': deleteColumn,
|
||||
}
|
||||
const tablesIn = (doc) => {
|
||||
const found = []
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'table') found.push({ node, pos })
|
||||
})
|
||||
return found
|
||||
}
|
||||
// Milkdown may leave the caret before the new table.
|
||||
const newTable = () => {
|
||||
const old = new Set(tablesIn(view.state.doc).map(({ node }) => node))
|
||||
editor.action(callCommand(insertTableCommand.key, { row: 3, col: 3 }))
|
||||
const made = tablesIn(view.state.doc).find(({ node }) => !old.has(node))
|
||||
if (!made) throw new Error('the inserted table is missing')
|
||||
const cell = TextSelection.near(view.state.doc.resolve(made.pos + 1))
|
||||
view.dispatch(view.state.tr.setSelection(cell).scrollIntoView())
|
||||
return true
|
||||
}
|
||||
const table = (op) => {
|
||||
if (!editable) return false
|
||||
const done = op === 'insert' ? newTable() : tables[op](view.state, view.dispatch)
|
||||
view.focus()
|
||||
return done
|
||||
}
|
||||
/** Images at the caret: `{ src, alt }` each. */
|
||||
const insert = (images) => {
|
||||
if (!editable) return false
|
||||
const image = view.state.schema.nodes.image
|
||||
let tr = view.state.tr
|
||||
for (const { src, alt } of images)
|
||||
tr = tr.replaceSelectionWith(image.create({ src, alt, title: '' }), false)
|
||||
view.dispatch(tr.scrollIntoView())
|
||||
view.focus()
|
||||
return true
|
||||
}
|
||||
return {
|
||||
insert,
|
||||
table,
|
||||
find: docFinder(view),
|
||||
close,
|
||||
text: () => write(view.state.doc).text,
|
||||
redone,
|
||||
focus: () => editable && view.focus(),
|
||||
outline: () => headings().map(({ level, title }) => ({ level, title })),
|
||||
heading: (key) => {
|
||||
const found = headings()[key]
|
||||
if (found) reveal(found.at ?? found.pos, found.at !== undefined)
|
||||
return Boolean(found)
|
||||
},
|
||||
line,
|
||||
where,
|
||||
at,
|
||||
// The caret's line to the middle of the view.
|
||||
center: () => {
|
||||
const { top } = view.coordsAtPos(view.state.selection.head)
|
||||
window.scrollBy(0, top - window.innerHeight / 2)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -83,34 +83,11 @@ pub fn open(app: AppId, files: &[PathBuf]) -> io::Result<()> {
|
||||
}
|
||||
let mut command = Command::new(&found);
|
||||
command.args(files);
|
||||
let child = fresh_signals(&mut command).spawn()?;
|
||||
let child = command.spawn()?;
|
||||
log::info!("apps: started {} as {}", app.name(), child.id());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The started app takes signals as from a shell.
|
||||
#[cfg(unix)]
|
||||
fn fresh_signals(command: &mut Command) -> &mut Command {
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
// Chromium's ignored or blocked signals would carry over.
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGQUIT] {
|
||||
libc::signal(signal, libc::SIG_DFL);
|
||||
}
|
||||
let mut none: libc::sigset_t = std::mem::zeroed();
|
||||
libc::sigemptyset(&mut none);
|
||||
libc::pthread_sigmask(libc::SIG_SETMASK, &none, std::ptr::null_mut());
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn fresh_signals(command: &mut Command) -> &mut Command {
|
||||
command
|
||||
}
|
||||
|
||||
/// Opens `path` in the default app; a refusal says why.
|
||||
pub fn open_default(path: &Path) -> io::Result<()> {
|
||||
let mut command = if cfg!(target_os = "macos") {
|
||||
@@ -170,28 +147,3 @@ mod tests {
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod signals {
|
||||
use std::os::unix::process::ExitStatusExt as _;
|
||||
|
||||
/// A starter that ignores and blocks SIGTERM, as Chromium may.
|
||||
#[test]
|
||||
fn a_started_app_still_stops_on_sigterm() {
|
||||
unsafe {
|
||||
libc::signal(libc::SIGTERM, libc::SIG_IGN);
|
||||
let mut term: libc::sigset_t = std::mem::zeroed();
|
||||
libc::sigemptyset(&mut term);
|
||||
libc::sigaddset(&mut term, libc::SIGTERM);
|
||||
libc::pthread_sigmask(libc::SIG_BLOCK, &term, std::ptr::null_mut());
|
||||
}
|
||||
let mut command = std::process::Command::new("/bin/sleep");
|
||||
command.arg("30");
|
||||
let mut child = super::fresh_signals(&mut command).spawn().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
unsafe { libc::kill(child.id() as i32, libc::SIGTERM) };
|
||||
let status = child.wait().unwrap();
|
||||
unsafe { libc::signal(libc::SIGTERM, libc::SIG_DFL) };
|
||||
assert_eq!(status.signal(), Some(libc::SIGTERM), "{status:?}");
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,15 @@ use crate::AppId;
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Log,
|
||||
/// The web content cache (ElyMark).
|
||||
Cache,
|
||||
Recovery,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub const ALL: [Kind; 3] = [Kind::Log, Kind::Cache, Kind::Recovery];
|
||||
pub const ALL: [Kind; 2] = [Kind::Log, Kind::Recovery];
|
||||
|
||||
fn place(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Log => "ely.log",
|
||||
Kind::Cache => "web",
|
||||
Kind::Recovery => "recovery",
|
||||
}
|
||||
}
|
||||
@@ -152,15 +149,14 @@ mod tests {
|
||||
#[test]
|
||||
fn marked_kinds_go_at_start_and_others_stay() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("web/Cache")).unwrap();
|
||||
std::fs::write(dir.path().join("web/Cache/blob"), vec![0u8; 4096]).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("recovery/a")).unwrap();
|
||||
std::fs::write(dir.path().join("recovery/a/point"), b"text").unwrap();
|
||||
assert_eq!(size(&dir.path().join("web")).unwrap(), 4096);
|
||||
std::fs::write(dir.path().join(Kind::Cache.mark()), b"").unwrap();
|
||||
std::fs::write(dir.path().join("recovery/a/point"), vec![0u8; 4096]).unwrap();
|
||||
std::fs::write(dir.path().join("ely.log"), b"line").unwrap();
|
||||
assert_eq!(size(&dir.path().join("recovery")).unwrap(), 4096);
|
||||
std::fs::write(dir.path().join(Kind::Recovery.mark()), b"").unwrap();
|
||||
clear_marked_in(dir.path()).unwrap();
|
||||
assert!(!dir.path().join("web").exists(), "the cache went");
|
||||
assert!(dir.path().join("recovery/a/point").exists(), "unmarked kinds stay");
|
||||
assert!(!dir.path().join(Kind::Cache.mark()).exists(), "the mark is spent");
|
||||
assert!(!dir.path().join("recovery").exists(), "the copies went");
|
||||
assert!(dir.path().join("ely.log").exists(), "the log stays");
|
||||
assert!(!dir.path().join(Kind::Recovery.mark()).exists(), "the mark is spent");
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("settings.git.bad", "{path} is not Git 2.40 or later: {error}"),
|
||||
("settings.storage", "Storage"),
|
||||
("settings.storage.log", "This app's log"),
|
||||
("settings.storage.cache", "Web content cache"),
|
||||
("settings.storage.recovery", "Recovery copies"),
|
||||
("settings.storage.clear", "Clear"),
|
||||
("settings.storage.clear_later", "Clear at next start"),
|
||||
@@ -92,7 +91,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("settings.git.bad", "{path} 不是 2.40 或更新的 Git:{error}"),
|
||||
("settings.storage", "存储"),
|
||||
("settings.storage.log", "本应用的日志"),
|
||||
("settings.storage.cache", "网页内容缓存"),
|
||||
("settings.storage.recovery", "恢复副本"),
|
||||
("settings.storage.clear", "清除"),
|
||||
("settings.storage.clear_later", "下次启动时清除"),
|
||||
|
||||
@@ -74,9 +74,7 @@ impl SettingsView {
|
||||
None => {}
|
||||
Some(Err(error)) => section = section.row(Banner::new("storage-unread", Severity::Danger, error.clone())),
|
||||
Some(Ok(usage)) => {
|
||||
// Only ElyMark keeps web content.
|
||||
let shown = usage.iter().filter(|(kind, ..)| *kind != Kind::Cache || self.app == ely_platform::AppId::Mark);
|
||||
for (kind, bytes, clearing) in shown {
|
||||
for (kind, bytes, clearing) in usage {
|
||||
section = section.row(row(*kind, *bytes, *clearing, this.clone(), cx));
|
||||
}
|
||||
}
|
||||
@@ -188,7 +186,6 @@ impl SettingsView {
|
||||
fn name(kind: Kind, cx: &App) -> SharedString {
|
||||
match kind {
|
||||
Kind::Log => text(cx, "settings.storage.log", &[]),
|
||||
Kind::Cache => text(cx, "settings.storage.cache", &[]),
|
||||
Kind::Recovery => text(cx, "settings.storage.recovery", &[]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
[package]
|
||||
name = "ely-web-content"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
cef.workspace = true
|
||||
ely-gpui-component.workspace = true
|
||||
ely-ui.workspace = true
|
||||
futures.workspace = true
|
||||
gpui.workspace = true
|
||||
image.workspace = true
|
||||
libc.workspace = true
|
||||
log.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ely-platform.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -1,317 +0,0 @@
|
||||
//! A live WebView: input, fence, failures, release, paint.
|
||||
|
||||
use std::{
|
||||
net::{TcpListener, UdpSocket},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use ely_platform::AppId;
|
||||
use ely_web_content::{Event, WebView};
|
||||
use gpui::{
|
||||
App, AppContext as _, AsyncWindowContext, Bounds, Context, ElementInputHandler, Entity,
|
||||
Focusable as _, InputHandler as _, IntoElement, KeyUpEvent, Keystroke, ParentElement as _,
|
||||
PlatformInput, Render, Styled as _, WeakEntity, Window, div,
|
||||
};
|
||||
|
||||
/// How many views open and close to show their release.
|
||||
const CHURN: usize = 20;
|
||||
/// Views dropped before the pump makes their browsers.
|
||||
const UNMADE: usize = 5;
|
||||
/// Views dropped within 3 ms steps of opening.
|
||||
const EARLY: u64 = 10;
|
||||
|
||||
struct Probe {
|
||||
view: Entity<WebView>,
|
||||
/// Pages beside the main one, each with its own test.
|
||||
others: Vec<Entity<WebView>>,
|
||||
/// The ratio before zoom, to compare after.
|
||||
before: Option<f64>,
|
||||
/// Fenced off: nothing may reach it.
|
||||
server: TcpListener,
|
||||
/// WebRTC's way out, by UDP; nothing may reach it either.
|
||||
udp: UdpSocket,
|
||||
churned: usize,
|
||||
crashed: bool,
|
||||
fixtures: PathBuf,
|
||||
work: PathBuf,
|
||||
}
|
||||
|
||||
fn typed(text: &str) -> Keystroke {
|
||||
Keystroke {
|
||||
key_char: Some(text.into()),
|
||||
..Keystroke::parse(text).expect("a keystroke")
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits, then runs `then` with the probe and its window.
|
||||
fn later(
|
||||
this: WeakEntity<Probe>,
|
||||
ms: u64,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Probe>,
|
||||
then: impl FnOnce(&mut Probe, &mut Window, &mut Context<Probe>) + 'static,
|
||||
) {
|
||||
cx.spawn_in(window, async move |_, cx: &mut AsyncWindowContext| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(ms))
|
||||
.await;
|
||||
let _ = this.update_in(cx, then);
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
fn main_heard(
|
||||
&mut self,
|
||||
_: &Entity<WebView>,
|
||||
event: &Event,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match event {
|
||||
// Held so the gate sees the page drawn again.
|
||||
Event::Loaded if self.crashed => {
|
||||
println!("probe: recovered");
|
||||
let this = cx.weak_entity();
|
||||
later(this, 4000, window, cx, |_, _, cx| cx.quit());
|
||||
}
|
||||
Event::Loaded => {
|
||||
let port = self.server.local_addr().expect("an address").port();
|
||||
let image = format!(
|
||||
"new Image().src = 'http://127.0.0.1:{port}/pixel?x=1'; \
|
||||
try {{ new WebSocket('ws://127.0.0.1:{port}/socket'); }} catch (e) {{}} \
|
||||
try {{ const peer = new RTCPeerConnection({{ iceServers: \
|
||||
[{{ urls: 'stun:127.0.0.1:{udp}' }}] }}); peer.createDataChannel('x'); \
|
||||
peer.createOffer().then((offer) => peer.setLocalDescription(offer)); \
|
||||
}} catch (e) {{}}",
|
||||
udp = self.udp.local_addr().expect("an address").port()
|
||||
);
|
||||
self.view.read(cx).run_js(&image);
|
||||
self.view.read(cx).run_js("report('loaded')");
|
||||
}
|
||||
Event::Query(said, reply) => {
|
||||
reply.ok("ok");
|
||||
let read: serde_json::Value = serde_json::from_str(said).expect("json");
|
||||
self.step(&read, window, cx);
|
||||
}
|
||||
Event::Leave(url) => println!("probe: leave {url}"),
|
||||
Event::Printed(path, ok) => println!("probe: printed {} {ok}", path.display()),
|
||||
// Recovery comes from the shown Reload button.
|
||||
Event::Failed(why) => {
|
||||
println!("probe: main failed");
|
||||
eprintln!("probe: because {why}");
|
||||
self.crashed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn step(&mut self, read: &serde_json::Value, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let this = cx.weak_entity();
|
||||
match read["step"].as_str() {
|
||||
Some("loaded") => {
|
||||
self.before = read["ratio"].as_f64();
|
||||
println!("probe: outside={}", read["outside"]);
|
||||
self.edit(window, cx);
|
||||
}
|
||||
Some("edited") => {
|
||||
println!("probe: value={}", read["value"].as_str().unwrap_or("?"));
|
||||
println!(
|
||||
"probe: focused={}",
|
||||
read["focused"].as_str().unwrap_or("none")
|
||||
);
|
||||
println!("probe: fg={}", read["fg"].as_str().unwrap_or(""));
|
||||
let reached = self.server.accept().is_ok();
|
||||
println!(
|
||||
"probe: network={}",
|
||||
if reached { "reached" } else { "refused" }
|
||||
);
|
||||
let heard = self.udp.recv(&mut [0u8; 2048]).is_ok();
|
||||
println!("probe: udp={}", if heard { "reached" } else { "silent" });
|
||||
println!("probe: rtc={}", read["rtc"].as_str().unwrap_or("?"));
|
||||
println!(
|
||||
"probe: network={}",
|
||||
if reached { "reached" } else { "refused" }
|
||||
);
|
||||
self.view.read(cx).set_zoom(1.25);
|
||||
later(this, 800, window, cx, |probe, _, cx| {
|
||||
probe.view.read(cx).run_js("report('zoomed')");
|
||||
});
|
||||
}
|
||||
Some("zoomed") => {
|
||||
let zoom = read["ratio"].as_f64().unwrap_or(0.0) / self.before.unwrap_or(1.0);
|
||||
println!("probe: zoom={zoom:.2}");
|
||||
// A page cannot leave its bundled folder.
|
||||
self.view
|
||||
.read(cx)
|
||||
.run_js("location.href = 'https://example.com/'");
|
||||
later(this, 800, window, cx, |probe, _, cx| {
|
||||
probe.view.read(cx).run_js("report('stayed')");
|
||||
});
|
||||
}
|
||||
Some("stayed") => {
|
||||
println!("probe: stayed");
|
||||
self.churn(window, cx);
|
||||
}
|
||||
other => println!("probe: unknown step {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compose 你好, commit it, then type A.
|
||||
fn edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = self.view.clone();
|
||||
// Input runs outside this update; GPUI redraws during it.
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
let typed_in = cx.update(|window, cx| {
|
||||
let focus = view.read(cx).focus_handle(cx);
|
||||
window.focus(&focus, cx);
|
||||
let mut ime = ElementInputHandler::new(Bounds::default(), view.clone());
|
||||
ime.replace_and_mark_text_in_range(None, "ni", Some(2..2), window, cx);
|
||||
ime.replace_and_mark_text_in_range(Some(0..2), "你好", None, window, cx);
|
||||
ime.replace_text_in_range(None, "你好", window, cx);
|
||||
let stroke = typed("a");
|
||||
window.dispatch_keystroke(stroke.clone(), cx);
|
||||
window.dispatch_event(PlatformInput::KeyUp(KeyUpEvent { keystroke: stroke }), cx);
|
||||
});
|
||||
if typed_in.is_err() {
|
||||
return;
|
||||
}
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(500))
|
||||
.await;
|
||||
view.update(cx, |view, _| view.run_js("report('edited')"));
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Opens and drops pages; each must free its memory.
|
||||
fn churn(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let page = self.fixtures.join("probe.html");
|
||||
if self.churned == CHURN {
|
||||
println!("probe: closed {CHURN}");
|
||||
for _ in 0..UNMADE {
|
||||
drop(WebView::open(&page, &self.fixtures, window, cx).expect("a view"));
|
||||
}
|
||||
for step in 0..EARLY {
|
||||
let view = WebView::open(&page, &self.fixtures, window, cx).expect("a view");
|
||||
self.others.push(view.clone());
|
||||
later(cx.weak_entity(), step * 3, window, cx, move |probe, _, _| {
|
||||
probe.others.retain(|other| *other != view);
|
||||
});
|
||||
}
|
||||
println!("probe: dropped {UNMADE} unmade, {EARLY} early");
|
||||
let this = cx.weak_entity();
|
||||
later(this, 2000, window, cx, |_, _, _| {
|
||||
// The log marks what freed before the hold.
|
||||
log::info!("probe: hold");
|
||||
println!("probe: hold");
|
||||
});
|
||||
return;
|
||||
}
|
||||
self.churned += 1;
|
||||
let view = WebView::open(&page, &self.fixtures, window, cx).expect("a view");
|
||||
cx.subscribe_in(&view, window, |probe, view, event, window, cx| {
|
||||
if matches!(event, Event::Loaded) {
|
||||
probe.others.retain(|other| other != view);
|
||||
probe.churn(window, cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
self.others.push(view);
|
||||
}
|
||||
|
||||
/// A page named like a URL, one gone, one inert.
|
||||
fn edges(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let page = self.fixtures.join("inert.html");
|
||||
let inert = WebView::open_inert(&page, &self.fixtures, window, cx).expect("a view");
|
||||
cx.subscribe_in(&inert, window, |_, _, event, _, _| match event {
|
||||
Event::Loaded => println!("probe: inert loaded"),
|
||||
Event::Query(_, reply) => {
|
||||
println!("probe: inert ran");
|
||||
reply.ok("");
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.detach();
|
||||
// The same page with scripts: it must run.
|
||||
let scripted = WebView::open(&page, &self.fixtures, window, cx).expect("a view");
|
||||
cx.subscribe_in(&scripted, window, |_, _, event, window, cx| {
|
||||
if let Event::Query(_, reply) = event {
|
||||
println!("probe: scripted ran");
|
||||
reply.ok("");
|
||||
later(cx.weak_entity(), 2000, window, cx, |_, _, _| println!("probe: inert checked"));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
self.others.extend([inert, scripted]);
|
||||
let hash = self.work.join("hash");
|
||||
std::fs::create_dir_all(&hash).expect("makes");
|
||||
std::fs::copy(
|
||||
self.fixtures.join("probe.html"),
|
||||
hash.join("name#part.html"),
|
||||
)
|
||||
.expect("copies");
|
||||
let named = WebView::open(&hash.join("name#part.html"), &hash, window, cx).expect("a view");
|
||||
cx.subscribe_in(&named, window, |_, _, event, _, _| match event {
|
||||
Event::Loaded => println!("probe: hash loaded"),
|
||||
Event::Failed(_) => println!("probe: hash failed"),
|
||||
_ => {}
|
||||
})
|
||||
.detach();
|
||||
let gone = self.work.join("gone");
|
||||
std::fs::create_dir_all(&gone).expect("makes");
|
||||
let page = gone.join("page.html");
|
||||
std::fs::copy(self.fixtures.join("probe.html"), &page).expect("copies");
|
||||
let missing = WebView::open(&page, &gone, window, cx).expect("a view");
|
||||
std::fs::remove_file(&page).expect("removes");
|
||||
cx.subscribe_in(&missing, window, |_, _, event, _, _| match event {
|
||||
Event::Loaded => println!("probe: missing loaded"),
|
||||
Event::Failed(_) => println!("probe: missing failed"),
|
||||
_ => {}
|
||||
})
|
||||
.detach();
|
||||
self.others.extend([named, missing]);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Probe {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div().size_full().child(self.view.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<ely_ui::Root> {
|
||||
let work = tempfile::tempdir().expect("a temp dir").keep();
|
||||
ely_web_content::start(&work, cx).expect("CEF starts");
|
||||
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/web");
|
||||
let fixtures = std::fs::canonicalize(fixtures).expect("fixtures");
|
||||
let view = WebView::open(&fixtures.join("probe.html"), &fixtures, window, cx).expect("a view");
|
||||
let server = TcpListener::bind("127.0.0.1:0").expect("a port");
|
||||
server.set_nonblocking(true).expect("nonblocking");
|
||||
let udp = UdpSocket::bind("127.0.0.1:0").expect("a port");
|
||||
udp.set_nonblocking(true).expect("nonblocking");
|
||||
let probe = cx.new(|cx| {
|
||||
cx.subscribe_in(&view, window, Probe::main_heard).detach();
|
||||
let mut probe = Probe {
|
||||
view,
|
||||
others: Vec::new(),
|
||||
before: None,
|
||||
server,
|
||||
udp,
|
||||
churned: 0,
|
||||
crashed: false,
|
||||
fixtures,
|
||||
work,
|
||||
};
|
||||
probe.edges(window, cx);
|
||||
probe
|
||||
});
|
||||
ely_ui::root(app, Some(probe.into()), window, cx)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Some(code) = ely_web_content::subprocess() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
ely_ui::run(AppId::Mark, open);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//! CEF's helper processes inside an app bundle.
|
||||
|
||||
fn main() {
|
||||
let Some(code) = ely_web_content::subprocess() else {
|
||||
eprintln!("ely-web-helper: started without --type; CEF starts it");
|
||||
std::process::exit(2);
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
//! AppKit's Command edit keys; off-screen Blink never gets them.
|
||||
|
||||
use cef::*;
|
||||
|
||||
const SHIFT: u32 = 1 << 1;
|
||||
const CONTROL: u32 = 1 << 2;
|
||||
const ALT: u32 = 1 << 3;
|
||||
const COMMAND: u32 = 1 << 7;
|
||||
|
||||
/// What AppKit's standard bindings do, as page script.
|
||||
pub(crate) fn script(windows_key: i32, modifiers: u32) -> Option<String> {
|
||||
if modifiers & COMMAND == 0 || modifiers & (CONTROL | ALT) != 0 {
|
||||
return None;
|
||||
}
|
||||
let alter = if modifiers & SHIFT != 0 {
|
||||
"extend"
|
||||
} else {
|
||||
"move"
|
||||
};
|
||||
let (direction, unit) = match windows_key {
|
||||
0x25 => ("backward", "lineboundary"),
|
||||
0x27 => ("forward", "lineboundary"),
|
||||
0x26 => ("backward", "documentboundary"),
|
||||
0x28 => ("forward", "documentboundary"),
|
||||
// Delete to the line's start or end.
|
||||
0x08 | 0x2E if modifiers & SHIFT == 0 => {
|
||||
let (direction, command) = match windows_key {
|
||||
0x08 => ("backward", "delete"),
|
||||
_ => ("forward", "forwardDelete"),
|
||||
};
|
||||
return Some(format!(
|
||||
"{{const s=getSelection();if(s.isCollapsed)s.modify('extend','{direction}','lineboundary');document.execCommand('{command}')}}"
|
||||
));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!(
|
||||
"{{const s=getSelection();s.modify('{alter}','{direction}','{unit}');s.focusNode?.parentElement?.scrollIntoView({{block:'nearest'}})}}"
|
||||
))
|
||||
}
|
||||
|
||||
wrap_keyboard_handler! {
|
||||
pub(crate) struct Keys;
|
||||
|
||||
impl KeyboardHandler {
|
||||
fn on_key_event(
|
||||
&self,
|
||||
browser: Option<&mut Browser>,
|
||||
event: Option<&KeyEvent>,
|
||||
_os_event: *mut u8,
|
||||
) -> ::std::os::raw::c_int {
|
||||
let (Some(browser), Some(event)) = (browser, event) else { return 0 };
|
||||
let down = matches!(event.type_, KeyEventType::RAWKEYDOWN | KeyEventType::KEYDOWN);
|
||||
if !down || event.focus_on_editable_field == 0 {
|
||||
return 0;
|
||||
}
|
||||
let Some(script) = script(event.windows_key_code, event.modifiers) else { return 0 };
|
||||
let Some(frame) = browser.focused_frame() else { return 0 };
|
||||
log::debug!("web: AppKit edit key {}", event.windows_key_code);
|
||||
frame.execute_java_script(Some(&CefString::from(script.as_str())), None, 0);
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_keys_only() {
|
||||
assert!(
|
||||
script(0x28, COMMAND)
|
||||
.unwrap()
|
||||
.contains("'move','forward','documentboundary'")
|
||||
);
|
||||
assert!(
|
||||
script(0x25, COMMAND | SHIFT)
|
||||
.unwrap()
|
||||
.contains("'extend','backward','lineboundary'")
|
||||
);
|
||||
assert!(
|
||||
script(0x08, COMMAND)
|
||||
.unwrap()
|
||||
.contains("execCommand('delete')")
|
||||
);
|
||||
assert!(script(0x2E, COMMAND).unwrap().contains("forwardDelete"));
|
||||
assert_eq!(script(0x28, 0), None);
|
||||
assert_eq!(script(0x28, COMMAND | ALT), None);
|
||||
assert_eq!(script(0x41, COMMAND), None);
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
//! CEF itself: the framework, its processes, its message pump.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Mutex, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use cef::{args::Args, *};
|
||||
use futures::{FutureExt as _, StreamExt as _, channel::mpsc};
|
||||
use gpui::{App, AsyncApp, Global, WeakEntity};
|
||||
|
||||
use crate::{handlers, surface::Shared, view::WebView};
|
||||
|
||||
/// The framework beside the app, else the fetched one.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn framework_dir() -> Result<PathBuf> {
|
||||
use anyhow::Context as _;
|
||||
const NAME: &str = "Chromium Embedded Framework.framework";
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut places: Vec<PathBuf> = Vec::new();
|
||||
if let Some(dir) = std::env::var_os("ELY_CEF") {
|
||||
places.push(PathBuf::from(dir).join(NAME));
|
||||
}
|
||||
if let Some(contents) = exe.parent().and_then(Path::parent) {
|
||||
places.push(contents.join("Frameworks").join(NAME));
|
||||
// A helper sits in Frameworks/<App> Helper.app/Contents/MacOS.
|
||||
if let Some(frameworks) = contents.parent().and_then(Path::parent) {
|
||||
places.push(frameworks.join(NAME));
|
||||
}
|
||||
}
|
||||
if let Some(built) = option_env!("CEF_PATH") {
|
||||
places.push(Path::new(built).join(NAME));
|
||||
}
|
||||
places
|
||||
.into_iter()
|
||||
.find(|place| place.is_dir())
|
||||
.with_context(|| format!("no {NAME} beside {} or in engines/cef", exe.display()))
|
||||
}
|
||||
|
||||
static LOADED: OnceLock<std::result::Result<PathBuf, String>> = OnceLock::new();
|
||||
|
||||
/// Loads CEF once and sets its API version.
|
||||
fn load() -> Result<PathBuf> {
|
||||
LOADED
|
||||
.get_or_init(|| {
|
||||
let dir = open()?;
|
||||
// Versioned calls exit the process until this runs.
|
||||
let _ = api_hash(sys::CEF_API_VERSION_LAST, 0);
|
||||
log::info!("web: CEF loaded from {}", dir.display());
|
||||
Ok(dir)
|
||||
})
|
||||
.clone()
|
||||
.map_err(anyhow::Error::msg)
|
||||
}
|
||||
|
||||
/// Loads the framework; its folder.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn open() -> std::result::Result<PathBuf, String> {
|
||||
let dir = framework_dir().map_err(|error| format!("{error:#}"))?;
|
||||
let library = dir.join("Chromium Embedded Framework");
|
||||
let name = std::ffi::CString::new(library.as_os_str().as_encoded_bytes()).map_err(|error| error.to_string())?;
|
||||
if unsafe { load_library(Some(&*name.as_ptr().cast())) } != 1 {
|
||||
return Err(format!("CEF did not load from {}", library.display()));
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The folder of the libcef.dll this process linked.
|
||||
#[cfg(windows)]
|
||||
fn open() -> std::result::Result<PathBuf, String> {
|
||||
use std::os::windows::ffi::OsStringExt as _;
|
||||
use windows_sys::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW};
|
||||
let name: Vec<u16> = "libcef.dll".encode_utf16().chain([0]).collect();
|
||||
let module = unsafe { GetModuleHandleW(name.as_ptr()) };
|
||||
if module.is_null() {
|
||||
return Err(format!("libcef.dll is not loaded: {}", std::io::Error::last_os_error()));
|
||||
}
|
||||
let mut path = vec![0u16; 32_768];
|
||||
let len = unsafe { GetModuleFileNameW(module, path.as_mut_ptr(), path.len() as u32) } as usize;
|
||||
if len == 0 || len == path.len() {
|
||||
return Err(format!("libcef.dll has no path: {}", std::io::Error::last_os_error()));
|
||||
}
|
||||
let file = PathBuf::from(std::ffi::OsString::from_wide(&path[..len]));
|
||||
file.parent().map(Path::to_path_buf).ok_or_else(|| format!("{} has no folder", file.display()))
|
||||
}
|
||||
|
||||
/// The folder of the libcef.so this process linked.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn open() -> std::result::Result<PathBuf, String> {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
let mut info: libc::Dl_info = unsafe { std::mem::zeroed() };
|
||||
let entry = sys::cef_api_hash as *const libc::c_void;
|
||||
if unsafe { libc::dladdr(entry, &mut info) } == 0 || info.dli_fname.is_null() {
|
||||
return Err("libcef.so is not loaded".into());
|
||||
}
|
||||
let name = unsafe { std::ffi::CStr::from_ptr(info.dli_fname) };
|
||||
let file = PathBuf::from(std::ffi::OsStr::from_bytes(name.to_bytes()));
|
||||
file.parent().map(Path::to_path_buf).ok_or_else(|| format!("{} has no folder", file.display()))
|
||||
}
|
||||
|
||||
/// In a CEF helper process, runs it to its end.
|
||||
pub fn subprocess() -> Option<i32> {
|
||||
// CEF's own parser needs the framework; plain args do not.
|
||||
if !std::env::args().any(|arg| arg.starts_with("--type=")) {
|
||||
return None;
|
||||
}
|
||||
if let Err(error) = load() {
|
||||
eprintln!("web: {error:#}");
|
||||
return Some(1);
|
||||
}
|
||||
let args = Args::new();
|
||||
let mut app = handlers::app();
|
||||
let code = execute_process(
|
||||
Some(args.as_main_args()),
|
||||
Some(&mut app),
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
Some(code)
|
||||
}
|
||||
|
||||
/// The longest the pump waits between turns, as in cefclient.
|
||||
const FALLBACK: Duration = Duration::from_millis(1000 / 30);
|
||||
|
||||
/// Pump work CEF asked for, from any thread.
|
||||
static WAKE: Mutex<Option<mpsc::UnboundedSender<i64>>> = Mutex::new(None);
|
||||
|
||||
/// CEF wants its loop run within `delay_ms`.
|
||||
pub(crate) fn schedule(delay_ms: i64) {
|
||||
if let Some(wake) = WAKE.lock().expect("the pump lock").as_ref() {
|
||||
let _ = wake.unbounded_send(delay_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// The live views the pump hands frames to.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Engine {
|
||||
pub views: Vec<WeakEntity<WebView>>,
|
||||
}
|
||||
|
||||
impl Global for Engine {}
|
||||
|
||||
thread_local! {
|
||||
/// CEF calls asked inside GPUI's updates, run after them.
|
||||
static LATER: std::cell::RefCell<Vec<Box<dyn FnOnce()>>> = Default::default();
|
||||
}
|
||||
|
||||
/// Runs `work` on the pump's next turn.
|
||||
pub(crate) fn later(work: impl FnOnce() + 'static) {
|
||||
LATER.with_borrow_mut(|queue| queue.push(Box::new(work)));
|
||||
schedule(0);
|
||||
}
|
||||
|
||||
/// In asked order; work may ask for more.
|
||||
fn run_later() {
|
||||
loop {
|
||||
let batch = LATER.with_borrow_mut(std::mem::take);
|
||||
if batch.is_empty() {
|
||||
return;
|
||||
}
|
||||
for work in batch {
|
||||
work();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Browsers asked for, made on the pump's next turn.
|
||||
static PENDING: Mutex<Vec<(Shared, String)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Why CEF could not start; every view is told.
|
||||
static FAILED: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
pub(crate) fn failed() -> Option<String> {
|
||||
FAILED.lock().expect("the start lock").clone()
|
||||
}
|
||||
|
||||
/// Queues a browser for `surface` at `url`.
|
||||
pub(crate) fn ask_browser(surface: Shared, url: String) {
|
||||
PENDING
|
||||
.lock()
|
||||
.expect("the pending lock")
|
||||
.push((surface, url));
|
||||
schedule(0);
|
||||
}
|
||||
|
||||
/// Outside GPUI's updates: CEF may spin the run loop.
|
||||
fn make_browsers() {
|
||||
let pending = std::mem::take(&mut *PENDING.lock().expect("the pending lock"));
|
||||
for (surface, url) in pending {
|
||||
if surface.lock().expect("the surface lock").closed {
|
||||
continue;
|
||||
}
|
||||
// Off-screen pages work only in CEF's Alloy style.
|
||||
let info = WindowInfo {
|
||||
windowless_rendering_enabled: true as _,
|
||||
runtime_style: RuntimeStyle::ALLOY,
|
||||
..Default::default()
|
||||
};
|
||||
let scripts = match surface.lock().expect("the surface lock").scripts {
|
||||
true => State::ENABLED,
|
||||
false => State::DISABLED,
|
||||
};
|
||||
let settings = BrowserSettings {
|
||||
windowless_frame_rate: 60,
|
||||
javascript: scripts,
|
||||
..Default::default()
|
||||
};
|
||||
// Made at once: no view ever waits on a browser.
|
||||
let made = browser_host_create_browser_sync(
|
||||
Some(&info),
|
||||
Some(&mut handlers::client(&surface)),
|
||||
Some(&url.as_str().into()),
|
||||
Some(&settings),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
match made {
|
||||
Some(browser) => {
|
||||
handlers::adopt(&browser, surface.clone());
|
||||
surface.lock().expect("the surface lock").browser = Some(browser);
|
||||
}
|
||||
None => {
|
||||
surface.lock().expect("the surface lock").failed =
|
||||
Some(format!("CEF made no browser for {url}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts CEF in this process; once per app.
|
||||
pub fn start(data: &Path, cx: &mut App) -> Result<()> {
|
||||
if cx.has_global::<Engine>() {
|
||||
return Ok(());
|
||||
}
|
||||
crate::input::bind(cx);
|
||||
let framework = load()?;
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::mac::adopt_app_protocol()?;
|
||||
let exe = std::env::current_exe()?;
|
||||
// Bundled, CEF finds its helper apps; else this binary helps.
|
||||
let bundled = cfg!(target_os = "macos")
|
||||
&& framework.parent().is_some_and(|dir| {
|
||||
let name = exe.file_name().unwrap_or_default().to_string_lossy();
|
||||
dir.join(format!("{name} Helper.app")).is_dir()
|
||||
});
|
||||
let helper = if bundled {
|
||||
String::new()
|
||||
} else {
|
||||
exe.to_string_lossy().into_owned()
|
||||
};
|
||||
let cache = data.join("web");
|
||||
std::fs::create_dir_all(&cache)?;
|
||||
let settings = Settings {
|
||||
windowless_rendering_enabled: true as _,
|
||||
external_message_pump: true as _,
|
||||
no_sandbox: true as _,
|
||||
browser_subprocess_path: helper.as_str().into(),
|
||||
#[cfg(target_os = "macos")]
|
||||
framework_dir_path: framework.to_string_lossy().as_ref().into(),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
resources_dir_path: framework.to_string_lossy().as_ref().into(),
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
locales_dir_path: framework.join("locales").to_string_lossy().as_ref().into(),
|
||||
root_cache_path: cache.to_string_lossy().as_ref().into(),
|
||||
persist_session_cookies: false as _,
|
||||
log_severity: LogSeverity::WARNING,
|
||||
..Default::default()
|
||||
};
|
||||
let (wake, mut asked) = mpsc::unbounded();
|
||||
*WAKE.lock().expect("the pump lock") = Some(wake);
|
||||
cx.set_global(Engine::default());
|
||||
cx.spawn(async move |cx: &mut AsyncApp| {
|
||||
// Not inside an update: CEF may run AppKit's loop here.
|
||||
let args = Args::new();
|
||||
let mut app = handlers::app();
|
||||
let started = initialize(
|
||||
Some(args.as_main_args()),
|
||||
Some(&settings),
|
||||
Some(&mut app),
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
if started != 1 {
|
||||
let why = held(&cache).unwrap_or_else(|| "CEF did not initialize".into());
|
||||
log::error!("web: {why}");
|
||||
*FAILED.lock().expect("the start lock") = Some(why);
|
||||
cx.update(crate::view::deliver);
|
||||
return;
|
||||
}
|
||||
log::info!("web: CEF started, cache {}", cache.display());
|
||||
let quitting = cx.update(|cx| cx.on_app_quit(|_| async { stop() }));
|
||||
quitting.detach();
|
||||
let mut due: Option<Duration> = Some(Duration::ZERO);
|
||||
loop {
|
||||
let timer = match due {
|
||||
Some(delay) => cx.background_executor().timer(delay).boxed_local(),
|
||||
None => futures::future::pending().boxed_local(),
|
||||
};
|
||||
futures::select_biased! {
|
||||
delay = asked.next() => match delay {
|
||||
Some(delay) => {
|
||||
let delay = Duration::from_millis(delay.max(0) as u64);
|
||||
due = Some(due.map_or(delay, |now| now.min(delay)));
|
||||
continue;
|
||||
}
|
||||
None => return,
|
||||
},
|
||||
_ = timer.fuse() => {}
|
||||
}
|
||||
run_later();
|
||||
make_browsers();
|
||||
do_message_loop_work();
|
||||
cx.update(crate::view::deliver);
|
||||
// As cefclient: CEF does not ask for all work.
|
||||
due = Some(FALLBACK);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops CEF as the app quits.
|
||||
fn stop() {
|
||||
WAKE.lock().expect("the pump lock").take();
|
||||
shutdown();
|
||||
log::info!("web: CEF stopped");
|
||||
}
|
||||
|
||||
/// Why CEF would refuse this cache: another app holds it.
|
||||
#[cfg(unix)]
|
||||
fn held(cache: &Path) -> Option<String> {
|
||||
let lock = std::fs::read_link(cache.join("SingletonLock")).ok()?;
|
||||
let lock = lock.to_string_lossy();
|
||||
let pid: i32 = lock.rsplit('-').next()?.parse().ok()?;
|
||||
// Only a live process holds it.
|
||||
let alive = unsafe { libc::kill(pid, 0) } == 0;
|
||||
alive.then(|| format!("another copy (process {pid}) is using {}", cache.display()))
|
||||
}
|
||||
|
||||
/// Chromium's Windows lock names no process.
|
||||
#[cfg(windows)]
|
||||
fn held(_cache: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn cef_loads_with_its_api_version() {
|
||||
let dir = super::load().expect("CEF loads");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
assert!(dir.join("locales").is_dir(), "{} holds CEF's files", dir.display());
|
||||
// Without the version this call exits the process.
|
||||
assert!(cef::command_line_create().is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
//! The fence: where pages go, what they load.
|
||||
|
||||
use cef::{wrapper::message_router::MessageRouterBrowserSideHandlerCallbacks, *};
|
||||
|
||||
use crate::{handlers::router, surface::Shared};
|
||||
|
||||
wrap_request_handler! {
|
||||
pub(crate) struct Requests {
|
||||
surface: Shared,
|
||||
}
|
||||
|
||||
impl RequestHandler {
|
||||
fn on_before_browse(
|
||||
&self,
|
||||
browser: Option<&mut Browser>,
|
||||
frame: Option<&mut Frame>,
|
||||
request: Option<&mut Request>,
|
||||
user_gesture: ::std::os::raw::c_int,
|
||||
_is_redirect: ::std::os::raw::c_int,
|
||||
) -> ::std::os::raw::c_int {
|
||||
let url = request.map(|r| CefString::from(&r.url()).to_string()).unwrap_or_default();
|
||||
let mut surface = self.surface.lock().expect("the surface lock");
|
||||
// Content stays bundled; a clicked link leaves for the system.
|
||||
if !surface.navigable(&url) {
|
||||
if user_gesture == 1 {
|
||||
surface.leaving.push(url);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
drop(surface);
|
||||
router().on_before_browse(browser.map(|b| b.clone()), frame.map(|f| f.clone()));
|
||||
0
|
||||
}
|
||||
|
||||
fn resource_request_handler(
|
||||
&self,
|
||||
_browser: Option<&mut Browser>,
|
||||
_frame: Option<&mut Frame>,
|
||||
_request: Option<&mut Request>,
|
||||
_is_navigation: ::std::os::raw::c_int,
|
||||
_is_download: ::std::os::raw::c_int,
|
||||
_request_initiator: Option<&CefString>,
|
||||
_disable_default_handling: Option<&mut ::std::os::raw::c_int>,
|
||||
) -> Option<ResourceRequestHandler> {
|
||||
Some(Resources::new(self.surface.clone()))
|
||||
}
|
||||
|
||||
fn on_render_process_terminated(
|
||||
&self,
|
||||
browser: Option<&mut Browser>,
|
||||
status: TerminationStatus,
|
||||
error_code: ::std::os::raw::c_int,
|
||||
_error_string: Option<&CefString>,
|
||||
) {
|
||||
router().on_render_process_terminated(browser.map(|b| b.clone()));
|
||||
self.surface.lock().expect("the surface lock").failed =
|
||||
Some(format!("the page stopped: {status:?} ({error_code})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wrap_resource_request_handler! {
|
||||
struct Resources {
|
||||
surface: Shared,
|
||||
}
|
||||
|
||||
impl ResourceRequestHandler {
|
||||
// Every request, scripts and images too, meets the fence.
|
||||
fn on_before_resource_load(
|
||||
&self,
|
||||
_browser: Option<&mut Browser>,
|
||||
_frame: Option<&mut Frame>,
|
||||
request: Option<&mut Request>,
|
||||
_callback: Option<&mut Callback>,
|
||||
) -> ReturnValue {
|
||||
let Some(request) = request else {
|
||||
return ReturnValue::CANCEL;
|
||||
};
|
||||
let url = CefString::from(&request.url()).to_string();
|
||||
let image = request.resource_type() == ResourceType::IMAGE;
|
||||
if self.surface.lock().expect("the surface lock").loadable(&url, image) {
|
||||
return ReturnValue::CONTINUE;
|
||||
}
|
||||
log::warn!("web: a request outside the allowed folders was refused");
|
||||
ReturnValue::CANCEL
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
//! What CEF calls back: per process, and per browser.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
};
|
||||
|
||||
use cef::{
|
||||
wrapper::message_router::{
|
||||
BrowserSideCallback, BrowserSideHandler, BrowserSideRouter, MessageRouterBrowserSide,
|
||||
MessageRouterBrowserSideHandlerCallbacks, MessageRouterConfig, MessageRouterRendererSide,
|
||||
MessageRouterRendererSideHandlerCallbacks, RendererSideRouter,
|
||||
},
|
||||
*,
|
||||
};
|
||||
|
||||
use crate::{fence::Requests, surface::Shared};
|
||||
|
||||
/// The page calls `window.elyQuery({request, onSuccess, onFailure})`.
|
||||
fn config() -> MessageRouterConfig {
|
||||
MessageRouterConfig {
|
||||
js_query_function: "elyQuery".into(),
|
||||
js_cancel_function: "elyQueryCancel".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Each browser's surface, by CEF's browser id.
|
||||
fn surfaces() -> &'static Mutex<HashMap<i32, Shared>> {
|
||||
static SURFACES: OnceLock<Mutex<HashMap<i32, Shared>>> = OnceLock::new();
|
||||
SURFACES.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
pub(crate) fn adopt(browser: &Browser, surface: Shared) {
|
||||
surfaces()
|
||||
.lock()
|
||||
.expect("the surface lock")
|
||||
.insert(browser.identifier(), surface);
|
||||
}
|
||||
|
||||
fn surface_of(browser: Option<&Browser>) -> Option<Shared> {
|
||||
let id = browser?.identifier();
|
||||
surfaces()
|
||||
.lock()
|
||||
.expect("the surface lock")
|
||||
.get(&id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Queries go to the view whose page asked.
|
||||
struct Queries;
|
||||
|
||||
impl BrowserSideHandler for Queries {
|
||||
fn on_query_str(
|
||||
&self,
|
||||
browser: Option<Browser>,
|
||||
_frame: Option<Frame>,
|
||||
_query_id: i64,
|
||||
request: &str,
|
||||
_persistent: bool,
|
||||
callback: Arc<Mutex<dyn BrowserSideCallback>>,
|
||||
) -> bool {
|
||||
let Some(surface) = surface_of(browser.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
surface
|
||||
.lock()
|
||||
.expect("the surface lock")
|
||||
.queries
|
||||
.push((request.to_string(), callback));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn router() -> &'static Arc<BrowserSideRouter> {
|
||||
static ROUTER: OnceLock<Arc<BrowserSideRouter>> = OnceLock::new();
|
||||
ROUTER.get_or_init(|| {
|
||||
let router = BrowserSideRouter::new(config());
|
||||
router.add_handler(Arc::new(Queries), true);
|
||||
router
|
||||
})
|
||||
}
|
||||
|
||||
fn renderer_router() -> &'static Arc<RendererSideRouter> {
|
||||
static ROUTER: OnceLock<Arc<RendererSideRouter>> = OnceLock::new();
|
||||
ROUTER.get_or_init(|| RendererSideRouter::new(config()))
|
||||
}
|
||||
|
||||
/// WebRTC reaches the network by UDP; pages lose it.
|
||||
const NO_PEERS: &str = "for (const name of ['RTCPeerConnection', 'webkitRTCPeerConnection', \
|
||||
'RTCDataChannel', 'RTCSessionDescription', 'RTCIceCandidate', 'RTCRtpSender', \
|
||||
'RTCRtpReceiver', 'RTCRtpTransceiver']) delete globalThis[name];";
|
||||
|
||||
wrap_app! {
|
||||
struct ElyApp;
|
||||
|
||||
impl App {
|
||||
fn on_before_command_line_processing(
|
||||
&self,
|
||||
_process_type: Option<&CefString>,
|
||||
command_line: Option<&mut CommandLine>,
|
||||
) {
|
||||
let Some(line) = command_line else { return };
|
||||
// No keychain prompt; the content keeps no secrets.
|
||||
line.append_switch(Some(&"use-mock-keychain".into()));
|
||||
line.append_switch(Some(&"disable-extensions".into()));
|
||||
// Offline pages: all traffic meets a dead proxy.
|
||||
line.append_switch_with_value(Some(&"proxy-server".into()), Some(&"127.0.0.1:9".into()));
|
||||
line.append_switch_with_value(
|
||||
Some(&"proxy-bypass-list".into()),
|
||||
Some(&"<-loopback>".into()),
|
||||
);
|
||||
// WebRTC sends UDP past proxies; it may use proxies only.
|
||||
line.append_switch_with_value(
|
||||
Some(&"force-webrtc-ip-handling-policy".into()),
|
||||
Some(&"disable_non_proxied_udp".into()),
|
||||
);
|
||||
}
|
||||
|
||||
fn browser_process_handler(&self) -> Option<BrowserProcessHandler> {
|
||||
Some(Pump::new())
|
||||
}
|
||||
|
||||
fn render_process_handler(&self) -> Option<RenderProcessHandler> {
|
||||
Some(Renderer::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn app() -> cef::App {
|
||||
ElyApp::new()
|
||||
}
|
||||
|
||||
wrap_browser_process_handler! {
|
||||
struct Pump;
|
||||
|
||||
impl BrowserProcessHandler {
|
||||
fn on_schedule_message_pump_work(&self, delay_ms: i64) {
|
||||
crate::engine::schedule(delay_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wrap_render_process_handler! {
|
||||
struct Renderer;
|
||||
|
||||
impl RenderProcessHandler {
|
||||
fn on_context_created(
|
||||
&self,
|
||||
browser: Option<&mut Browser>,
|
||||
frame: Option<&mut Frame>,
|
||||
context: Option<&mut V8Context>,
|
||||
) {
|
||||
// Before any page script: no peer connections at all.
|
||||
if let Some(context) = context.as_ref() {
|
||||
let code = CefString::from(NO_PEERS);
|
||||
let (mut value, mut thrown) = (None, None);
|
||||
if context.eval(Some(&code), None, 0, Some(&mut value), Some(&mut thrown)) != 1 {
|
||||
eprintln!("web: WebRTC could not be removed from a page");
|
||||
}
|
||||
}
|
||||
renderer_router().on_context_created(
|
||||
browser.map(|b| b.clone()),
|
||||
frame.map(|f| f.clone()),
|
||||
context.map(|c| c.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
fn on_context_released(
|
||||
&self,
|
||||
browser: Option<&mut Browser>,
|
||||
frame: Option<&mut Frame>,
|
||||
context: Option<&mut V8Context>,
|
||||
) {
|
||||
renderer_router().on_context_released(
|
||||
browser.map(|b| b.clone()),
|
||||
frame.map(|f| f.clone()),
|
||||
context.map(|c| c.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
fn on_process_message_received(
|
||||
&self,
|
||||
browser: Option<&mut Browser>,
|
||||
frame: Option<&mut Frame>,
|
||||
source_process: ProcessId,
|
||||
message: Option<&mut ProcessMessage>,
|
||||
) -> ::std::os::raw::c_int {
|
||||
renderer_router().on_process_message_received(
|
||||
browser.map(|b| b.clone()),
|
||||
frame.map(|f| f.clone()),
|
||||
Some(source_process),
|
||||
message.map(|m| m.clone()),
|
||||
) as _
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wrap_render_handler! {
|
||||
struct Paint {
|
||||
surface: Shared,
|
||||
}
|
||||
|
||||
impl RenderHandler {
|
||||
fn view_rect(&self, _browser: Option<&mut Browser>, rect: Option<&mut Rect>) {
|
||||
let Some(rect) = rect else { return };
|
||||
let surface = self.surface.lock().expect("the surface lock");
|
||||
// CEF needs a page at least one point square.
|
||||
rect.width = (surface.size.0.round() as i32).max(1);
|
||||
rect.height = (surface.size.1.round() as i32).max(1);
|
||||
}
|
||||
|
||||
fn screen_info(
|
||||
&self,
|
||||
_browser: Option<&mut Browser>,
|
||||
screen_info: Option<&mut ScreenInfo>,
|
||||
) -> ::std::os::raw::c_int {
|
||||
let Some(info) = screen_info else { return 0 };
|
||||
info.device_scale_factor = self.surface.lock().expect("the surface lock").scale;
|
||||
1
|
||||
}
|
||||
|
||||
fn on_paint(
|
||||
&self,
|
||||
_browser: Option<&mut Browser>,
|
||||
type_: PaintElementType,
|
||||
_dirty_rects: Option<&[Rect]>,
|
||||
buffer: *const u8,
|
||||
width: ::std::os::raw::c_int,
|
||||
height: ::std::os::raw::c_int,
|
||||
) {
|
||||
// Popups (select menus) are not drawn; pages avoid them.
|
||||
if type_ != PaintElementType::VIEW || buffer.is_null() || width <= 0 || height <= 0 {
|
||||
return;
|
||||
}
|
||||
let length = width as usize * height as usize * 4;
|
||||
let bgra = unsafe { std::slice::from_raw_parts(buffer, length) }.to_vec();
|
||||
self.surface.lock().expect("the surface lock").frame =
|
||||
Some((width as u32, height as u32, bgra));
|
||||
}
|
||||
|
||||
fn on_ime_composition_range_changed(
|
||||
&self,
|
||||
_browser: Option<&mut Browser>,
|
||||
_selected_range: Option<&Range>,
|
||||
character_bounds: Option<&[Rect]>,
|
||||
) {
|
||||
let bounds = character_bounds.unwrap_or_default();
|
||||
self.surface.lock().expect("the surface lock").ime = bounds
|
||||
.iter()
|
||||
.map(|r| (r.x as f32, r.y as f32, r.width as f32, r.height as f32))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wrap_display_handler! {
|
||||
struct Display {
|
||||
surface: Shared,
|
||||
}
|
||||
|
||||
impl DisplayHandler {
|
||||
fn on_console_message(
|
||||
&self,
|
||||
_browser: Option<&mut Browser>,
|
||||
level: LogSeverity,
|
||||
_message: Option<&CefString>,
|
||||
_source: Option<&CefString>,
|
||||
line: ::std::os::raw::c_int,
|
||||
) -> ::std::os::raw::c_int {
|
||||
// Words and sources a document may forge stay out.
|
||||
if level.get_raw() >= LogSeverity::WARNING.get_raw() {
|
||||
log::warn!("web: the page warned at line {line}");
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn on_cursor_change(
|
||||
&self,
|
||||
_browser: Option<&mut Browser>,
|
||||
_cursor: CursorHandle,
|
||||
type_: CursorType,
|
||||
_custom_cursor_info: Option<&CursorInfo>,
|
||||
) -> ::std::os::raw::c_int {
|
||||
self.surface.lock().expect("the surface lock").cursor = type_;
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wrap_load_handler! {
|
||||
struct Load {
|
||||
surface: Shared,
|
||||
}
|
||||
|
||||
impl LoadHandler {
|
||||
fn on_load_start(
|
||||
&self,
|
||||
_browser: Option<&mut Browser>,
|
||||
frame: Option<&mut Frame>,
|
||||
_transition_type: TransitionType,
|
||||
) {
|
||||
if frame.is_some_and(|frame| frame.is_main() == 1) {
|
||||
self.surface.lock().expect("the surface lock").errored = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn on_load_error(
|
||||
&self,
|
||||
_browser: Option<&mut Browser>,
|
||||
frame: Option<&mut Frame>,
|
||||
error_code: Errorcode,
|
||||
error_text: Option<&CefString>,
|
||||
failed_url: Option<&CefString>,
|
||||
) {
|
||||
// A stopped load is no failure; a fenced one is.
|
||||
if frame.is_none_or(|frame| frame.is_main() != 1) || error_code == Errorcode::ABORTED {
|
||||
return;
|
||||
}
|
||||
let text = error_text.map(CefString::to_string).unwrap_or_default();
|
||||
let url = failed_url.map(CefString::to_string).unwrap_or_default();
|
||||
let mut surface = self.surface.lock().expect("the surface lock");
|
||||
surface.errored = true;
|
||||
surface.failed = Some(format!("{text} ({}) {url}", error_code.get_raw()));
|
||||
}
|
||||
|
||||
fn on_load_end(
|
||||
&self,
|
||||
_browser: Option<&mut Browser>,
|
||||
frame: Option<&mut Frame>,
|
||||
_http_status_code: ::std::os::raw::c_int,
|
||||
) {
|
||||
if frame.is_some_and(|frame| frame.is_main() == 1) {
|
||||
let mut surface = self.surface.lock().expect("the surface lock");
|
||||
surface.loaded = !surface.errored;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wrap_life_span_handler! {
|
||||
struct Life {
|
||||
surface: Shared,
|
||||
}
|
||||
|
||||
impl LifeSpanHandler {
|
||||
fn on_after_created(&self, browser: Option<&mut Browser>) {
|
||||
if let Some(browser) = browser {
|
||||
log::info!("web: browser {} made", browser.identifier());
|
||||
}
|
||||
}
|
||||
|
||||
fn on_before_close(&self, browser: Option<&mut Browser>) {
|
||||
let browser = browser.map(|b| b.clone());
|
||||
router().on_before_close(browser.clone());
|
||||
if let Some(browser) = browser {
|
||||
surfaces().lock().expect("the surface lock").remove(&browser.identifier());
|
||||
}
|
||||
// The browser holds these handlers; they hold the surface.
|
||||
self.surface.lock().expect("the surface lock").browser = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wrap_client! {
|
||||
struct Handlers {
|
||||
paint: RenderHandler,
|
||||
display: DisplayHandler,
|
||||
load: LoadHandler,
|
||||
life: LifeSpanHandler,
|
||||
requests: RequestHandler,
|
||||
keys: Option<KeyboardHandler>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn keyboard_handler(&self) -> Option<KeyboardHandler> {
|
||||
self.keys.clone()
|
||||
}
|
||||
|
||||
fn render_handler(&self) -> Option<RenderHandler> {
|
||||
Some(self.paint.clone())
|
||||
}
|
||||
|
||||
fn display_handler(&self) -> Option<DisplayHandler> {
|
||||
Some(self.display.clone())
|
||||
}
|
||||
|
||||
fn load_handler(&self) -> Option<LoadHandler> {
|
||||
Some(self.load.clone())
|
||||
}
|
||||
|
||||
fn life_span_handler(&self) -> Option<LifeSpanHandler> {
|
||||
Some(self.life.clone())
|
||||
}
|
||||
|
||||
fn request_handler(&self) -> Option<RequestHandler> {
|
||||
Some(self.requests.clone())
|
||||
}
|
||||
|
||||
fn on_process_message_received(
|
||||
&self,
|
||||
browser: Option<&mut Browser>,
|
||||
frame: Option<&mut Frame>,
|
||||
source_process: ProcessId,
|
||||
message: Option<&mut ProcessMessage>,
|
||||
) -> ::std::os::raw::c_int {
|
||||
router().on_process_message_received(
|
||||
browser.map(|b| b.clone()),
|
||||
frame.map(|f| f.clone()),
|
||||
source_process,
|
||||
message.map(|m| m.clone()),
|
||||
) as _
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A browser's handlers, all sharing its surface.
|
||||
pub(crate) fn client(surface: &Shared) -> cef::Client {
|
||||
Handlers::new(
|
||||
Paint::new(surface.clone()),
|
||||
Display::new(surface.clone()),
|
||||
Load::new(surface.clone()),
|
||||
Life::new(surface.clone()),
|
||||
Requests::new(surface.clone()),
|
||||
keys(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn keys() -> Option<KeyboardHandler> {
|
||||
Some(crate::edit::Keys::new())
|
||||
}
|
||||
|
||||
/// Elsewhere Chromium binds these keys itself.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn keys() -> Option<KeyboardHandler> {
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use cef::{CefString, ImplDisplayHandler, LogSeverity};
|
||||
|
||||
struct Kept;
|
||||
|
||||
fn kept() -> &'static Mutex<Vec<String>> {
|
||||
static KEPT: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
|
||||
KEPT.get_or_init(Mutex::default)
|
||||
}
|
||||
|
||||
impl log::Log for Kept {
|
||||
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn log(&self, record: &log::Record) {
|
||||
kept().lock().expect("the kept lock").push(record.args().to_string());
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pages_words_and_sources_never_reach_the_log() {
|
||||
log::set_boxed_logger(Box::new(Kept)).expect("the only logger");
|
||||
log::set_max_level(log::LevelFilter::Trace);
|
||||
let surface = crate::surface::Surface::new(std::env::temp_dir(), 1.0);
|
||||
let display = super::Display::new(Arc::new(Mutex::new(surface)));
|
||||
let said = CefString::from("ely: M11_SECRET");
|
||||
let at = CefString::from("file:///content/M11_SECRET.js");
|
||||
for level in [LogSeverity::WARNING, LogSeverity::ERROR, LogSeverity::INFO] {
|
||||
display.on_console_message(None, level, Some(&said), Some(&at), 7);
|
||||
}
|
||||
let records = kept().lock().expect("the kept lock").clone();
|
||||
let warned = records.iter().filter(|said| *said == "web: the page warned at line 7");
|
||||
assert_eq!(warned.count(), 2, "warnings and errors stay seen: {records:#?}");
|
||||
assert!(!records.iter().any(|said| said.contains("M11_SECRET")), "{records:#?}");
|
||||
}
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
//! GPUI's mouse, keys, focus and IME, as CEF events.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use cef::*;
|
||||
use gpui::{
|
||||
App, Bounds, Context, Div, Entity, EntityInputHandler, InteractiveElement as _, KeyDownEvent,
|
||||
KeyUpEvent, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
|
||||
Point, ScrollDelta, ScrollWheelEvent, UTF16Selection, Window, point, px, size,
|
||||
};
|
||||
|
||||
use crate::{keys, view::WebView};
|
||||
|
||||
gpui::actions!(web, [PageTab, PageBackTab]);
|
||||
|
||||
/// The page's key context: Tab stays in the page.
|
||||
const CONTEXT: &str = "WebView";
|
||||
|
||||
/// Tab and Shift-Tab go to the page, not to focus.
|
||||
pub(crate) fn bind(cx: &mut App) {
|
||||
cx.bind_keys([
|
||||
gpui::KeyBinding::new("tab", PageTab, Some(CONTEXT)),
|
||||
gpui::KeyBinding::new("shift-tab", PageBackTab, Some(CONTEXT)),
|
||||
]);
|
||||
}
|
||||
|
||||
/// CEF's `cef_event_flags_t` for these modifiers.
|
||||
pub(crate) fn flags(modifiers: &Modifiers) -> u32 {
|
||||
let mut flags = 0;
|
||||
for (on, bit) in [
|
||||
(modifiers.shift, 1 << 1),
|
||||
(modifiers.control, 1 << 2),
|
||||
(modifiers.alt, 1 << 3),
|
||||
(modifiers.platform, 1 << 7),
|
||||
] {
|
||||
if on {
|
||||
flags |= bit;
|
||||
}
|
||||
}
|
||||
flags
|
||||
}
|
||||
|
||||
fn button(button: MouseButton) -> Option<(MouseButtonType, u32)> {
|
||||
match button {
|
||||
MouseButton::Left => Some((MouseButtonType::LEFT, 1 << 4)),
|
||||
MouseButton::Middle => Some((MouseButtonType::MIDDLE, 1 << 5)),
|
||||
MouseButton::Right => Some((MouseButtonType::RIGHT, 1 << 6)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl WebView {
|
||||
/// A window point as a page point.
|
||||
fn at(&self, position: Point<Pixels>, modifiers: u32) -> MouseEvent {
|
||||
let local = position - self.bounds.origin;
|
||||
MouseEvent {
|
||||
x: f32::from(local.x).round() as i32,
|
||||
y: f32::from(local.y).round() as i32,
|
||||
modifiers,
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.focus(&self.focus, cx);
|
||||
let Some((kind, held)) = button(event.button) else {
|
||||
return;
|
||||
};
|
||||
let at = self.at(event.position, flags(&event.modifiers) | held);
|
||||
let count = event.click_count as i32;
|
||||
self.on_host(move |host| host.send_mouse_click_event(Some(&at), kind, false as _, count));
|
||||
}
|
||||
|
||||
fn mouse_up(&mut self, event: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
|
||||
let Some((kind, _)) = button(event.button) else {
|
||||
return;
|
||||
};
|
||||
let at = self.at(event.position, flags(&event.modifiers));
|
||||
let count = event.click_count as i32;
|
||||
self.on_host(move |host| host.send_mouse_click_event(Some(&at), kind, true as _, count));
|
||||
}
|
||||
|
||||
fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, _: &mut Context<Self>) {
|
||||
let held = event
|
||||
.pressed_button
|
||||
.and_then(button)
|
||||
.map_or(0, |(_, bit)| bit);
|
||||
let at = self.at(event.position, flags(&event.modifiers) | held);
|
||||
self.on_host(move |host| host.send_mouse_move_event(Some(&at), false as _));
|
||||
}
|
||||
|
||||
fn scroll(&mut self, event: &ScrollWheelEvent, _: &mut Window, _: &mut Context<Self>) {
|
||||
// Lines as Chromium counts them: forty points.
|
||||
let delta = match event.delta {
|
||||
ScrollDelta::Pixels(delta) => (f32::from(delta.x), f32::from(delta.y)),
|
||||
ScrollDelta::Lines(delta) => (delta.x * 40.0, delta.y * 40.0),
|
||||
};
|
||||
let at = self.at(event.position, flags(&event.modifiers));
|
||||
let (x, y) = (delta.0.round() as i32, delta.1.round() as i32);
|
||||
self.on_host(move |host| host.send_mouse_wheel_event(Some(&at), x, y));
|
||||
}
|
||||
|
||||
fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let stroke = &event.keystroke;
|
||||
// Edit commands Chromium only takes from its own menu.
|
||||
let edit = stroke.key.clone();
|
||||
if keys::edit_chord(&stroke.modifiers) && matches!(edit.as_str(), "c" | "x" | "v" | "a") {
|
||||
// The owner may take a paste, such as an image.
|
||||
if edit == "v" && self.paste.clone().is_some_and(|take| take(window, cx)) {
|
||||
return cx.stop_propagation();
|
||||
}
|
||||
self.on_browser(move |browser| {
|
||||
let Some(frame) = browser.main_frame() else {
|
||||
return;
|
||||
};
|
||||
match edit.as_str() {
|
||||
"c" => frame.copy(),
|
||||
"x" => frame.cut(),
|
||||
"v" => frame.paste(),
|
||||
_ => frame.select_all(),
|
||||
}
|
||||
});
|
||||
return cx.stop_propagation();
|
||||
}
|
||||
let Some(code) = keys::code(&stroke.key) else {
|
||||
return log::debug!("web: no key code for {}", stroke.key);
|
||||
};
|
||||
let event = KeyEvent {
|
||||
modifiers: flags(&stroke.modifiers),
|
||||
windows_key_code: code.windows,
|
||||
native_key_code: code.native,
|
||||
..Default::default()
|
||||
};
|
||||
let chord = stroke.modifiers.control || stroke.modifiers.platform;
|
||||
match (keys::character(&stroke.key), &stroke.key_char) {
|
||||
// Typed text arrives by the input handler, which sends it.
|
||||
(None, Some(_)) if !chord => self.typing = Some(event),
|
||||
(character, typed) => {
|
||||
let character = character
|
||||
.or_else(|| typed.as_deref().and_then(|t| t.encode_utf16().next()))
|
||||
.unwrap_or(0);
|
||||
self.press(event, character);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab, with Shift when `back`, down and up.
|
||||
fn tab(&mut self, back: bool) {
|
||||
let code = keys::code("tab").expect("tab has a key code");
|
||||
let event = KeyEvent {
|
||||
modifiers: if back { 1 << 1 } else { 0 },
|
||||
windows_key_code: code.windows,
|
||||
native_key_code: code.native,
|
||||
..Default::default()
|
||||
};
|
||||
self.press(event.clone(), 0x09);
|
||||
let up = KeyEvent {
|
||||
type_: KeyEventType::KEYUP,
|
||||
character: 0x09,
|
||||
unmodified_character: 0x09,
|
||||
..event
|
||||
};
|
||||
self.on_host(move |host| host.send_key_event(Some(&up)));
|
||||
}
|
||||
|
||||
/// A key down and its character, as cefclient sends them.
|
||||
fn press(&self, event: KeyEvent, character: u16) {
|
||||
let mut event = KeyEvent {
|
||||
type_: KeyEventType::KEYDOWN,
|
||||
character,
|
||||
unmodified_character: character,
|
||||
..event
|
||||
};
|
||||
self.on_host(move |host| {
|
||||
host.send_key_event(Some(&event));
|
||||
event.type_ = KeyEventType::CHAR;
|
||||
host.send_key_event(Some(&event));
|
||||
});
|
||||
}
|
||||
|
||||
fn key_up(&mut self, event: &KeyUpEvent, _: &mut Window, _: &mut Context<Self>) {
|
||||
let stroke = &event.keystroke;
|
||||
let Some(code) = keys::code(&stroke.key) else {
|
||||
return;
|
||||
};
|
||||
// An up without its character reads as another down.
|
||||
let character = keys::character(&stroke.key)
|
||||
.or_else(|| {
|
||||
stroke
|
||||
.key_char
|
||||
.as_deref()
|
||||
.and_then(|t| t.encode_utf16().next())
|
||||
})
|
||||
.or_else(|| stroke.key.encode_utf16().next())
|
||||
.unwrap_or(0);
|
||||
let up = KeyEvent {
|
||||
type_: KeyEventType::KEYUP,
|
||||
modifiers: flags(&stroke.modifiers),
|
||||
windows_key_code: code.windows,
|
||||
native_key_code: code.native,
|
||||
character,
|
||||
unmodified_character: character,
|
||||
..Default::default()
|
||||
};
|
||||
self.on_host(move |host| host.send_key_event(Some(&up)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The view's listeners on its outer element.
|
||||
pub(crate) fn listen(element: Div, cx: &mut Context<WebView>) -> Div {
|
||||
element
|
||||
.key_context(CONTEXT)
|
||||
.on_action(cx.listener(|view, _: &PageTab, _, _| view.tab(false)))
|
||||
.on_action(cx.listener(|view, _: &PageBackTab, _, _| view.tab(true)))
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(WebView::mouse_down))
|
||||
.on_mouse_down(MouseButton::Right, cx.listener(WebView::mouse_down))
|
||||
.on_mouse_down(MouseButton::Middle, cx.listener(WebView::mouse_down))
|
||||
.on_mouse_up(MouseButton::Left, cx.listener(WebView::mouse_up))
|
||||
.on_mouse_up(MouseButton::Right, cx.listener(WebView::mouse_up))
|
||||
.on_mouse_up(MouseButton::Middle, cx.listener(WebView::mouse_up))
|
||||
.on_mouse_move(cx.listener(WebView::mouse_move))
|
||||
.on_scroll_wheel(cx.listener(WebView::scroll))
|
||||
.on_key_down(cx.listener(WebView::key_down))
|
||||
.on_key_up(cx.listener(WebView::key_up))
|
||||
}
|
||||
|
||||
/// CEF's page focus follows the view's.
|
||||
pub(crate) fn follow_focus(view: &Entity<WebView>, window: &mut Window, cx: &mut App) {
|
||||
view.update(cx, |this, cx| {
|
||||
let focus = this.focus.clone();
|
||||
cx.on_focus(&focus, window, |view, _, _| {
|
||||
view.on_host(|host| host.set_focus(true as _));
|
||||
})
|
||||
.detach();
|
||||
cx.on_blur(&focus, window, |view, _, _| {
|
||||
view.on_host(|host| host.set_focus(false as _));
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
|
||||
/// CEF's "no range": both ends at the maximum.
|
||||
const NO_RANGE: cef::Range = cef::Range {
|
||||
from: u32::MAX,
|
||||
to: u32::MAX,
|
||||
};
|
||||
|
||||
fn utf16(text: &str) -> Vec<u16> {
|
||||
text.encode_utf16().collect()
|
||||
}
|
||||
|
||||
impl EntityInputHandler for WebView {
|
||||
/// The IME sees none of the page's text.
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
_: Range<usize>,
|
||||
_: &mut Option<Range<usize>>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_: bool,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<UTF16Selection> {
|
||||
None
|
||||
}
|
||||
|
||||
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
|
||||
self.composing.clone()
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {
|
||||
if self.composing.take().is_some() {
|
||||
self.on_host(|host| host.ime_finish_composing_text(false as _));
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
_: Option<Range<usize>>,
|
||||
text: &str,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) {
|
||||
let typed = self.typing.take();
|
||||
let was = self.composing.take();
|
||||
let units = utf16(text);
|
||||
// One character, no composition: a key press, as cefclient.
|
||||
if let (Some(event), None, [character]) = (typed, was, units.as_slice()) {
|
||||
return self.press(event, *character);
|
||||
}
|
||||
let text = CefString::from(text);
|
||||
self.on_host(move |host| host.ime_commit_text(Some(&text), Some(&NO_RANGE), 0));
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
_: Option<Range<usize>>,
|
||||
new_text: &str,
|
||||
new_selected_range: Option<Range<usize>>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) {
|
||||
let length = utf16(new_text).len();
|
||||
self.composing = (length > 0).then_some(0..length);
|
||||
let selection = new_selected_range.unwrap_or(length..length);
|
||||
let range = cef::Range {
|
||||
from: selection.start as _,
|
||||
to: selection.end as _,
|
||||
};
|
||||
let text = CefString::from(new_text);
|
||||
self.on_host(move |host| {
|
||||
host.ime_set_composition(Some(&text), None, Some(&NO_RANGE), Some(&range))
|
||||
});
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
range: Range<usize>,
|
||||
element: Bounds<Pixels>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
let surface = self.surface.lock().expect("the surface lock");
|
||||
let (x, y, w, h) = *surface.ime.get(range.start).or(surface.ime.last())?;
|
||||
Some(Bounds::new(
|
||||
element.origin + point(px(x), px(y)),
|
||||
size(px(w), px(h)),
|
||||
))
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
_: Point<Pixels>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
//! GPUI key names as the codes Chromium reads.
|
||||
|
||||
use gpui::Modifiers;
|
||||
|
||||
/// Windows' virtual key, and macOS's hardware key.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Code {
|
||||
pub windows: i32,
|
||||
pub native: i32,
|
||||
}
|
||||
|
||||
/// Letters, then digits, as macOS numbers its keys.
|
||||
const MAC_LETTERS: [i32; 26] = [
|
||||
0, 11, 8, 2, 14, 3, 5, 4, 34, 38, 40, 37, 46, 45, 31, 35, 12, 15, 1, 17, 32, 9, 13, 7, 16, 6,
|
||||
];
|
||||
const MAC_DIGITS: [i32; 10] = [29, 18, 19, 20, 21, 23, 22, 26, 28, 25];
|
||||
const MAC_F: [i32; 12] = [122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111];
|
||||
|
||||
/// Named keys: GPUI's name, Windows, macOS.
|
||||
const NAMED: &[(&str, i32, i32)] = &[
|
||||
("backspace", 0x08, 51),
|
||||
("tab", 0x09, 48),
|
||||
("enter", 0x0D, 36),
|
||||
("escape", 0x1B, 53),
|
||||
("space", 0x20, 49),
|
||||
("pageup", 0x21, 116),
|
||||
("pagedown", 0x22, 121),
|
||||
("end", 0x23, 119),
|
||||
("home", 0x24, 115),
|
||||
("left", 0x25, 123),
|
||||
("up", 0x26, 126),
|
||||
("right", 0x27, 124),
|
||||
("down", 0x28, 125),
|
||||
("delete", 0x2E, 117),
|
||||
(";", 0xBA, 41),
|
||||
("=", 0xBB, 24),
|
||||
(",", 0xBC, 43),
|
||||
("-", 0xBD, 27),
|
||||
(".", 0xBE, 47),
|
||||
("/", 0xBF, 44),
|
||||
("`", 0xC0, 50),
|
||||
("[", 0xDB, 33),
|
||||
("\\", 0xDC, 42),
|
||||
("]", 0xDD, 30),
|
||||
("'", 0xDE, 39),
|
||||
];
|
||||
|
||||
pub fn code(key: &str) -> Option<Code> {
|
||||
if let Some(&(_, windows, native)) = NAMED.iter().find(|(name, _, _)| *name == key) {
|
||||
return Some(Code { windows, native });
|
||||
}
|
||||
let mut chars = key.chars();
|
||||
let (Some(c), None) = (chars.next(), chars.next()) else {
|
||||
let n: usize = key.strip_prefix('f')?.parse().ok()?;
|
||||
let native = *MAC_F.get(n.checked_sub(1)?)?;
|
||||
return Some(Code {
|
||||
windows: 0x6F + n as i32,
|
||||
native,
|
||||
});
|
||||
};
|
||||
match c {
|
||||
'a'..='z' => Some(Code {
|
||||
windows: c.to_ascii_uppercase() as i32,
|
||||
native: MAC_LETTERS[(c as u8 - b'a') as usize],
|
||||
}),
|
||||
'0'..='9' => Some(Code {
|
||||
windows: c as i32,
|
||||
native: MAC_DIGITS[(c as u8 - b'0') as usize],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The character AppKit gives keys that type none.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn character(key: &str) -> Option<u16> {
|
||||
let n: Option<u16> = key.strip_prefix('f').and_then(|n| n.parse().ok());
|
||||
Some(match key {
|
||||
"backspace" => 0x7F,
|
||||
"tab" => 0x09,
|
||||
"enter" => 0x0D,
|
||||
"escape" => 0x1B,
|
||||
"up" => 0xF700,
|
||||
"down" => 0xF701,
|
||||
"left" => 0xF702,
|
||||
"right" => 0xF703,
|
||||
"delete" => 0xF728,
|
||||
"home" => 0xF729,
|
||||
"end" => 0xF72B,
|
||||
"pageup" => 0xF72C,
|
||||
"pagedown" => 0xF72D,
|
||||
_ => match n {
|
||||
Some(n @ 1..=12) => 0xF703 + n,
|
||||
_ => return None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The control character other systems type, if any.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn character(key: &str) -> Option<u16> {
|
||||
Some(match key {
|
||||
"backspace" => 0x08,
|
||||
"tab" => 0x09,
|
||||
"enter" => 0x0D,
|
||||
"escape" => 0x1B,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The chord for cut, copy, paste and select-all here.
|
||||
pub fn edit_chord(modifiers: &Modifiers) -> bool {
|
||||
let primary = if cfg!(target_os = "macos") {
|
||||
modifiers.platform && !modifiers.control
|
||||
} else {
|
||||
modifiers.control && !modifiers.platform
|
||||
};
|
||||
primary && !modifiers.alt && !modifiers.shift
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Code, code};
|
||||
|
||||
#[test]
|
||||
fn keys_carry_both_codes() {
|
||||
assert_eq!(
|
||||
code("a"),
|
||||
Some(Code {
|
||||
windows: 0x41,
|
||||
native: 0
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
code("z"),
|
||||
Some(Code {
|
||||
windows: 0x5A,
|
||||
native: 6
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
code("0"),
|
||||
Some(Code {
|
||||
windows: 0x30,
|
||||
native: 29
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
code("enter"),
|
||||
Some(Code {
|
||||
windows: 0x0D,
|
||||
native: 36
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
code("f1"),
|
||||
Some(Code {
|
||||
windows: 0x70,
|
||||
native: 122
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
code("f12"),
|
||||
Some(Code {
|
||||
windows: 0x7B,
|
||||
native: 111
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
code("["),
|
||||
Some(Code {
|
||||
windows: 0xDB,
|
||||
native: 33
|
||||
})
|
||||
);
|
||||
assert_eq!(code("f13"), None);
|
||||
assert_eq!(code("é"), None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn keys_without_text_carry_appkit_characters() {
|
||||
assert_eq!(super::character("backspace"), Some(0x7F));
|
||||
assert_eq!(super::character("up"), Some(0xF700));
|
||||
assert_eq!(super::character("f1"), Some(0xF704));
|
||||
assert_eq!(super::character("f12"), Some(0xF70F));
|
||||
assert_eq!(super::character("a"), None);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//! Bundled pages: CEF draws off screen, GPUI shows.
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod edit;
|
||||
mod engine;
|
||||
mod fence;
|
||||
mod handlers;
|
||||
mod input;
|
||||
mod keys;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac;
|
||||
mod print;
|
||||
mod surface;
|
||||
mod tokens;
|
||||
mod view;
|
||||
|
||||
pub use engine::{start, subprocess};
|
||||
pub use surface::file_url;
|
||||
pub use view::{Event, Reply, WebView};
|
||||
@@ -1,105 +0,0 @@
|
||||
//! Chromium's app protocol on GPUI's own `NSApplication` class.
|
||||
|
||||
use std::{cell::Cell, ffi::CStr};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use objc2::{
|
||||
ffi,
|
||||
runtime::{AnyClass, AnyObject, Bool, Imp, Sel},
|
||||
sel,
|
||||
};
|
||||
|
||||
thread_local! {
|
||||
/// NSApp lives on the main thread; one flag serves it.
|
||||
static SENDING: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
extern "C-unwind" fn is_handling(_this: *mut AnyObject, _cmd: Sel) -> Bool {
|
||||
Bool::new(SENDING.get())
|
||||
}
|
||||
|
||||
extern "C-unwind" fn set_handling(_this: *mut AnyObject, _cmd: Sel, on: Bool) {
|
||||
SENDING.set(on.as_bool());
|
||||
}
|
||||
|
||||
extern "C-unwind" fn send_event(this: *mut AnyObject, _cmd: Sel, event: *mut AnyObject) {
|
||||
let was = SENDING.replace(true);
|
||||
let base = AnyClass::get(c"NSApplication").expect("AppKit has NSApplication");
|
||||
unsafe {
|
||||
let _: () = objc2::msg_send![super(&*this, base), sendEvent: event];
|
||||
}
|
||||
SENDING.set(was);
|
||||
}
|
||||
|
||||
fn add(class: &AnyClass, name: Sel, imp: Imp, types: &CStr) -> Result<()> {
|
||||
let added = unsafe {
|
||||
ffi::class_addMethod(
|
||||
class as *const AnyClass as *mut AnyClass,
|
||||
name,
|
||||
imp,
|
||||
types.as_ptr(),
|
||||
)
|
||||
};
|
||||
if !added.as_bool() {
|
||||
bail!("{} already has -{name}", class.name().to_string_lossy());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gives NSApp's class what CEF checks before it runs.
|
||||
pub fn adopt_app_protocol() -> Result<()> {
|
||||
let Some(app) = (unsafe { objc2_app_kit_nsapp() }) else {
|
||||
bail!("no NSApp yet");
|
||||
};
|
||||
let class = unsafe { &*ffi::object_getClass(app.cast()) };
|
||||
if class.responds_to(sel!(isHandlingSendEvent)) {
|
||||
return Ok(());
|
||||
}
|
||||
unsafe {
|
||||
add(
|
||||
class,
|
||||
sel!(isHandlingSendEvent),
|
||||
std::mem::transmute(is_handling as *const ()),
|
||||
c"c@:",
|
||||
)?;
|
||||
add(
|
||||
class,
|
||||
sel!(setHandlingSendEvent:),
|
||||
std::mem::transmute(set_handling as *const ()),
|
||||
c"v@:c",
|
||||
)?;
|
||||
add(
|
||||
class,
|
||||
sel!(sendEvent:),
|
||||
std::mem::transmute(send_event as *const ()),
|
||||
c"v@:@",
|
||||
)?;
|
||||
}
|
||||
// Chromium checks the two `Cr` protocols; CEF's own is header-only.
|
||||
for (name, needed) in [
|
||||
(c"CrAppProtocol", true),
|
||||
(c"CrAppControlProtocol", true),
|
||||
(c"CefAppProtocol", false),
|
||||
] {
|
||||
let protocol = unsafe { ffi::objc_getProtocol(name.as_ptr()) };
|
||||
match (protocol.is_null(), needed) {
|
||||
(true, true) => bail!("CEF did not register {}", name.to_string_lossy()),
|
||||
(true, false) => log::info!("web: {} is not registered", name.to_string_lossy()),
|
||||
(false, _) => unsafe {
|
||||
ffi::class_addProtocol(class as *const AnyClass as *mut AnyClass, protocol);
|
||||
},
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"web: {} takes CefAppProtocol",
|
||||
class.name().to_string_lossy()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The shared application, if made.
|
||||
unsafe fn objc2_app_kit_nsapp() -> Option<*mut AnyObject> {
|
||||
let class = AnyClass::get(c"NSApplication")?;
|
||||
let app: *mut AnyObject = unsafe { objc2::msg_send![class, sharedApplication] };
|
||||
(!app.is_null()).then_some(app)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//! A page as PDF, printed by Chromium itself (MARK-11).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cef::*;
|
||||
|
||||
use crate::{surface::Shared, view::WebView};
|
||||
|
||||
wrap_pdf_print_callback! {
|
||||
struct Printed {
|
||||
surface: Shared,
|
||||
}
|
||||
|
||||
impl PdfPrintCallback {
|
||||
fn on_pdf_print_finished(&self, path: Option<&CefString>, ok: ::std::os::raw::c_int) {
|
||||
let path = PathBuf::from(path.map(CefString::to_string).unwrap_or_default());
|
||||
log::info!("web: printed {} ok {}", path.display(), ok != 0);
|
||||
self.surface.lock().expect("the surface lock").printed.push((path, ok != 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebView {
|
||||
/// Prints the page to `path`; `Event::Printed` tells how.
|
||||
pub fn print_pdf(&self, path: &Path) {
|
||||
let (path, surface) = (
|
||||
CefString::from(path.to_string_lossy().as_ref()),
|
||||
self.surface.clone(),
|
||||
);
|
||||
self.on_host(move |host| {
|
||||
let settings = PdfPrintSettings {
|
||||
print_background: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let mut done = Printed::new(surface);
|
||||
host.print_to_pdf(Some(&path), Some(&settings), Some(&mut done));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
//! What CEF's handlers leave the view; the page fence.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use cef::{wrapper::message_router::BrowserSideCallback, *};
|
||||
|
||||
pub(crate) type Shared = Arc<Mutex<Surface>>;
|
||||
|
||||
pub(crate) struct Surface {
|
||||
/// Points, as GPUI lays the view out.
|
||||
pub size: (f32, f32),
|
||||
pub scale: f32,
|
||||
pub frame: Option<(u32, u32, Vec<u8>)>,
|
||||
/// Composing characters' boxes, in points.
|
||||
pub ime: Vec<(f32, f32, f32, f32)>,
|
||||
pub cursor: CursorType,
|
||||
pub loaded: bool,
|
||||
/// The main frame's load failed since it started.
|
||||
pub errored: bool,
|
||||
pub queries: Vec<(String, Arc<Mutex<dyn BrowserSideCallback>>)>,
|
||||
pub leaving: Vec<String>,
|
||||
/// PDFs printed: where, and whether it worked.
|
||||
pub printed: Vec<(PathBuf, bool)>,
|
||||
/// Why the page cannot show: load, renderer or start.
|
||||
pub failed: Option<String>,
|
||||
/// Made on the pump's turn, after the view.
|
||||
pub browser: Option<Browser>,
|
||||
/// The view went before its browser came.
|
||||
pub closed: bool,
|
||||
/// Whether the page's scripts run.
|
||||
pub scripts: bool,
|
||||
/// The only folder pages load from, real path.
|
||||
root: PathBuf,
|
||||
/// A folder images alone may also come from.
|
||||
pub images: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
pub fn new(root: PathBuf, scale: f32) -> Self {
|
||||
Surface {
|
||||
size: (1.0, 1.0),
|
||||
scale,
|
||||
frame: None,
|
||||
ime: Vec::new(),
|
||||
cursor: CursorType::POINTER,
|
||||
loaded: false,
|
||||
errored: false,
|
||||
queries: Vec::new(),
|
||||
leaving: Vec::new(),
|
||||
printed: Vec::new(),
|
||||
failed: None,
|
||||
browser: None,
|
||||
closed: false,
|
||||
scripts: true,
|
||||
root,
|
||||
images: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A file under `root`, by its real path.
|
||||
fn holds(root: &Path, url: &str) -> bool {
|
||||
let Some(path) = url.strip_prefix("file://") else {
|
||||
return false;
|
||||
};
|
||||
let path = path.split(['?', '#']).next().unwrap_or_default();
|
||||
let Ok(path) = String::from_utf8(decoded(path)) else {
|
||||
return false;
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let path = windows_path(&path);
|
||||
std::fs::canonicalize(path).is_ok_and(|real| real.starts_with(root))
|
||||
}
|
||||
|
||||
/// Where the page may go: its own files, or blank.
|
||||
pub fn navigable(&self, url: &str) -> bool {
|
||||
url == "about:blank" || Self::holds(&self.root, url)
|
||||
}
|
||||
|
||||
/// What a page loads: its files, data, allowed images.
|
||||
pub fn loadable(&self, url: &str, image: bool) -> bool {
|
||||
let pictured = image
|
||||
&& self
|
||||
.images
|
||||
.as_deref()
|
||||
.is_some_and(|dir| Self::holds(dir, url));
|
||||
self.navigable(url)
|
||||
|| url.starts_with("data:")
|
||||
|| url.starts_with("blob:file://")
|
||||
|| pictured
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Surface {
|
||||
fn drop(&mut self) {
|
||||
log::info!("web: a page's memory is freed");
|
||||
}
|
||||
}
|
||||
|
||||
/// `%XX` decoded; a file URL's path.
|
||||
fn decoded(text: &str) -> Vec<u8> {
|
||||
let bytes = text.as_bytes();
|
||||
let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0);
|
||||
while at < bytes.len() {
|
||||
let hex = bytes
|
||||
.get(at + 1..at + 3)
|
||||
.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok());
|
||||
match (bytes[at], hex) {
|
||||
(b'%', Some(byte)) => {
|
||||
out.push(byte);
|
||||
at += 3;
|
||||
}
|
||||
(byte, _) => {
|
||||
out.push(byte);
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bytes as URL text, reserved ones escaped.
|
||||
fn escaped(bytes: &[u8]) -> String {
|
||||
let mut out = String::new();
|
||||
for &byte in bytes {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
|
||||
out.push(byte as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A path as a file URL, every reserved byte escaped.
|
||||
pub fn file_url(path: &Path) -> String {
|
||||
#[cfg(windows)]
|
||||
return windows_url(&path.to_string_lossy());
|
||||
#[cfg(not(windows))]
|
||||
format!("file://{}", escaped(path.as_os_str().as_encoded_bytes()))
|
||||
}
|
||||
|
||||
/// A drive or UNC path as `file:///C:/…` or `file://host/…`.
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_url(path: &str) -> String {
|
||||
let path = match path.strip_prefix(r"\\?\UNC\") {
|
||||
Some(rest) => format!(r"\\{rest}"),
|
||||
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_owned(),
|
||||
};
|
||||
let slashed = |text: &str| escaped(text.replace('\\', "/").as_bytes());
|
||||
match path.strip_prefix(r"\\") {
|
||||
Some(unc) => {
|
||||
let (host, rest) = unc.split_once('\\').unwrap_or((unc, ""));
|
||||
format!("file://{}/{}", slashed(host), slashed(rest))
|
||||
}
|
||||
None if path.get(1..2) == Some(":") => {
|
||||
format!("file:///{}{}", &path[..2], slashed(&path[2..]))
|
||||
}
|
||||
None => format!("file:///{}", slashed(&path)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A decoded file URL's rest as a Windows path.
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_path(rest: &str) -> String {
|
||||
match rest.strip_prefix('/') {
|
||||
Some(drive) => drive.replace('/', "\\"),
|
||||
None => format!(r"\\{}", rest.replace('/', "\\")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
fn surface(root: &Path) -> super::Surface {
|
||||
super::Surface::new(std::fs::canonicalize(root).expect("real"), 1.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_real_files_under_the_root_load() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = dir.path().join("content");
|
||||
let outside = dir.path().join("outside");
|
||||
std::fs::create_dir_all(&root).expect("makes");
|
||||
std::fs::create_dir_all(&outside).expect("makes");
|
||||
std::fs::write(root.join("index.html"), "x").expect("writes");
|
||||
std::fs::write(root.join("a #b?.html"), "x").expect("writes");
|
||||
std::fs::write(outside.join("o.html"), "x").expect("writes");
|
||||
let surface = surface(&root);
|
||||
let url = |path: &Path| super::file_url(&std::fs::canonicalize(path).expect("real"));
|
||||
assert!(surface.navigable(&url(&root.join("index.html"))));
|
||||
assert!(
|
||||
surface.navigable(&url(&root.join("a #b?.html"))),
|
||||
"reserved bytes"
|
||||
);
|
||||
assert!(surface.navigable("about:blank"));
|
||||
assert!(!surface.navigable(&url(&outside.join("o.html"))));
|
||||
let up = format!("{}/../outside/o.html", super::file_url(&surface.root));
|
||||
assert!(!surface.navigable(&up));
|
||||
assert!(!surface.navigable(&format!("{}/missing.html", super::file_url(&surface.root))));
|
||||
assert!(!surface.navigable("https://example.com/"));
|
||||
assert!(
|
||||
!surface.navigable("data:text/html,x")
|
||||
&& surface.loadable("data:image/png;base64,x", true)
|
||||
);
|
||||
assert!(!surface.loadable("http://127.0.0.1:9/pixel", true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_folder_admits_images_alone() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (root, docs) = (dir.path().join("content"), dir.path().join("docs"));
|
||||
std::fs::create_dir_all(&root).expect("makes");
|
||||
std::fs::create_dir_all(&docs).expect("makes");
|
||||
std::fs::write(docs.join("a.png"), "x").expect("writes");
|
||||
let mut surface = surface(&root);
|
||||
let picture = super::file_url(&std::fs::canonicalize(docs.join("a.png")).expect("real"));
|
||||
assert!(
|
||||
!surface.loadable(&picture, true),
|
||||
"not before it is allowed"
|
||||
);
|
||||
surface.images = Some(std::fs::canonicalize(&docs).expect("real"));
|
||||
assert!(surface.loadable(&picture, true));
|
||||
assert!(!surface.loadable(&picture, false), "a script from there");
|
||||
assert!(!surface.navigable(&picture));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_out_of_the_root_is_outside() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = dir.path().join("content");
|
||||
let outside = dir.path().join("outside");
|
||||
std::fs::create_dir_all(&root).expect("makes");
|
||||
std::fs::create_dir_all(&outside).expect("makes");
|
||||
std::fs::write(outside.join("o.html"), "x").expect("writes");
|
||||
std::os::unix::fs::symlink(&outside, root.join("link")).expect("links");
|
||||
let surface = surface(&root);
|
||||
let through = format!("{}/link/o.html", super::file_url(&surface.root));
|
||||
assert!(!surface.navigable(&through));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_paths_make_valid_file_urls_and_back() {
|
||||
let cases = [
|
||||
(r"C:\Users\A b\doc.md", "file:///C:/Users/A%20b/doc.md"),
|
||||
(r"\\?\C:\notes\doc.md", "file:///C:/notes/doc.md"),
|
||||
(r"\\server\share\doc.md", "file://server/share/doc.md"),
|
||||
(r"\\?\UNC\server\share\doc.md", "file://server/share/doc.md"),
|
||||
];
|
||||
for (path, url) in cases {
|
||||
assert_eq!(super::windows_url(path), url, "{path}");
|
||||
}
|
||||
assert_eq!(
|
||||
super::windows_path("/C:/Users/A b/doc.md"),
|
||||
r"C:\Users\A b\doc.md"
|
||||
);
|
||||
assert_eq!(
|
||||
super::windows_path("server/share/doc.md"),
|
||||
r"\\server\share\doc.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_escapes_decode_and_bad_ones_stay() {
|
||||
assert_eq!(super::decoded("a%20b%e4%b8%ad"), "a b中".as_bytes());
|
||||
assert_eq!(super::decoded("100%"), b"100%");
|
||||
assert_eq!(super::decoded("%zz"), b"%zz");
|
||||
assert_eq!(
|
||||
super::file_url(Path::new("/a b/中#?.html")),
|
||||
"file:///a%20b/%E4%B8%AD%23%3F.html"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
//! The app's theme as CSS variables on the page's root.
|
||||
|
||||
use ely_gpui_component::theme::{ActiveTheme as _, Palette};
|
||||
use gpui::{App, Hsla, Rgba};
|
||||
|
||||
fn css(color: Hsla) -> String {
|
||||
let rgba = Rgba::from(color);
|
||||
let byte = |part: f32| (part.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!(
|
||||
"rgba({}, {}, {}, {:.3})",
|
||||
byte(rgba.r),
|
||||
byte(rgba.g),
|
||||
byte(rgba.b),
|
||||
rgba.a
|
||||
)
|
||||
}
|
||||
|
||||
/// `--ely-*` names and values, palette then type.
|
||||
pub fn tokens(cx: &App) -> Vec<(String, String)> {
|
||||
let theme = cx.theme();
|
||||
let colors: &Palette = &theme.colors;
|
||||
let mut out: Vec<(String, String)> = [
|
||||
("bg", colors.bg),
|
||||
("surface", colors.surface),
|
||||
("sunken", colors.sunken),
|
||||
("hover", colors.hover),
|
||||
("border", colors.border),
|
||||
("fg", colors.fg),
|
||||
("fg-muted", colors.fg_muted),
|
||||
("fg-subtle", colors.fg_subtle),
|
||||
("accent", colors.accent),
|
||||
("on-accent", colors.on_accent),
|
||||
("focus", colors.focus),
|
||||
("link", colors.link),
|
||||
("selection", colors.selection),
|
||||
("danger", colors.danger),
|
||||
("warning", colors.warning),
|
||||
("success", colors.success),
|
||||
("keyword", colors.syntax.keyword),
|
||||
("string", colors.syntax.string),
|
||||
("number", colors.syntax.number),
|
||||
("comment", colors.syntax.comment),
|
||||
("function", colors.syntax.function),
|
||||
("type", colors.syntax.type_name),
|
||||
("tag", colors.syntax.tag),
|
||||
("attribute", colors.syntax.attribute),
|
||||
("punctuation", colors.syntax.punctuation),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(name, color)| (format!("--ely-{name}"), css(color)))
|
||||
.collect();
|
||||
out.push(("--ely-font".into(), format!("\"{}\"", theme.font_family)));
|
||||
out.push(("--ely-mono".into(), format!("\"{}\"", theme.mono_family)));
|
||||
out.push(("--ely-font-scale".into(), theme.font_scale.to_string()));
|
||||
let scheme = if theme.is_dark() { "dark" } else { "light" };
|
||||
out.push(("color-scheme".into(), scheme.into()));
|
||||
out
|
||||
}
|
||||
|
||||
/// Script that sets `tokens` on `:root`.
|
||||
pub fn script(tokens: &[(String, String)]) -> String {
|
||||
let pairs = serde_json::to_string(tokens).expect("strings serialize");
|
||||
format!("for (const [k, v] of {pairs}) document.documentElement.style.setProperty(k, v);")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_script_sets_each_token_as_json() {
|
||||
let tokens = vec![("--ely-fg".to_string(), "rgba(1, 2, 3, 1.000)".to_string())];
|
||||
let script = super::script(&tokens);
|
||||
assert!(
|
||||
script.contains(r#"[["--ely-fg","rgba(1, 2, 3, 1.000)"]]"#),
|
||||
"{script}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
//! A CEF page drawn by GPUI: frames in, events out.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use cef::{wrapper::message_router::BrowserSideCallback, *};
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
feedback::ErrorView,
|
||||
primitives::IconName,
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Bounds, Context, CursorStyle, ElementInputHandler, Entity,
|
||||
EventEmitter, FocusHandle, Focusable, Hitbox, HitboxBehavior, InteractiveElement as _,
|
||||
IntoElement, ParentElement as _, Pixels, Render, RenderImage, Styled as _, Window, canvas, div,
|
||||
};
|
||||
use image::{Frame, ImageBuffer, Rgba};
|
||||
|
||||
use crate::{
|
||||
engine::Engine,
|
||||
surface::{Shared, Surface, file_url},
|
||||
};
|
||||
|
||||
/// A page's answer to one `elyQuery`.
|
||||
#[derive(Clone)]
|
||||
pub struct Reply(Arc<Mutex<dyn BrowserSideCallback>>);
|
||||
|
||||
impl Reply {
|
||||
pub fn ok(&self, text: &str) {
|
||||
self.0.lock().expect("the reply lock").success_str(text);
|
||||
}
|
||||
|
||||
pub fn fail(&self, message: &str) {
|
||||
self.0.lock().expect("the reply lock").failure(1, message);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the page tells its owner.
|
||||
pub enum Event {
|
||||
Loaded,
|
||||
Query(String, Reply),
|
||||
/// A link to leave by; the owner decides.
|
||||
Leave(String),
|
||||
/// The page cannot show: load, start or run failed.
|
||||
Failed(String),
|
||||
/// A PDF print ended: where, and whether it worked.
|
||||
Printed(PathBuf, bool),
|
||||
}
|
||||
|
||||
pub struct WebView {
|
||||
pub(crate) surface: Shared,
|
||||
image: Option<Arc<RenderImage>>,
|
||||
/// Replaced frames, freed from GPUI's atlas next paint.
|
||||
stale: Vec<Arc<RenderImage>>,
|
||||
pub(crate) focus: FocusHandle,
|
||||
pub(crate) bounds: Bounds<Pixels>,
|
||||
pub(crate) composing: Option<std::ops::Range<usize>>,
|
||||
/// The key whose text the input handler brings next.
|
||||
pub(crate) typing: Option<cef::KeyEvent>,
|
||||
url: String,
|
||||
/// Shown instead of the page until reloaded.
|
||||
fault: Option<String>,
|
||||
/// Takes a paste before the page; true when it did.
|
||||
pub(crate) paste: Option<Paste>,
|
||||
}
|
||||
|
||||
/// Takes a paste before the page; true when it did.
|
||||
pub(crate) type Paste = std::rc::Rc<dyn Fn(&mut Window, &mut App) -> bool>;
|
||||
|
||||
impl EventEmitter<Event> for WebView {}
|
||||
|
||||
impl Focusable for WebView {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl WebView {
|
||||
/// Its frames, for a closing owner to free.
|
||||
pub fn retire_all(&mut self) -> ely_gpui_component::primitives::Retired {
|
||||
let mut images = ely_gpui_component::primitives::Retired::default();
|
||||
for image in self.image.take().into_iter().chain(self.stale.drain(..)) {
|
||||
images.push(image);
|
||||
}
|
||||
images
|
||||
}
|
||||
|
||||
/// Opens `page`, a file in `root`, in a new browser.
|
||||
pub fn open(page: &Path, root: &Path, window: &mut Window, cx: &mut App) -> Result<Entity<Self>> {
|
||||
Self::opened(page, root, true, window, cx)
|
||||
}
|
||||
|
||||
/// Opens `page` with its scripts off: shown, never run.
|
||||
pub fn open_inert(page: &Path, root: &Path, window: &mut Window, cx: &mut App) -> Result<Entity<Self>> {
|
||||
Self::opened(page, root, false, window, cx)
|
||||
}
|
||||
|
||||
fn opened(
|
||||
page: &Path,
|
||||
root: &Path,
|
||||
scripts: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Result<Entity<Self>> {
|
||||
if !cx.has_global::<Engine>() {
|
||||
bail!("the web engine has not started");
|
||||
}
|
||||
let root = std::fs::canonicalize(root)?;
|
||||
let page = std::fs::canonicalize(page)?;
|
||||
let mut surface = Surface::new(root, window.scale_factor());
|
||||
surface.scripts = scripts;
|
||||
let surface = Arc::new(Mutex::new(surface));
|
||||
let url = file_url(&page);
|
||||
if !surface.lock().expect("the surface lock").navigable(&url) {
|
||||
bail!("{} is outside its content folder", page.display());
|
||||
}
|
||||
log::info!("web: a browser asked for {url}");
|
||||
crate::engine::ask_browser(surface.clone(), url.clone());
|
||||
let view = cx.new(|cx| WebView {
|
||||
surface,
|
||||
image: None,
|
||||
stale: Vec::new(),
|
||||
focus: cx.focus_handle(),
|
||||
bounds: Bounds::default(),
|
||||
composing: None,
|
||||
typing: None,
|
||||
url,
|
||||
fault: None,
|
||||
paste: None,
|
||||
});
|
||||
crate::input::follow_focus(&view, window, cx);
|
||||
view.update(cx, |_, cx| {
|
||||
cx.observe_global::<ely_gpui_component::theme::Theme>(|view, cx| view.theme(cx))
|
||||
.detach();
|
||||
});
|
||||
cx.global_mut::<Engine>().views.push(view.downgrade());
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
/// The browser, on the pump's next turn, outside any update.
|
||||
pub(crate) fn on_browser(&self, work: impl FnOnce(&Browser) + 'static) {
|
||||
let surface = self.surface.clone();
|
||||
crate::engine::later(move || {
|
||||
// Not held while CEF runs: its callbacks lock it too.
|
||||
let browser = surface.lock().expect("the surface lock").browser.clone();
|
||||
if let Some(browser) = browser {
|
||||
work(&browser);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn on_host(&self, work: impl FnOnce(&BrowserHost) + 'static) {
|
||||
self.on_browser(move |browser| {
|
||||
if let Some(host) = browser.host() {
|
||||
work(&host);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Lets images under `dir` load; one folder at a time.
|
||||
pub fn allow_images(&self, dir: &Path) -> Result<()> {
|
||||
let dir = std::fs::canonicalize(dir)?;
|
||||
log::info!("web: images may come from {}", dir.display());
|
||||
self.surface.lock().expect("the surface lock").images = Some(dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `take` sees pastes first; true keeps them from the page.
|
||||
pub fn intercept_paste(&mut self, take: impl Fn(&mut Window, &mut App) -> bool + 'static) {
|
||||
self.paste = Some(std::rc::Rc::new(take));
|
||||
}
|
||||
|
||||
/// Runs script in the page's main frame.
|
||||
pub fn run_js(&self, code: &str) {
|
||||
let code = CefString::from(code);
|
||||
self.on_browser(move |browser| {
|
||||
if let Some(frame) = browser.main_frame() {
|
||||
frame.execute_java_script(Some(&code), None, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The app's colors and fonts, into the page.
|
||||
fn theme(&mut self, cx: &mut Context<Self>) {
|
||||
self.run_js(&crate::tokens::script(&crate::tokens::tokens(cx)));
|
||||
}
|
||||
|
||||
/// Zoom as a factor; CEF counts in 1.2 steps.
|
||||
pub fn set_zoom(&self, factor: f64) {
|
||||
let level = factor.ln() / 1.2f64.ln();
|
||||
self.on_host(move |host| host.set_zoom_level(level));
|
||||
}
|
||||
|
||||
/// Takes what the handlers left; says if anything came.
|
||||
fn take(&mut self, cx: &mut Context<Self>) {
|
||||
let (frame, events) = {
|
||||
let mut surface = self.surface.lock().expect("the surface lock");
|
||||
let mut events = Vec::new();
|
||||
if std::mem::take(&mut surface.loaded) {
|
||||
events.push(Event::Loaded);
|
||||
}
|
||||
for (request, callback) in surface.queries.drain(..) {
|
||||
events.push(Event::Query(request, Reply(callback)));
|
||||
}
|
||||
events.extend(surface.leaving.drain(..).map(Event::Leave));
|
||||
events.extend(
|
||||
surface
|
||||
.printed
|
||||
.drain(..)
|
||||
.map(|(path, ok)| Event::Printed(path, ok)),
|
||||
);
|
||||
events.extend(surface.failed.take().map(Event::Failed));
|
||||
if let Some(why) = crate::engine::failed().filter(|_| !surface.closed) {
|
||||
surface.closed = true;
|
||||
events.push(Event::Failed(why));
|
||||
}
|
||||
(surface.frame.take(), events)
|
||||
};
|
||||
if let Some((width, height, bgra)) = frame {
|
||||
match ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, bgra) {
|
||||
Some(picture) => {
|
||||
let image = Arc::new(RenderImage::new([Frame::new(picture)]));
|
||||
self.stale.extend(self.image.replace(image));
|
||||
cx.notify();
|
||||
}
|
||||
None => log::error!("web: a {width}×{height} frame was short"),
|
||||
}
|
||||
}
|
||||
for event in events {
|
||||
match &event {
|
||||
// Each load starts unthemed; the tokens go in first.
|
||||
Event::Loaded => {
|
||||
self.fault = None;
|
||||
self.theme(cx);
|
||||
}
|
||||
Event::Failed(why) => {
|
||||
log::error!("web: {} failed: {why}", self.url);
|
||||
self.fault = Some(why.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cx.notify();
|
||||
cx.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Follows the room GPUI gave; CEF resizes to it.
|
||||
fn place(&mut self, bounds: Bounds<Pixels>, scale: f32) {
|
||||
self.bounds = bounds;
|
||||
let size = (f32::from(bounds.size.width), f32::from(bounds.size.height));
|
||||
let (resized, rescaled) = {
|
||||
let mut surface = self.surface.lock().expect("the surface lock");
|
||||
let changed = (surface.size != size, surface.scale != scale);
|
||||
(surface.size, surface.scale) = (size, scale);
|
||||
changed
|
||||
};
|
||||
if resized || rescaled {
|
||||
self.on_host(move |host| {
|
||||
if rescaled {
|
||||
host.notify_screen_info_changed();
|
||||
}
|
||||
host.was_resized();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries the page again; a lost browser is made anew.
|
||||
pub fn reload(&mut self, cx: &mut Context<Self>) {
|
||||
self.fault = None;
|
||||
let made = self
|
||||
.surface
|
||||
.lock()
|
||||
.expect("the surface lock")
|
||||
.browser
|
||||
.is_some();
|
||||
match made {
|
||||
true => self.on_browser(|browser| browser.reload()),
|
||||
false => crate::engine::ask_browser(self.surface.clone(), self.url.clone()),
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The fault, with a way to try again.
|
||||
fn fault_view(&self, why: &str, cx: &mut Context<Self>) -> AnyElement {
|
||||
let this = cx.weak_entity();
|
||||
let title = ely_ui::i18n::text(cx, "web.failed", &[]);
|
||||
let again = Button::new("web-reload", ely_ui::i18n::text(cx, "web.reload", &[]))
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, window, cx| {
|
||||
// The page, not the gone button, takes keys.
|
||||
this.update(cx, |view, cx| {
|
||||
view.reload(cx);
|
||||
window.focus(&view.focus, cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
let view =
|
||||
ErrorView::new("web-failed", IconName::TriangleAlert, title).detail(why.to_string());
|
||||
// Without a running engine nothing can try again.
|
||||
match crate::engine::failed() {
|
||||
Some(_) => view.into_any_element(),
|
||||
None => view.action(again).into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(&self) -> CursorStyle {
|
||||
match self.surface.lock().expect("the surface lock").cursor {
|
||||
CursorType::HAND => CursorStyle::PointingHand,
|
||||
CursorType::IBEAM => CursorStyle::IBeam,
|
||||
CursorType::CROSS => CursorStyle::Crosshair,
|
||||
CursorType::EASTWESTRESIZE | CursorType::COLUMNRESIZE => CursorStyle::ResizeLeftRight,
|
||||
CursorType::NORTHSOUTHRESIZE | CursorType::ROWRESIZE => CursorStyle::ResizeUpDown,
|
||||
CursorType::NOTALLOWED => CursorStyle::OperationNotAllowed,
|
||||
_ => CursorStyle::Arrow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WebView {
|
||||
fn drop(&mut self) {
|
||||
self.surface.lock().expect("the surface lock").closed = true;
|
||||
self.on_host(|host| host.close_browser(true as _));
|
||||
}
|
||||
}
|
||||
|
||||
/// After each pump: every live view takes its news.
|
||||
pub(crate) fn deliver(cx: &mut App) {
|
||||
let views = std::mem::take(&mut cx.global_mut::<Engine>().views);
|
||||
let alive: Vec<_> = views
|
||||
.into_iter()
|
||||
.filter(|view| view.update(cx, |view, cx| view.take(cx)).is_ok())
|
||||
.collect();
|
||||
cx.global_mut::<Engine>().views.extend(alive);
|
||||
}
|
||||
|
||||
impl Render for WebView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if let Some(why) = self.fault.clone() {
|
||||
return div()
|
||||
.size_full()
|
||||
.child(self.fault_view(&why, cx))
|
||||
.into_any_element();
|
||||
}
|
||||
let this = cx.entity();
|
||||
let paint = this.clone();
|
||||
let element = canvas(
|
||||
move |bounds, window, cx| {
|
||||
let scale = window.scale_factor();
|
||||
this.update(cx, |view, _| view.place(bounds, scale));
|
||||
window.insert_hitbox(bounds, HitboxBehavior::Normal)
|
||||
},
|
||||
move |bounds, hitbox: Hitbox, window, cx| {
|
||||
let (image, stale, cursor, focus) = paint.update(cx, |view, _| {
|
||||
let stale = std::mem::take(&mut view.stale);
|
||||
(view.image.clone(), stale, view.cursor(), view.focus.clone())
|
||||
});
|
||||
for old in stale {
|
||||
if let Err(error) = window.drop_image(old) {
|
||||
log::error!("web: an old frame stayed: {error}");
|
||||
}
|
||||
}
|
||||
window.set_cursor_style(cursor, &hitbox);
|
||||
window.handle_input(&focus, ElementInputHandler::new(bounds, paint.clone()), cx);
|
||||
let Some(image) = image else { return };
|
||||
let painted =
|
||||
window.paint_image(bounds, bounds, Default::default(), image, 0, false);
|
||||
if let Err(error) = painted {
|
||||
log::error!("web: a frame was not painted: {error}");
|
||||
}
|
||||
},
|
||||
)
|
||||
.size_full();
|
||||
crate::input::listen(div().size_full(), cx)
|
||||
.track_focus(&self.focus)
|
||||
.child(element)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
| --- | --- | --- |
|
||||
| 键盘完成打开、编辑、保存、搜索与主要工具 | 共用命令(`ely_ui::commands`:新建、打开、保存、另存为、导出、打印、关闭标签 Cmd+W、命令面板、设置),各应用的查找(Cmd+F)与模式键;`shell.sh`、`doc-shell.sh`、`mark-tools.sh` 以按键驱动 | 拖动类操作(图片手柄、透视表字段、PDF 区域标记)没有键盘替代 |
|
||||
| 焦点框不依赖悬停 | 组件库可按元素都是 Tab 停靠点并带 `focus_ring`(7fab072 起 git 行带透明边框使焦点框可见) | — |
|
||||
| 内容区与界面间焦点可预测 | Tab 顺序导航→内容→属性→状态(C05.4,`shell.sh`);浮层关闭后焦点回到打开前之处(组件库 8658999);文档(LOK 视图)与网页(ElyMark)把 Tab 留给自己(制表符、跳单元格、缩进),F6 与 Shift+F6 照 Tab 的次序移出与移回(组件库 029632b;原先没有键盘出口) | CEF 页面内的焦点顺序由页面决定 |
|
||||
| 内容区与界面间焦点可预测 | Tab 顺序导航→内容→属性→状态(C05.4,`shell.sh`);浮层关闭后焦点回到打开前之处(组件库 8658999);文档(LOK 视图)把 Tab 留给自己(制表符、跳单元格、缩进),F6 与 Shift+F6 照 Tab 的次序移出与移回(组件库 029632b;原先没有键盘出口) | ElyMark 原生组件的焦点次序待 C14.16 核对 |
|
||||
| 对比度 4.5:1 | 主题与 Omarchy 色板按组件库 `A11yChecker` 规则收口(正文、状态色 4.5:1,焦点与图表色 3:1),核对不过的主题整套不用(C05.1) | 文档内容的颜色取自文件,照原样显示 |
|
||||
|
||||
## 辅助技术
|
||||
@@ -16,7 +16,7 @@
|
||||
| 要求 | 依据 | 缺口 |
|
||||
| --- | --- | --- |
|
||||
| 控件有角色与名称 | 组件库核心控件、对话框、列表、树、提交与差异行、设置行(组与说明)、提示(Toast 有角色与名称);真窗口测试都经 AX 树操作(`ax-values`、`ax-press`) | `lists::Outline` 与 `feedback::Alert` 无角色(上游待办);gpui 无 aria-disabled,以说明文字表达 |
|
||||
| 文档内容可达 | LOK 视图把当前段落(Calc 为当前单元格与输入行)以 `MultilineTextInput` 交给 AX(`lokview.sh` 读到键入后的段落) | gpui 没有文本选区与插入点的 AX:选区、插入点、整篇文档到不了辅助技术;ElyMark 与内容宿主的页面不进 AX 树(CEF 离屏);PDF 页面文字未接 AX |
|
||||
| 文档内容可达 | LOK 视图把当前段落(Calc 为当前单元格与输入行)以 `MultilineTextInput` 交给 AX(`lokview.sh` 读到键入后的段落) | gpui 没有文本选区与插入点的 AX:选区、插入点、整篇文档到不了辅助技术;ElyMark 原生组件的 AX 待 C14.16 核对;PDF 页面文字未接 AX |
|
||||
| 编辑状态可达 | 保存状态、风险、引擎停止以提示条与状态栏文字表达 | 状态栏文字是否进 AX 树未逐项核对 |
|
||||
|
||||
## 国际化
|
||||
@@ -24,7 +24,7 @@
|
||||
| 要求 | 依据 | 缺口 |
|
||||
| --- | --- | --- |
|
||||
| 简中与英文界面 | `ely_ui::i18n` 缺键即失败;组件库面板族、对话框、批注栏等内建中英目录;设置可切语言(下次启动生效) | 组件库 `SortBuilder`、`FilterBuilder`、欢迎页等文案仍为英文(上游待办);ElyTable 选区统计以外的内核文字为英文 |
|
||||
| 中英文输入、组合输入、标点 | LOK 视图与 CEF 页面都经输入法回调处理组字(`lokview.sh`、`webview.sh` 组“你好”);往返用例含中文(`independent.rs`) | 系统拼音输入法实机只做过人工检查,测试以输入法回调模拟 |
|
||||
| 中英文输入、组合输入、标点 | LOK 视图与 ElyMark 的编辑器都经输入法回调处理组字(`lokview.sh` 组“你好”;组件库的文档测试);往返用例含中文(`independent.rs`) | 系统拼音输入法实机只做过人工检查,测试以输入法回调模拟 |
|
||||
| 混合字体 | 内核按字体回退排版;缺失字体在 ElyDoc 页面上方列出 | 字体缺失环境的分页对照未做(见 `docs/compatibility.md`) |
|
||||
| 非 ASCII 路径 | `paths_keep_spaces_cjk_and_case`(保存);启动参数按当前目录取绝对路径(C20.1 修) | — |
|
||||
| 日期、数字、纸张用显式区域 | 内核载入选项固定 `Language=en-US`(`ely-lok/src/worker.rs` 的 `LOAD_OPTIONS`),公式为 Excel A1 语法;纸张取自文档本身并原样打印(C19.2) | — |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
随发行说明发布,每个版本一份。支持某格式不等于支持它的全部功能;下表只写已验证的内容、已知限制与保存行为。
|
||||
|
||||
版本:Ely Office 0.1.3(首版六个应用:ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark)。日期 2026-10-11。
|
||||
内核:LibreOffice 26.2.6.3(8221e31,加 `patches/libreoffice` 0001–0041);MuPDF 1.28.5(8ad45e9,加 `patches/mupdf` 0001–0005);Git 2.56.0(a018953)与 git-lfs 3.8.0;CEF 154.0.32。
|
||||
内核:LibreOffice 26.2.6.3(8221e31,加 `patches/libreoffice` 0001–0041);MuPDF 1.28.5(8ad45e9,加 `patches/mupdf` 0001–0005);Git 2.56.0(a018953)与 git-lfs 3.8.0。
|
||||
验证机:macOS 27.0 arm64。Windows 与 Linux 未验证。本机没有 Microsoft Office:与 Office 的交叉检查未做(待做)。
|
||||
|
||||
## 往返验证
|
||||
@@ -66,9 +66,9 @@
|
||||
|
||||
| | 格式 |
|
||||
| --- | --- |
|
||||
| 编辑与保存 | MD、Markdown(UTF-8,带不带 BOM;保留换行风格;未改动的块逐字节照原文写回) |
|
||||
| 导入或受限 | UTF-16 与非 UTF-8 拒绝打开并指出字节位置;Milkdown 不支持的语法以原文块保留;超过 2 MiB 只以源码打开 |
|
||||
| 输出 | HTML(单文件或带资源文件夹)、PDF、纯文本、打印(macOS) |
|
||||
| 编辑与保存 | MD、Markdown(UTF-8,带不带 BOM;保留换行风格;文本即原文,只写回所改的字) |
|
||||
| 导入或受限 | UTF-16 与非 UTF-8 拒绝打开并指出字节位置;markdown-rs 不认的语法照原文显示;超过 2 MiB 只以源码打开 |
|
||||
| 输出 | 原生重做中(C14.14),此版没有 |
|
||||
|
||||
## 第二批
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
- 有未保存改动时,每 10 秒在本机数据目录写一个恢复点(加密的文档其恢复点也加密),只留在本机,不上传。
|
||||
- 应用崩溃后再启动,未保存的内容以未命名副本打开,原文件不动;只恢复内容,光标与撤销历史不恢复。
|
||||
- 文档引擎单独停止时,窗口上方写明原因与最近一次恢复点的时间,“重新打开文档”起新引擎并逐个重开:有改动的从恢复点、无改动的从文件。
|
||||
- 设置“存储”里可看日志、网页缓存、恢复副本的大小,选每个文档保留几个恢复点与多少空间,并清除它们(先问)。
|
||||
- 设置“存储”里可看日志、恢复副本的大小,选每个文档保留几个恢复点与多少空间,并清除它们(先问)。
|
||||
|
||||
## 离线边界
|
||||
|
||||
|
||||
+2
-3
@@ -10,7 +10,7 @@ Ely Office 的自有代码以 AGPL-3.0-or-later 发布,全文在仓库根的 `
|
||||
|
||||
| 文件 | 内容 | 生成 |
|
||||
| --- | --- | --- |
|
||||
| `ElyOffice-<版本>-source.tar.gz` | 自有源码、`Cargo.lock`、`rust-toolchain.toml`、ElyMark 网页的 `package-lock.json`、上游补丁(`patches/libreoffice`、`patches/mupdf`)、构建与取上游的脚本(各上游钉在提交,脚本里写明地址与散列)、测试、文档 | `packaging/macos/release.sh` |
|
||||
| `ElyOffice-<版本>-source.tar.gz` | 自有源码、`Cargo.lock`、`rust-toolchain.toml`、上游补丁(`patches/libreoffice`、`patches/mupdf`)、构建与取上游的脚本(各上游钉在提交,脚本里写明地址与散列)、测试、文档 | `packaging/macos/release.sh` |
|
||||
| `ElyOffice-<版本>-upstream-source.tar.gz` | 实际编译的 MuPDF 源码(钉住的提交加我们的补丁,AGPL)与随包 Git 的源码(v2.56.0,GPLv2) | 同上;`tests/scripts/release-macos.sh` 核对其中是打过补丁的 MuPDF |
|
||||
| 各应用包 `Resources/Licenses/` | Ely 的 AGPL 全文;`rust-crates.txt`(链进该应用各二进制的全部 crate 与其许可文件,无许可文件的取标准文本并写明作者);所带引擎的许可与通知(见下) | `packaging/licenses.sh`,缺任何一份即打包失败 |
|
||||
|
||||
@@ -26,8 +26,7 @@ Ely Office 的自有代码以 AGPL-3.0-or-later 发布,全文在仓库根的 `
|
||||
| Great Vibes 字体 | OFL-1.1 | OFL | PDF |
|
||||
| Git 2.56.0 | GPL-2.0(部分 LGPL-2.1) | COPYING、LGPL-2.1;源码在上游源码包 | Tree(Mark 使用同级 Tree 包内的 Git) |
|
||||
| git-lfs 3.8.0 | MIT | LICENSE.md(按标签与散列取得) | Tree |
|
||||
| CEF 154 / Chromium | BSD 及其依赖各自许可 | CEF LICENSE、Chromium CREDITS | Mark |
|
||||
| Milkdown、CodeMirror、KaTeX、Mermaid 等网页依赖 | 各自许可(多为 MIT) | `THIRD-PARTY-NOTICES.txt`,按打进包的每个包取其许可文件 | Mark |
|
||||
| KaTeX 字体(随 RaTeX 嵌入) | OFL-1.1 | OFL、FONT_NOTICE | Mark |
|
||||
| 媒体播放 | 系统框架(AVFoundation),不随包 | — | Show |
|
||||
|
||||
## 未尽之处
|
||||
|
||||
+1
-1
@@ -12,6 +12,6 @@
|
||||
| repo | 10 万个提交 | PERF-06 |
|
||||
| notes.md | 1 MB | PERF-07 |
|
||||
|
||||
计时从启动进程起,到日志里出现标记为止(脚本每 10 毫秒读一次日志):PERF-01 到首帧(`ELY_SMOKE=1` 打印 `ready`),首次运行(新的用户目录)与日常启动分记,每次等上一次的进程(含仍在启动的内核)退出后再测;Doc、Table、Show 另记空白文档就绪(`opened Untitled`),它要等内核启动;PERF-03、04 到第一块图块真正画到窗口上(`lok: first tile drawn`,在 `paint_image` 成功之后记;其间内核画图块失败或窗口画图块失败即整次作废),PERF-03 之前先核对样本经内核排版恰为 100 页(`lok: 100 pages`);PERF-05 到第一页的第一块画到窗口上(`pdf: first tile drawn, page 0`;画失败即整次作废);PERF-06 的历史首屏从按下“历史”到显示(`history shows`),查询从按下回车到结果(`searched true`),性能要求的指标是后者;PERF-07 到可视编辑器显示(`mark: shown as visual`)。PERF-02 为 ElyDoc 每次按键到显示该编辑的图块(`lok: edit to tile`,每键须各有一条,缺即失败),并以 `ELY_STALLS=1` 记下主线程连续忙于 100 毫秒以上的次数(`main thread was busy`)。内存为应用及其全部子进程(内核 worker、CEF 辅助进程)的 RSS 之和。PERF-08 为 ElyDoc 新建再关闭文档 40 次:循环前记下基线(总内存、全部句柄的类型与名称、内核中的文档数 `lok-worker: N documents open`),每次关闭后等内核回到基线文档数(等不到或没有这条日志即失败),再按身份比对句柄:比基线多出的只许是 `/System/Library/` 下的只读系统资源(第一次打开模板对话框时一次性载入两个系统字体与 AppKit 的两个本地化表),且此后每次都相同;多出任何别的句柄即失败。记录写基线与每次的内存、句柄数、文档数。
|
||||
计时从启动进程起,到日志里出现标记为止(脚本每 10 毫秒读一次日志):PERF-01 到首帧(`ELY_SMOKE=1` 打印 `ready`),首次运行(新的用户目录)与日常启动分记,每次等上一次的进程(含仍在启动的内核)退出后再测;Doc、Table、Show 另记空白文档就绪(`opened Untitled`),它要等内核启动;PERF-03、04 到第一块图块真正画到窗口上(`lok: first tile drawn`,在 `paint_image` 成功之后记;其间内核画图块失败或窗口画图块失败即整次作废),PERF-03 之前先核对样本经内核排版恰为 100 页(`lok: 100 pages`);PERF-05 到第一页的第一块画到窗口上(`pdf: first tile drawn, page 0`;画失败即整次作废);PERF-06 的历史首屏从按下“历史”到显示(`history shows`),查询从按下回车到结果(`searched true`),性能要求的指标是后者;PERF-07 到可视编辑器显示(`mark: shown as visual`)。PERF-02 为 ElyDoc 每次按键到显示该编辑的图块(`lok: edit to tile`,每键须各有一条,缺即失败),并以 `ELY_STALLS=1` 记下主线程连续忙于 100 毫秒以上的次数(`main thread was busy`)。内存为应用及其全部子进程(内核 worker)的 RSS 之和。PERF-08 为 ElyDoc 新建再关闭文档 40 次:循环前记下基线(总内存、全部句柄的类型与名称、内核中的文档数 `lok-worker: N documents open`),每次关闭后等内核回到基线文档数(等不到或没有这条日志即失败),再按身份比对句柄:比基线多出的只许是 `/System/Library/` 下的只读系统资源(第一次打开模板对话框时一次性载入两个系统字体与 AppKit 的两个本地化表),且此后每次都相同;多出任何别的句柄即失败。记录写基线与每次的内存、句柄数、文档数。
|
||||
|
||||
记录开头的 JSON 写机型、CPU、核数、内存、GPU、系统、系统字体文件数、各应用发行二进制的散列与样本散列。冷启动未清系统文件缓存(需要管理员权限),“日常启动”即文件缓存已热时的启动。
|
||||
@@ -21,7 +21,6 @@ put . "$root/LICENSE"
|
||||
case $slug in
|
||||
ely-doc | ely-table | ely-show) crates=(ely-lok) ;;
|
||||
ely-pdf) crates=(ely-pdf-engine) ;;
|
||||
ely-mark) crates=(ely-web-content) ;;
|
||||
*) crates=() ;;
|
||||
esac
|
||||
python3 "$root/packaging/crates.py" "$slug" ${crates[@]+"${crates[@]}"} >"$out/rust-crates.txt"
|
||||
@@ -81,8 +80,10 @@ case $slug in
|
||||
put GreatVibes "$root/crates/ely-pdf-engine/fonts/OFL.txt"
|
||||
;;
|
||||
ely-mark)
|
||||
put CEF "$root/reference/cef/LICENSE.txt" "${ELY_CEF:-$root/engines/cef}/CREDITS.html"
|
||||
put content "$root/engines/content/ely-mark/THIRD-PARTY-NOTICES.txt"
|
||||
# RaTeX embeds KaTeX's fonts.
|
||||
katex=$(cargo metadata --format-version 1 --locked --manifest-path "$root/Cargo.toml" |
|
||||
python3 -c 'import json, pathlib, sys; print(next(pathlib.Path(p["manifest_path"]).parent for p in json.load(sys.stdin)["packages"] if p["name"] == "ratex-katex-fonts"))')
|
||||
put KaTeX "$katex/fonts/OFL.txt" "$katex/fonts/FONT_NOTICE.txt"
|
||||
;;
|
||||
esac
|
||||
# ElyTree carries Git; on Windows and Linux ElyMark does too.
|
||||
|
||||
@@ -106,20 +106,9 @@ for helper in ely-lok-worker ely-pdf-worker; do
|
||||
deployed+=(--executable "$bin/$helper")
|
||||
fi
|
||||
done
|
||||
if [ -f "$bin/libcef.so" ]; then
|
||||
# Binaries here load from usr/lib; CEF's data sits with libcef.
|
||||
cef=${ELY_CEF:-$root/engines/cef}
|
||||
mkdir -p "$appdir/usr/lib"
|
||||
for item in "$bin"/*; do
|
||||
if [ -e "$cef/${item##*/}" ]; then
|
||||
mv "$item" "$appdir/usr/lib/"
|
||||
fi
|
||||
done
|
||||
deployed+=(--library "$appdir/usr/lib/libcef.so")
|
||||
fi
|
||||
|
||||
# linuxdeploy bundles libraries and fails on a missing one.
|
||||
LD_LIBRARY_PATH=$appdir/usr/lib "$deploy" --appimage-extract-and-run --appdir "$appdir" "${deployed[@]}" \
|
||||
"$deploy" --appimage-extract-and-run --appdir "$appdir" "${deployed[@]}" \
|
||||
--desktop-file "$work/$slug.desktop" --icon-file "$icon"
|
||||
|
||||
mkdir -p "$dist"
|
||||
|
||||
@@ -66,7 +66,7 @@ if uses ely-lok; then
|
||||
echo "bundle.sh: $binary is not code; Frameworks holds only code" >&2
|
||||
exit 1
|
||||
fi
|
||||
done < <(find "$app/Contents/Frameworks" -type f -not -path "*/Chromium Embedded Framework.framework/*")
|
||||
done < <(find "$app/Contents/Frameworks" -type f)
|
||||
fi
|
||||
if uses ely-pdf-engine; then
|
||||
tessdata=${ELY_TESSDATA:-$root/engines/tessdata}
|
||||
@@ -84,53 +84,6 @@ if [ "$slug" = ely-tree ]; then
|
||||
file -b "$binary" | grep -q '^Mach-O' && inner+=("$binary")
|
||||
done < <(find "$app/Contents/Resources/git" -type f -perm -111)
|
||||
fi
|
||||
# Web content apps carry CEF and their bundled pages.
|
||||
framework=
|
||||
helpers=()
|
||||
if grep -q '^ely-web-content' "$root/apps/$slug/Cargo.toml"; then
|
||||
cef=${ELY_CEF:-$root/engines/cef}
|
||||
[ -d "$cef/Chromium Embedded Framework.framework" ] || {
|
||||
echo "bundle.sh: no CEF at $cef; run scripts/fetch-cef.sh" >&2
|
||||
exit 1
|
||||
}
|
||||
mkdir -p "$app/Contents/Frameworks"
|
||||
framework="$app/Contents/Frameworks/Chromium Embedded Framework.framework"
|
||||
cp -R "$cef/Chromium Embedded Framework.framework" "$framework"
|
||||
case $profile in
|
||||
release) cargo build --manifest-path "$root/Cargo.toml" --release -p ely-web-content --bin ely-web-helper ;;
|
||||
debug) cargo build --manifest-path "$root/Cargo.toml" -p ely-web-content --bin ely-web-helper ;;
|
||||
esac
|
||||
# Chromium looks for these five, named after the app's binary.
|
||||
for kind in "" " (GPU)" " (Renderer)" " (Plugin)" " (Alerts)"; do
|
||||
helper="$slug Helper$kind"
|
||||
contents="$app/Contents/Frameworks/$helper.app/Contents"
|
||||
mkdir -p "$contents/MacOS"
|
||||
cp "$root/target/$profile/ely-web-helper" "$contents/MacOS/$helper"
|
||||
id=$(echo "${slug#ely-}.helper$kind" | tr -d '()' | tr ' ' '.' | tr 'A-Z' 'a-z')
|
||||
cat >"$contents/Info.plist" <<HELPER
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key><string>$helper</string>
|
||||
<key>CFBundleIdentifier</key><string>$prefix.$id</string>
|
||||
<key>CFBundleName</key><string>$helper</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>$version</string>
|
||||
<key>LSMinimumSystemVersion</key><string>14.0</string>
|
||||
<key>LSUIElement</key><string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
HELPER
|
||||
helpers+=("$app/Contents/Frameworks/$helper.app")
|
||||
done
|
||||
content=$root/engines/content/$slug
|
||||
[ -f "$content/index.html" ] || {
|
||||
echo "bundle.sh: no page at $content; run its scripts/build-*-content.sh" >&2
|
||||
exit 1
|
||||
}
|
||||
cp -R "$content" "$app/Contents/Resources/content"
|
||||
fi
|
||||
"$root/packaging/licenses.sh" "$slug" "$app/Contents/Resources/Licenses"
|
||||
cat >"$app/Contents/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -153,18 +106,11 @@ $("$root/packaging/macos/documents.sh" "$slug")
|
||||
</plist>
|
||||
PLIST
|
||||
# Nested code is signed before the app that holds it.
|
||||
for target in ${inner[@]+"${inner[@]}"} ${framework:+"$framework"} ${helpers[@]+"${helpers[@]}"} "$app"; do
|
||||
for target in ${inner[@]+"${inner[@]}"} "$app"; do
|
||||
if [ "$identity" = - ]; then
|
||||
codesign --force --sign - "$target"
|
||||
else
|
||||
# Hardened runtime: V8 in CEF's helpers needs its entitlements.
|
||||
entitled=()
|
||||
case $target in
|
||||
*" Helper (Renderer).app") entitled=(--entitlements "$root/packaging/macos/entitlements/renderer.plist") ;;
|
||||
*" Helper (GPU).app") entitled=(--entitlements "$root/packaging/macos/entitlements/gpu.plist") ;;
|
||||
*" Helper (Plugin).app") entitled=(--entitlements "$root/packaging/macos/entitlements/plugin.plist") ;;
|
||||
esac
|
||||
codesign --force --options runtime --timestamp ${entitled[@]+"${entitled[@]}"} --sign "$identity" "$target"
|
||||
codesign --force --options runtime --timestamp --sign "$identity" "$target"
|
||||
fi
|
||||
done
|
||||
echo "$app"
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -65,16 +65,5 @@ if [ "$slug" = ely-tree ] || [ "$slug" = ely-mark ]; then
|
||||
need "$root/engines/git" "scripts/build-git.sh and scripts/fetch-git-lfs.sh"
|
||||
cp -R "$root/engines/git" "$out/git"
|
||||
fi
|
||||
if uses ely-web-content; then
|
||||
cef=${ELY_CEF:-$root/engines/cef}
|
||||
need "$cef/icudtl.dat" scripts/fetch-cef.sh
|
||||
# CEF's runtime sits beside the app, as cef-dll-sys has it.
|
||||
find "$cef" -maxdepth 1 -type f \( -name '*.dll' -o -name '*.so' -o -name '*.so.*' -o -name '*.pak' \
|
||||
-o -name '*.bin' -o -name '*.dat' -o -name '*.json' -o -name chrome-sandbox \) -exec cp {} "$out/" \;
|
||||
cp -R "$cef/locales" "$out/"
|
||||
content=$root/engines/content/$slug
|
||||
need "$content/index.html" "its scripts/build-*-content.sh"
|
||||
cp -R "$content" "$out/content"
|
||||
fi
|
||||
"$root/packaging/licenses.sh" "$slug" "$out/licenses"
|
||||
echo "$out"
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds ElyMark's page, pinned by the lockfile, into engines/content.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
web=$root/apps/ely-mark/web
|
||||
cd "$web"
|
||||
# Install scripts never run; esbuild's binary is its own package.
|
||||
npm ci --ignore-scripts --no-audit --no-fund
|
||||
node build.mjs "$root/engines/content/ely-mark"
|
||||
@@ -40,11 +40,9 @@ if [ -n "${ELY_SHARE:-}" ]; then
|
||||
else
|
||||
scripts/fetch-references.sh
|
||||
${inside[@]+"${inside[@]}"} scripts/build-mupdf.sh
|
||||
${inside[@]+"${inside[@]}"} scripts/fetch-cef.sh
|
||||
${inside[@]+"${inside[@]}"} scripts/build-git.sh
|
||||
${inside[@]+"${inside[@]}"} scripts/fetch-git-lfs.sh
|
||||
${inside[@]+"${inside[@]}"} scripts/fetch-tessdata.sh
|
||||
${inside[@]+"${inside[@]}"} scripts/build-mark-content.sh
|
||||
${inside[@]+"${inside[@]}"} scripts/build-libreoffice.sh
|
||||
fi
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@ set -euo pipefail
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
cd "$root"
|
||||
find scripts/fetch-references.sh scripts/apply-lo-patches.sh scripts/build-libreoffice.sh scripts/build-mupdf.sh \
|
||||
scripts/build-git.sh scripts/fetch-git-lfs.sh scripts/fetch-tessdata.sh scripts/fetch-cef.sh \
|
||||
scripts/build-git.sh scripts/fetch-git-lfs.sh scripts/fetch-tessdata.sh \
|
||||
packaging/libreoffice patches -type f | LC_ALL=C sort | xargs shasum -a 256 | shasum -a 256 | cut -c1-12
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# CEF's minimal build at cef-rs's pin, SHA-1 checked, into engines/cef.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
[ -d "$root/reference/cef-rs" ] || { echo "fetch-cef: run scripts/fetch-references.sh first" >&2; exit 1; }
|
||||
# Not the system temp: Windows may block programs there.
|
||||
mkdir -p "$root/engines"
|
||||
work=$(mktemp -d "$root/engines/cef-export.XXXXXX")
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
cd "$root/reference/cef-rs"
|
||||
# The exporter checks each archive against CEF's own index.
|
||||
CARGO_TARGET_DIR="$work" cargo run -q -p export-cef-dir -- --force "$root/engines/cef"
|
||||
rm -f "$root"/engines/cef_binary_*.tar.bz2
|
||||
cat "$root/engines/cef/archive.json"
|
||||
@@ -48,10 +48,6 @@ fetch tesseract https://github.com/tesseract-ocr/tesseract.git db0ec62f81b0737fb
|
||||
fetch mailcore2 https://github.com/MailCore/mailcore2.git 1b48bab20b2492fa8ed3965abcbbe099d4ce9d92
|
||||
git -C "$ref/mailcore2" submodule update --init --depth 1 --recursive
|
||||
fetch libetpan https://github.com/dinhviethoa/libetpan.git 4d478625c4c43721191ae1bd7aed387a66c28ec0
|
||||
fetch cef https://github.com/chromiumembedded/cef.git ff57d4eae16d36457895f2de115a71d502e85a08
|
||||
fetch cef-rs https://github.com/tauri-apps/cef-rs.git a712ad9bacd234c050631a95b9745b27d346b7cc
|
||||
fetch milkdown https://github.com/Milkdown/milkdown.git bbb8bcbbb8ce0aa16f92567c23f6236747be9b3b
|
||||
fetch codemirror-dev https://github.com/codemirror/dev.git c010426d06689a7115aa9df08425126b6d3ead2f
|
||||
fetch marktext https://github.com/marktext/marktext.git b29cb5621e12cedb801c1cd886d9005159feaa2e
|
||||
fetch colamd https://github.com/marswaveai/ColaMD.git 5e0f3c3584bb771180afe1a4670efbaf28dee65b
|
||||
fetch magpie https://github.com/yetone/magpie.git e4c8316e3fa9d73683079df3f9142561265bc1bb
|
||||
|
||||
@@ -10,17 +10,7 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
check() { python3 "$root/tests/scripts/comments.py" "$work" >/dev/null 2>&1; }
|
||||
mkdir -p "$work/apps/a/web/src" "$work/tests/scripts"
|
||||
printf '// One short line.\nconst a = 1\n' >"$work/apps/a/web/src/a.js"
|
||||
check || fail "a short comment failed"
|
||||
printf '/* A comment\n spanning lines. */\n' >"$work/apps/a/web/src/a.js"
|
||||
! check || fail "a multi-line JS block comment passed"
|
||||
printf 'const a = 1 /* A comment\n spanning lines. */\n' >"$work/apps/a/web/src/a.js"
|
||||
! check || fail "an inline multi-line JS block comment passed"
|
||||
printf '// One two three four five six seven eight nine ten eleven.\n' >"$work/apps/a/web/src/a.js"
|
||||
! check || fail "an eleven-word JS comment passed"
|
||||
rm "$work/apps/a/web/src/a.js"
|
||||
mkdir -p "$work/site/src"
|
||||
mkdir -p "$work/site/src" "$work/tests/scripts"
|
||||
for ext in ts css; do
|
||||
printf '/* A comment\n spanning lines. */\n' >"$work/site/src/a.$ext"
|
||||
! check || fail "a multi-line .$ext block comment passed"
|
||||
@@ -30,6 +20,8 @@ for ext in ts css; do
|
||||
check || fail "a short .$ext comment failed"
|
||||
rm "$work/site/src/a.$ext"
|
||||
done
|
||||
printf 'const a = 1 /* A comment\n spanning lines. */\n' >"$work/site/src/a.ts"
|
||||
! check || fail "an inline multi-line .ts block comment passed"
|
||||
printf 'const glob = "/*.css";\n' >"$work/site/src/a.ts"
|
||||
check || fail "a /* inside a .ts string failed"
|
||||
printf 'const a = 1; // One two three four five six seven eight nine ten eleven.\n' >"$work/site/src/a.ts"
|
||||
|
||||
@@ -20,7 +20,6 @@ PATTERNS = [
|
||||
"crates/**/*.h",
|
||||
"patches/**/*.patch",
|
||||
"tests/**/*.swift",
|
||||
"apps/*/web/src/**/*.js",
|
||||
"site/src/**/*.ts",
|
||||
"site/src/**/*.css",
|
||||
"site/test/**/*.ts",
|
||||
@@ -37,7 +36,7 @@ LINE_COMMENT = {
|
||||
}
|
||||
|
||||
# Scanned by tokens: strings skipped, trailing comments found.
|
||||
SCANNED = {".js": True, ".ts": True, ".css": False}
|
||||
SCANNED = {".ts": True, ".css": False}
|
||||
|
||||
|
||||
def scan(text, slashes):
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ElyMark signed with hardened runtime still draws its page.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
[ "$(uname)" = Darwin ] || {
|
||||
echo "SKIP hardened-macos: macOS only"
|
||||
exit 0
|
||||
}
|
||||
[ -n "${ELY_SIGN_IDENTITY:-}" ] || {
|
||||
echo "SKIP hardened-macos: set ELY_SIGN_IDENTITY to a signing identity"
|
||||
exit 0
|
||||
}
|
||||
work=$(cd "$(mktemp -d)" && pwd -P)
|
||||
trap 'pkill -f "$work/ElyMark.app/Contents/MacOS/" 2>/dev/null || true; sleep 1; rm -rf "$work"' EXIT
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
ELY_DIST=$work "$root/packaging/macos/bundle.sh" ely-mark debug >"$work/bundle.log" 2>&1 ||
|
||||
fail "bundle: $(tail -n 3 "$work/bundle.log")"
|
||||
app=$work/ElyMark.app
|
||||
codesign --verify --deep --strict "$app" || fail "the signature does not verify"
|
||||
codesign -d --entitlements - "$app/Contents/Frameworks/ely-mark Helper (Renderer).app" 2>/dev/null |
|
||||
grep -q allow-jit || fail "the renderer helper has no JIT entitlement"
|
||||
printf '# Hardened\n' >"$work/t.md"
|
||||
open -n -a "$app" --env "HOME=$work/home" --env ELY_LOG=info --env ELY_NO_DIALOG=1 "$work/t.md"
|
||||
log="$work/home/Library/Application Support/ElyOffice/ely-mark/ely.log"
|
||||
for _ in $(seq 240); do
|
||||
grep -q 'mark: shown as' "$log" 2>/dev/null && {
|
||||
echo "hardened-macos: the signed page draws"
|
||||
exit 0
|
||||
}
|
||||
sleep 0.25
|
||||
done
|
||||
fail "the page never showed: $(grep -E 'web:|mark:' "$log" 2>/dev/null | tail -n 5)"
|
||||
@@ -31,7 +31,7 @@ docker build -q -t ely-smoke-fedora - >/dev/null <<'EOF'
|
||||
FROM fedora:44@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5045d80
|
||||
RUN dnf install -y -q xorg-x11-server-Xvfb mesa-vulkan-drivers xwd xwininfo && dnf clean all
|
||||
EOF
|
||||
# As the work dir's owner: Git and CEF refuse root.
|
||||
# As the work dir's owner: Git refuses others' repos.
|
||||
inside='set -euo pipefail
|
||||
slug=$1 work=$2 file=$3
|
||||
shift 3
|
||||
|
||||
@@ -17,7 +17,7 @@ done
|
||||
release=$root/target/ubuntu/release
|
||||
lo=$root/engines/lo-src/instdir/program
|
||||
required=("$release"/ely-{doc,table,show,pdf,tree,mark,lok-worker,pdf-worker} "$lo/libmergedlo.so"
|
||||
"$root/engines/git/bin/git" "$root/engines/cef/libcef.so")
|
||||
"$root/engines/git/bin/git")
|
||||
for file in "${required[@]}"; do
|
||||
[ -f "$file" ] || fail "no $file; build in packaging/linux/container.sh"
|
||||
magic=$(head -c 4 "$file" | od -An -c | tr -d ' ') || fail "cannot read $file"
|
||||
@@ -26,7 +26,7 @@ done
|
||||
list=$(mktemp)
|
||||
trap 'rm -f "$list"' EXIT
|
||||
find -L "$release"/ely-{doc,table,show,pdf,tree,mark,lok-worker,pdf-worker} "$lo" "$root/engines/git" \
|
||||
"$root/engines/cef" -type f -print0 >"$list" || fail "cannot list every file"
|
||||
-type f -print0 >"$list" || fail "cannot list every file"
|
||||
checked=0
|
||||
while IFS= read -r -d '' file; do
|
||||
magic=$(head -c 4 "$file" | od -An -c | tr -d ' ') || fail "cannot read $file"
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Idle on Linux: ElyMark rereads no theme, settings or file.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
[ "$(uname)" = Linux ] || {
|
||||
echo "SKIP: inotify is Linux"
|
||||
exit 0
|
||||
}
|
||||
[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ] || {
|
||||
echo "SKIP: no display"
|
||||
exit 0
|
||||
}
|
||||
for tool in inotifywait jq hyprctl grim magick tesseract; do
|
||||
command -v "$tool" >/dev/null || {
|
||||
echo "SKIP: no $tool"
|
||||
exit 0
|
||||
}
|
||||
done
|
||||
[ -f "$root/engines/cef/libcef.so" ] || fail "no engines/cef; run scripts/fetch-cef.sh"
|
||||
[ -f "$root/engines/content/ely-mark/index.html" ] || fail "no ElyMark page; run scripts/build-mark-content.sh"
|
||||
app=$(cargo build -q --locked --message-format=json --manifest-path "$root/Cargo.toml" -p ely-mark --bin ely-mark |
|
||||
jq -r 'select(.reason == "compiler-artifact" and .target.name == "ely-mark") | .executable // empty')
|
||||
[ -x "$app" ] || fail "Cargo reported no ElyMark binary"
|
||||
|
||||
work=$(mktemp -d)
|
||||
pid=
|
||||
spy=
|
||||
trap '[ -z "$spy" ] || kill "$spy" 2>/dev/null; [ -z "$pid" ] || stop_app "$pid"; rm -rf "$work"' EXIT
|
||||
# shellcheck source=tests/scripts/linux-common.sh
|
||||
. "$root/tests/scripts/linux-common.sh"
|
||||
current=$work/omarchy/current
|
||||
mkdir -p "$current/theme" "$work/data/ElyOffice" "$work/notes"
|
||||
cp "$root/crates/ely-ui/tests/fixtures/omarchy/catppuccin.toml" "$current/theme/colors.toml"
|
||||
echo catppuccin >"$current/theme.name"
|
||||
echo '{"version": 1}' >"$work/data/ElyOffice/settings.json"
|
||||
note=$work/notes/note.md
|
||||
printf '# Idle\n\nNothing moves.\n' >"$note"
|
||||
|
||||
LD_LIBRARY_PATH="$root/engines/cef" ELY_OMARCHY_DIR="$current" ELY_SMOKE=hold ELY_LOG=info HOME="$work" \
|
||||
XDG_DATA_HOME="$work/data" setsid "$app" "$note" 2>"$work/app.err" &
|
||||
pid=$!
|
||||
seen() { grep -c "$1" "$work/app.err" || true; }
|
||||
logged() { # pattern, what, count before
|
||||
local before=${3:-0}
|
||||
for _ in $(seq 120); do
|
||||
[ "$(seen "$1")" -gt "$before" ] && return
|
||||
kill -0 "$pid" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/app.err")"
|
||||
sleep 0.25
|
||||
done
|
||||
fail "ElyMark missed $2"
|
||||
}
|
||||
logged 'mark: watching 1 folders' "its file's folder"
|
||||
logged 'omarchy catppuccin applied' "the Omarchy theme"
|
||||
sleep 3
|
||||
|
||||
# Four quiet seconds: nothing opens the theme, settings or note.
|
||||
idle() { # when
|
||||
inotifywait -q -m -e open --format '%w%f' "$current" "$current/theme" "$work/data/ElyOffice" "$work/notes" \
|
||||
>"$work/opens" 2>/dev/null &
|
||||
spy=$!
|
||||
for _ in 1 2 3 4 5; do
|
||||
sleep 1
|
||||
kill -0 "$pid" 2>/dev/null || fail "ElyMark quit while $1: $(tail -n 5 "$work/app.err")"
|
||||
done
|
||||
kill "$spy"
|
||||
spy=
|
||||
local opens
|
||||
opens=$(grep -c -E '(colors\.toml|theme\.name|settings\.json|note\.md)$' "$work/opens" || true)
|
||||
[ "$opens" -eq 0 ] || fail "ElyMark $1 opened files $opens times: $(sort "$work/opens" | uniq -c)"
|
||||
echo "linux: ElyMark $1 reads nothing"
|
||||
}
|
||||
idle "idle after start"
|
||||
|
||||
mkdir "$current/next-theme"
|
||||
cp "$root/crates/ely-ui/tests/fixtures/omarchy/gruvbox.toml" "$current/next-theme/colors.toml"
|
||||
rm -rf "$current/theme"
|
||||
mv "$current/next-theme" "$current/theme"
|
||||
echo gruvbox >"$current/theme.name"
|
||||
logged 'omarchy gruvbox applied' "Omarchy's theme swap"
|
||||
echo '{"version": 1, "author": "Tester"}' >"$work/data/ElyOffice/settings.json"
|
||||
logged 'settings: changed on disk' "a settings write"
|
||||
changed=$(seen 'changed outside')
|
||||
printf '# Idle\n\nSomething moved.\n' >"$note"
|
||||
logged 'changed outside' "an outside edit" "$changed"
|
||||
# The window shows the new words.
|
||||
shown=
|
||||
for _ in $(seq 20); do
|
||||
kill -0 "$pid" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/app.err")"
|
||||
id=$(hyprctl clients -j | jq -r --argjson pid "$pid" '[.[] | select(.pid == $pid and .mapped)][0].stableId // empty')
|
||||
if [ -n "$id" ]; then
|
||||
grim -T "$id" "$work/shot.png" 2>"$work/grim.err" || fail "grim failed: $(cat "$work/grim.err")"
|
||||
magick "$work/shot.png" -colorspace Gray -negate "$work/ocr.png"
|
||||
text=$(tesseract "$work/ocr.png" - 2>"$work/ocr.err") || fail "tesseract failed: $(cat "$work/ocr.err")"
|
||||
grep -q 'moved' <<<"$text" && shown=1 && break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
[ -n "$shown" ] || fail "ElyMark does not show the outside edit"
|
||||
sleep 2
|
||||
idle "idle after changes"
|
||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
script=$root/scripts/fetch-references.sh
|
||||
names="Ely-GPUI-Components zed libreoffice-core mupdf git tesseract mailcore2 libetpan cef cef-rs milkdown codemirror-dev marktext colamd magpie agent-client-protocol markion markdown-rs omarchy"
|
||||
names="Ely-GPUI-Components zed libreoffice-core mupdf git tesseract mailcore2 libetpan marktext colamd magpie agent-client-protocol markion markdown-rs omarchy"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
@@ -29,8 +29,7 @@ for header in \
|
||||
tesseract/include/tesseract/capi.h \
|
||||
mailcore2/CMakeLists.txt \
|
||||
libetpan/src/low-level/gmail/mailgmail.h \
|
||||
libetpan/src/low-level/activesync/mailactivesync.h \
|
||||
cef-rs/Cargo.toml; do
|
||||
libetpan/src/low-level/activesync/mailactivesync.h; do
|
||||
[ -s "$root/reference/$header" ] || fail "missing reference/$header"
|
||||
done
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ while IFS=$'\t' read -r slug name; do
|
||||
ely-doc | ely-table | ely-show) engines=(LibreOffice) ;;
|
||||
ely-pdf) engines=(MuPDF MuPDF/fonts/urw MuPDF/fonts/sil MuPDF/fonts/noto MuPDF/fonts/droid MuPDF/fonts/han OpenSSL tessdata GreatVibes) ;;
|
||||
ely-tree) engines=(Git git-lfs) ;;
|
||||
ely-mark) engines=(CEF content) ;;
|
||||
ely-mark) engines=(KaTeX) ;;
|
||||
esac
|
||||
for part in LICENSE rust-crates.txt "${engines[@]}"; do
|
||||
[ -n "$(find "$licenses/$part" -type f -size +0 2>/dev/null)" ] || fail "$name lacks the licences of $part"
|
||||
@@ -83,9 +83,8 @@ while IFS=$'\t' read -r slug name; do
|
||||
[ -n "$(find "$family" -type f -size +0 \( -iname 'licen[cs]e*' -o -iname 'copying*' -o -iname 'notice*' -o -iname 'ofl*' \))" ] ||
|
||||
fail "$name lacks the licence of the ${family##*/} fonts"
|
||||
done
|
||||
# Nothing inside registers with macOS but CEF's helpers.
|
||||
extra=$(find "$app/Contents" \( -name '*.app' -o -name '*.appex' -o -name '*.mdimporter' -o -name '*.qlgenerator' \) |
|
||||
grep -v "/Frameworks/$slug Helper" || true)
|
||||
# Nothing inside registers with macOS.
|
||||
extra=$(find "$app/Contents" \( -name '*.app' -o -name '*.appex' -o -name '*.mdimporter' -o -name '*.qlgenerator' \))
|
||||
[ -z "$extra" ] || fail "$name carries bundles macOS registers: $extra"
|
||||
# No binary may need a newer macOS than the app.
|
||||
least=$(/usr/libexec/PlistBuddy -c "Print :LSMinimumSystemVersion" "$app/Contents/Info.plist")
|
||||
@@ -125,10 +124,6 @@ while IFS=$'\t' read -r slug name; do
|
||||
# Built-ins stay links to one binary, not copies.
|
||||
[ "$(du -sm "$app/Contents/Resources/git" | cut -f1)" -lt 100 ] || fail "ElyTree's Git copies its built-ins"
|
||||
;;
|
||||
ely-mark)
|
||||
grep -qF "web: CEF loaded from $app/Contents/Frameworks" "$log" || fail "ElyMark used another CEF"
|
||||
grep -qF "mark: page from $app/Contents/Resources/content" "$log" || fail "ElyMark used another page"
|
||||
;;
|
||||
esac
|
||||
# A running app takes Finder's next file.
|
||||
open -a "$app" "$files/$second"
|
||||
|
||||
+21
-21
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Storage: sizes shown; log cleared now, cache at restart.
|
||||
# Storage: sizes shown; log cleared now, copies at restart.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
@@ -48,18 +48,18 @@ quit() {
|
||||
for _ in $(seq 40); do kill -0 "$pid" 2>/dev/null || break; sleep 0.25; done
|
||||
wait "$pid" 2>/dev/null || true
|
||||
}
|
||||
mkdir -p "$data/web"
|
||||
# A known 2 MiB in the cache for the row.
|
||||
dd if=/dev/zero of="$data/web/ely-sample" bs=1024 count=2048 2>/dev/null
|
||||
mkdir -p "$data/recovery"
|
||||
# A known 2 MiB in the copies for the row.
|
||||
dd if=/dev/zero of="$data/recovery/ely-sample" bs=1024 count=2048 2>/dev/null
|
||||
# A log long enough that clearing it shows.
|
||||
head -c 8192 /dev/zero | tr '\0' 'x' >"$data/ely.log"
|
||||
start
|
||||
keys 'keystroke "," using command down'
|
||||
seen "title=Storage" || fail "no settings window"
|
||||
"$work/ax-press" "$pid" Storage >/dev/null
|
||||
seen "title=Web content cache" || fail "the storage page lists no cache"
|
||||
shown=$(kb "Web content cache")
|
||||
[ "${shown:-0}" -ge 2048 ] || fail "the cache shows ${shown:-nothing} KB, not at least 2048"
|
||||
seen "title=Recovery copies" || fail "the storage page lists no copies"
|
||||
shown=$(kb "Recovery copies")
|
||||
[ "${shown:-0}" -ge 2048 ] || fail "the copies show ${shown:-nothing} KB, not at least 2048"
|
||||
shown=$(kb "This app's log")
|
||||
[ "${shown:-0}" -ge 8 ] || fail "the log shows ${shown:-nothing} KB, not at least 8"
|
||||
# Each clear asks first; nothing goes before the yes.
|
||||
@@ -70,11 +70,11 @@ seen "title=Delete" || fail "clearing the log did not ask"
|
||||
sleep 1
|
||||
[ "$(wc -c <"$data/ely.log")" -lt 4096 ] || fail "the log was not cleared"
|
||||
"$work/ax-press" "$pid" "Clear at next start" AXButton >/dev/null
|
||||
seen "title=Delete" || fail "clearing the cache did not ask"
|
||||
[ ! -e "$data/.clear-web" ] || fail "the cache was marked before the yes"
|
||||
seen "title=Delete" || fail "clearing the copies did not ask"
|
||||
[ ! -e "$data/.clear-recovery" ] || fail "the copies were marked before the yes"
|
||||
"$work/ax-press" "$pid" Delete AXButton >/dev/null
|
||||
seen "Clears at next start" || fail "the cache clear is not shown as pending"
|
||||
[ -e "$data/ely-sample" ] || [ -d "$data/web" ] || fail "the cache went while in use"
|
||||
seen "Clears at next start" || fail "the copies clear is not shown as pending"
|
||||
[ -e "$data/recovery/ely-sample" ] || fail "the copies went while in use"
|
||||
# Ten points kept per document, once twelve were made.
|
||||
"$work/ax-press" "$pid" 10 >/dev/null
|
||||
for _ in $(seq 20); do grep -q '"keep": 10' "$data/settings.json" 2>/dev/null && break; sleep 0.25; done
|
||||
@@ -82,8 +82,9 @@ grep -q '"keep": 10' "$data/settings.json" || fail "the recovery limit is not ke
|
||||
osascript -e "tell application \"System Events\" to click button 1 of (first window of (first process whose unix id is $pid) whose name is \"Settings\")" >/dev/null
|
||||
sleep 1
|
||||
# An untitled document: never autosaved, so it keeps recovery points.
|
||||
tabs=$(grep -c "as tab" "$data/ely.log" || true)
|
||||
keys 'keystroke "n" using command down'
|
||||
for _ in $(seq 40); do grep -q "mark: shown" "$data/ely.log" && break; sleep 0.25; done
|
||||
for _ in $(seq 40); do [ "$(grep -c "as tab" "$data/ely.log")" -gt "$tabs" ] && break; sleep 0.25; done
|
||||
sleep 2
|
||||
# Twelve points written, one edit each.
|
||||
written() { grep -c "recovery: a point at" "$data/ely.log" || true; }
|
||||
@@ -146,22 +147,21 @@ grep -q '"keep": 20' "$data/settings.json" || fail "the retried limit is not kep
|
||||
sleep 0.5
|
||||
"$work/ax-values" "$pid" | grep -q "Not done" && fail "the failure stays after a good save"
|
||||
quit
|
||||
touch "$data/web/ely-marker"
|
||||
touch "$data/recovery/ely-marker"
|
||||
start
|
||||
[ ! -e "$data/web/ely-marker" ] || fail "the cache was not cleared at the start"
|
||||
[ ! -e "$data/recovery/ely-marker" ] || fail "the copies were not cleared at the start"
|
||||
quit
|
||||
# A failed clear stops the start; the mark stays.
|
||||
mkdir -p "$data/web/locked"
|
||||
touch "$data/web/locked/kept" "$data/.clear-web"
|
||||
chmod 555 "$data/web/locked"
|
||||
mkdir -p "$data/recovery/locked"
|
||||
touch "$data/recovery/locked/kept" "$data/.clear-recovery"
|
||||
chmod 555 "$data/recovery/locked"
|
||||
status=0
|
||||
ELY_SMOKE=hold ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$home" perl -e 'alarm 60; exec @ARGV' "$root/target/debug/ely-mark" >"$work/out" 2>&1 || status=$?
|
||||
chmod 755 "$data/web/locked"
|
||||
chmod 755 "$data/recovery/locked"
|
||||
[ "$status" = 1 ] || fail "a failed clear started anyway ($status)"
|
||||
grep -q "could not be deleted" "$work/out" || fail "the failed clear is not told: $(tail -3 "$work/out")"
|
||||
grep -q "web: CEF" "$work/out" && fail "the cache opened after a failed clear"
|
||||
[ -e "$data/.clear-web" ] && [ -e "$data/web/locked/kept" ] || fail "the failed clear lost its mark or data"
|
||||
[ -e "$data/.clear-recovery" ] && [ -e "$data/recovery/locked/kept" ] || fail "the failed clear lost its mark or data"
|
||||
start
|
||||
[ ! -e "$data/web/locked/kept" ] || fail "the clear was not tried again"
|
||||
[ ! -e "$data/recovery/locked/kept" ] || fail "the clear was not tried again"
|
||||
quit
|
||||
echo "ok: storage"
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# A live WebView: input, fence, failures, release, paint.
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
|
||||
[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || {
|
||||
echo "SKIP: no CEF; run scripts/fetch-cef.sh"
|
||||
exit 0
|
||||
}
|
||||
work=$(mktemp -d)
|
||||
probe=
|
||||
trap '[ -n "$probe" ] && kill "$probe" 2>/dev/null; rm -rf "$work"' EXIT
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cargo build -q -p ely-web-content --example webprobe --manifest-path "$root/Cargo.toml"
|
||||
swiftc -O -o "$work/share" "$root/tests/scripts/window-share.swift"
|
||||
swiftc -O -o "$work/press" "$root/tests/scripts/ax-press.swift"
|
||||
ELY_LOG=info ELY_LOCALE=en ELY_SMOKE=hold HOME="$work" XDG_DATA_HOME="$work/data" \
|
||||
perl -e 'alarm 150; exec @ARGV' "$root/target/debug/examples/webprobe" >"$work/out" 2>"$work/err" &
|
||||
probe=$!
|
||||
wait_for() {
|
||||
for _ in $(seq 400); do
|
||||
grep -qxF "$1" "$work/out" && return 0
|
||||
kill -0 "$probe" 2>/dev/null || break
|
||||
sleep 0.25
|
||||
done
|
||||
fail "never saw '$1': $(cat "$work/out") $(tail -n 5 "$work/err")"
|
||||
}
|
||||
wait_for "probe: hold"
|
||||
[ "$("$work/press" $$ none)" != untrusted ] || fail "needs Accessibility permission"
|
||||
# The page's magenta marker: share of the window.
|
||||
marker() {
|
||||
local share
|
||||
share=$("$work/share" "$probe" 255,0,170)
|
||||
case $share in
|
||||
denied) fail "needs Screen Recording permission" ;;
|
||||
none) fail "the probe has no window" ;;
|
||||
esac
|
||||
echo "$share"
|
||||
}
|
||||
share=$(marker)
|
||||
awk -v s="$share" 'BEGIN { exit !(s > 0.01) }' || fail "the page is not painted (marker share $share)"
|
||||
# A killed renderer: the fault shows, its button reloads.
|
||||
pkill -P "$probe" -f -- "--type=renderer" || fail "no renderer to stop"
|
||||
wait_for "probe: main failed"
|
||||
sleep 1
|
||||
gone=$(marker)
|
||||
awk -v s="$gone" 'BEGIN { exit !(s < 0.001) }' || fail "the dead page still shows (marker share $gone)"
|
||||
pressed=
|
||||
for _ in $(seq 20); do
|
||||
[ "$("$work/press" "$probe" Reload)" = pressed ] && pressed=1 && break
|
||||
sleep 0.25
|
||||
done
|
||||
[ -n "$pressed" ] || fail "no Reload button in the fault view"
|
||||
wait_for "probe: recovered"
|
||||
sleep 1.5
|
||||
back=$(marker)
|
||||
awk -v s="$back" 'BEGIN { exit !(s > 0.01) }' || fail "the page did not come back (marker share $back)"
|
||||
wait "$probe" || fail "the probe did not exit cleanly: $(tail -n 5 "$work/err")"
|
||||
probe=
|
||||
out=$(cat "$work/out")
|
||||
for want in "probe: value=你好a" "probe: focused=text" "probe: zoom=1.25" "probe: stayed" \
|
||||
"probe: outside=false" "probe: network=refused" "probe: udp=silent" "probe: rtc=undefined" "probe: main failed" "probe: hash loaded" "probe: missing failed" \
|
||||
"probe: closed 20" "probe: dropped 5 unmade, 10 early" "probe: inert loaded" "probe: scripted ran" "probe: inert checked"; do
|
||||
grep -qxF "$want" <<<"$out" || fail "expected '$want', got: $out"
|
||||
done
|
||||
! grep -q "probe: inert ran" <<<"$out" || fail "an inert page ran a script: $out"
|
||||
grep -qE '^probe: fg=rgba\([0-9]+, [0-9]+, [0-9]+, ' <<<"$out" || fail "no theme token: $out"
|
||||
# Freed before the hold: 20 closed, 5 unmade, 10 early.
|
||||
freed=$(awk "/probe: hold/ { exit } /a page's memory is freed/ { n++ } END { print n + 0 }" "$work/err")
|
||||
[ "$freed" -ge 35 ] || fail "only $freed of 35 closed pages freed their memory"
|
||||
! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")"
|
||||
echo "ok: webview (marker share $share, $freed pages freed)"
|
||||
Reference in new issue
Block a user