From fcf91137f968849486933748466b8ad7414f4cc6 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Sun, 8 Mar 2026 18:38:16 +0800 Subject: [PATCH 01/29] docs: add project badges to readmes and release notes --- README-zh.md | 5 +++++ README.md | 3 +++ scripts/generate-release-notes.sh | 3 +++ 3 files changed, 11 insertions(+) diff --git a/README-zh.md b/README-zh.md index 9266953..b1796fd 100644 --- a/README-zh.md +++ b/README-zh.md @@ -1,5 +1,10 @@ # CRIEW 中文使用说明 +[![build](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml/badge.svg)](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml) +[![crates.io](https://img.shields.io/crates/v/criew?label=latest)](https://crates.io/crates/criew) +[![docs](https://docs.rs/criew/badge.svg)](https://docs.rs/criew/) +[![codecov](https://codecov.io/github/ChenMiaoi/CRIEW/graph/badge.svg?token=AH99YLKKPD)](https://codecov.io/github/ChenMiaoi/CRIEW) + CRIEW 是一个面向 Linux kernel patch 邮件工作流的 Rust TUI 工具,用来把“订阅 -> 同步 -> 阅读 -> 应用 patch -> 回复邮件”放进同一条终端内、本地优先的工作流中。 `CRIEW` 的含义是 `Code Review in Efficient Workflow`。 仓库名保持大写 `CRIEW`,crate 和 CLI 命令使用小写 `criew`。 diff --git a/README.md b/README.md index da5b1ae..4b07fd9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # CRIEW +[![build](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml/badge.svg)](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml) +[![crates.io](https://img.shields.io/crates/v/criew?label=latest)](https://crates.io/crates/criew) +[![docs](https://docs.rs/criew/badge.svg)](https://docs.rs/criew/) [![codecov](https://codecov.io/github/ChenMiaoi/CRIEW/graph/badge.svg?token=AH99YLKKPD)](https://codecov.io/github/ChenMiaoi/CRIEW) CRIEW is a Rust TUI for Linux kernel patch mail workflows. diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh index 8d3751a..3e3e53a 100755 --- a/scripts/generate-release-notes.sh +++ b/scripts/generate-release-notes.sh @@ -55,6 +55,9 @@ mkdir -p "$(dirname "${output_file}")" { printf '## %s %s\n\n' "${package_name}" "${tag_name}" + printf '[![build](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml/badge.svg)](https://github.com/ChenMiaoi/CRIEW/actions/workflows/ci.yml)\n' + printf '[![crates.io](https://img.shields.io/crates/v/criew?label=latest)](https://crates.io/crates/criew)\n' + printf '[![docs](https://docs.rs/criew/badge.svg)](https://docs.rs/criew/)\n' printf '[![codecov](https://codecov.io/github/ChenMiaoi/CRIEW/graph/badge.svg?token=AH99YLKKPD)](https://codecov.io/github/ChenMiaoi/CRIEW)\n\n' printf 'Source release for `%s`.\n\n' "$(git rev-list -n 1 "${tag_name}")" printf '### Release Assets\n\n' From 0d861400ca4c678389dd72b2bc98d8634177e684 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Sun, 8 Mar 2026 18:52:05 +0800 Subject: [PATCH 02/29] docs: simplify crates.io install command Signed-off-by: Chen Miao --- README-zh.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README-zh.md b/README-zh.md index b1796fd..dec42ab 100644 --- a/README-zh.md +++ b/README-zh.md @@ -56,7 +56,7 @@ criew doctor ### 从 crates.io 安装 ```bash -cargo install criew --locked +cargo install criew ``` 这个安装包会把一个最小可运行的 vendored `b4` fallback 内嵌进二进制。 diff --git a/README.md b/README.md index 4b07fd9..4f74278 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ refresh your checkout or reinstall the binary and bootstrap a new CRIEW runtime ### Install from crates.io ```bash -cargo install criew --locked +cargo install criew ``` This build keeps a minimal vendored `b4` runtime embedded in the binary. From 9753800c82e091e45ac515e19a49d3bb984478f4 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Sun, 8 Mar 2026 19:11:21 +0800 Subject: [PATCH 03/29] chore(repo): add CRIEW skill and tighten commit policy - add a repository-local CRIEW skill that points future work at the repo guidelines - require Signed-off-by trailers and large-commit bullet bodies in hooks and CI - document the commit policy in the English and Chinese guideline files Signed-off-by: Chen Miao --- .codex/skills/criew-development/SKILL.md | 94 +++++++++++++++ .../criew-development/agents/openai.yaml | 4 + .githooks/README.md | 35 +++++- .githooks/commit-msg | 109 +++++++++++++++--- .githooks/prepare-commit-msg | 7 ++ docs/code-guildline-cn.md | 8 ++ docs/code-guildline.md | 8 ++ scripts/check-commit-messages.sh | 2 +- 8 files changed, 246 insertions(+), 21 deletions(-) create mode 100644 .codex/skills/criew-development/SKILL.md create mode 100644 .codex/skills/criew-development/agents/openai.yaml diff --git a/.codex/skills/criew-development/SKILL.md b/.codex/skills/criew-development/SKILL.md new file mode 100644 index 0000000..25f10be --- /dev/null +++ b/.codex/skills/criew-development/SKILL.md @@ -0,0 +1,94 @@ +--- +name: criew-development +description: Repository-specific workflow and coding rules for the CRIEW codebase. Use when modifying or reviewing CRIEW Rust code, TUI behavior, sync/IMAP/reply/patch workflows, migrations, tests, docs, or config, and whenever the task must follow `docs/code-guildline.md` or `docs/code-guildline-cn.md`. +--- + +# Criew Development + +## Overview + +Follow the CRIEW repository's coding rules, architecture boundaries, and validation workflow. +Read the repository docs first, then make focused changes that preserve the current mail, patch, and reply behavior. + +## Start With The Project Docs + +Read the relevant repository docs before editing code. + +- Read `docs/code-guildline.md` first for the canonical coding rules. +- Read `docs/code-guildline-cn.md` when the user works in Chinese or explicitly asks for the Chinese guideline. +- Read `README.md` or `README-zh.md` before changing user-visible behavior, install steps, naming, or operator workflow. +- Read `docs/design.md` before changing architecture, module boundaries, sync flow, or data-model assumptions. +- Read `docs/reply-format-spec.md` before changing reply composition, quoting, headers, or send flow. +- Read `docs/config.example.toml` before changing config keys, defaults, or path semantics. + +Treat `docs/code-guildline.md` as the priority source when a local convention is unclear. +Then follow tool-enforced rules and the existing style in the touched module. + +## Keep The CRIEW Boundaries + +Use the existing layer split unless the task explicitly asks for architectural rework. + +- Keep `src/app/` focused on use-case orchestration and CLI/TUI entry workflows. +- Keep `src/domain/` focused on core models and business meaning. +- Keep `src/infra/` focused on storage, config, IMAP, sendmail, `b4`, logging, and other external integrations. +- Keep `src/ui/` focused on TUI state, rendering, input handling, and UI tests. +- Keep database changes in `migrations/` and align schema changes with the Rust storage code in the same change. +- Treat `vendor/b4/` as vendored third-party code; avoid editing it unless the task explicitly targets that dependency. + +Preserve the current naming set from the README: +use `CRIEW` for the repository name and `criew` for the crate, CLI, config file, runtime directory, and environment variables. +Do not reintroduce legacy Courier naming. + +## Apply The Coding Rules Directly + +Implement changes in the style required by `docs/code-guildline.md`. + +- Prefer descriptive, behavior-accurate names. +- Encode units in names when the type system does not. +- Use assertion-style names for booleans. +- Keep one primary concept per file; split large files early. +- Keep code readable from top to bottom, with high-level flow before helper detail. +- Keep functions small and focused; reduce nesting with early returns. +- Avoid ambiguous boolean arguments; prefer enums or small config structs. +- Express invariants in types when practical. +- Propagate fallible paths with `?`. +- Keep visibility and lint suppression as narrow as possible. +- Add `// SAFETY:` comments for every `unsafe` block and document `# Safety` on unsafe APIs. +- Write comments only when they explain why, constraints, or design tradeoffs. + +## Use A Repository-Focused Workflow + +Follow this order unless the task is trivial. + +1. Locate the relevant module with `rg` and read the surrounding code before proposing changes. +2. Confirm the user-visible behavior from the README or a spec doc when the change affects sync, reply, patch apply/export, config, or startup flow. +3. Keep the change focused on one logical topic. +4. Add or update regression tests when fixing a bug or changing observable behavior. +5. Update docs when behavior, commands, config, or workflow changed. + +Prefer updating both `README.md` and `README-zh.md` when the change affects user-facing usage or setup. +Keep PR-sized changes focused and commit subjects aligned with the repository's Conventional Commit prefixes: +`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. +When asked to create a commit in this repository, use `git commit -s`. +The repository hook at `.githooks/commit-msg` and CI both validate the `Signed-off-by:` trailer. +Simple commits may use only the subject line. +For larger commits, add a body with bullet points that summarize the main changes. + +## Run The Required Validation + +Run the full repository validation set after non-trivial changes when the environment allows it. + +- `cargo fmt --all -- --check` +- `cargo clippy --all-targets --all-features -- -D warnings` +- `cargo test --all-targets --all-features` +- `./scripts/check-coverage.sh` + +If time or environment constraints prevent a command from running, report that clearly and avoid claiming full verification. + +## Pull The Right Context On Demand + +Load extra repository docs only when the task needs them. + +- Read `docs/mvp-milestones.md` and `docs/reply-mvp-milestones.md` for historical intent or rollout sequencing. +- Read `docs/vim-mvp-milestones.md` and `docs/code-preview-vim-prototype.md` for Vim-mode and code-preview behavior. +- Read `src/ui/tui/tests.rs` before extending TUI behavior that already has test coverage. diff --git a/.codex/skills/criew-development/agents/openai.yaml b/.codex/skills/criew-development/agents/openai.yaml new file mode 100644 index 0000000..d3064f0 --- /dev/null +++ b/.codex/skills/criew-development/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CRIEW Development" + short_description: "CRIEW repo workflow and coding rules" + default_prompt: "Use $criew-development to make a focused change in the CRIEW repository." diff --git a/.githooks/README.md b/.githooks/README.md index 40efab4..fb84355 100644 --- a/.githooks/README.md +++ b/.githooks/README.md @@ -3,7 +3,8 @@ 本目录保存 CRIEW 仓库版本化管理的 Git hooks。 这些 hooks 基于 [docs/code-guildline-cn.md](../docs/code-guildline-cn.md) 中的 -提交规范实现,目标是让提交信息在本地就能得到提示和校验。 +提交规范实现,目标是让提交信息和 `Signed-off-by:` trailer +在本地就能得到提示和校验。 GitHub Actions CI 也会通过 [scripts/check-commit-messages.sh](../scripts/check-commit-messages.sh) 复用这里的 `commit-msg` 规则,避免本地与 CI 漂移。 @@ -37,6 +38,8 @@ git config --get core.hooksPath - 当你执行普通 `git commit` 且提交信息还是空白时,自动插入注释模板 - 提示允许的格式:`feat:`、`feat(scope):`、`fix:`、`docs:`、`refactor:`、`test:`、`chore:` - 提醒主题句使用祈使语气,并尽量控制在 72 字符内 +- 提醒作者自己产生的 commit 使用 `git commit -s` +- 提醒较大提交需要补 body,并用分点条目说明主要改动 不会改写的场景: @@ -51,6 +54,8 @@ git config --get core.hooksPath - 校验提交信息第一条真实主题行 - 要求主题格式为:`: ` 或 `(): ` +- 要求作者自己产生的 commit 带有合法的 `Signed-off-by:` trailer +- 当提交改动较大时,要求 body 中存在分点条目 - 允许的 `type`: - `feat` - `fix` @@ -60,13 +65,37 @@ git config --get core.hooksPath - `chore` - 主题超过 72 字符时给出 warning,但不阻断提交 -放行的特殊场景: +主题特判场景: - `Merge ...` - `Revert ...` - `fixup! ...` - `squash! ...` +说明: + +- `Merge ...` 直接放行 +- `Revert ...` 允许不使用 Conventional Commit 前缀,但仍需 `Signed-off-by:` +- `fixup! ...` / `squash! ...` 会先剥离前缀再做主题校验,仍需 `Signed-off-by:` +- `Merge ...`、`Revert ...`、`fixup! ...`、`squash! ...` 不参与“大提交必须写 body”的额外校验 + +当前“大提交”阈值: + +- 单个 commit 改动至少 6 个文件 +- 或新增/删除总行数至少 150 行 + +推荐提交流程: + +```bash +git commit -s +git commit -s -m "fix(sync): handle empty IMAP checkpoint" +git commit -s + +# Then add a body for a larger change: +# - split sync state handling from UI refresh +# - add regression coverage for startup inbox sync +``` + ## 推荐写法 推荐: @@ -96,6 +125,8 @@ hooks 当前落地的是 `docs/code-guildline-cn.md` 中与提交信息直接相 - `atomic-commits`:一个 commit 一个逻辑变化 - `refactor-then-feature`:重构与功能改动尽量拆开 +- `signed-commits`:作者自己产生的 commit 必须带 `Signed-off-by:` trailer +- `large-commit-body`:较大提交必须带分点 body - Conventional Commit 前缀: - `feat:` - `feat(scope):` diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 1e46338..143f8ac 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -4,26 +4,18 @@ set -euo pipefail message_file=${1:?missing commit message file} -comment_char="$(git config --get core.commentChar || true)" -if [[ -z "${comment_char}" || "${comment_char}" == "auto" ]]; then - comment_char="#" -fi +large_commit_file_threshold=${CRIEW_LARGE_COMMIT_FILE_THRESHOLD:-6} +large_commit_line_threshold=${CRIEW_LARGE_COMMIT_LINE_THRESHOLD:-150} + +cleaned_message="$(git stripspace --strip-comments < "${message_file}")" subject="$( - awk -v comment_char="${comment_char}" ' - { - trimmed = $0 - sub(/^[[:space:]]+/, "", trimmed) - if (trimmed == "") { - next - } - if (substr(trimmed, 1, length(comment_char)) == comment_char) { - next - } - print trimmed + printf '%s\n' "${cleaned_message}" | awk ' + NF { + print exit } - ' "${message_file}" + ' )" if [[ -z "${subject}" ]]; then @@ -31,17 +23,25 @@ if [[ -z "${subject}" ]]; then exit 1 fi -if [[ "${subject}" =~ ^(Merge|Revert)\ ]]; then +if [[ "${subject}" =~ ^Merge\ ]]; then exit 0 fi +skip_conventional_subject_check=0 +skip_large_commit_body_check=0 +if [[ "${subject}" =~ ^Revert\ ]]; then + skip_conventional_subject_check=1 + skip_large_commit_body_check=1 +fi + if [[ "${subject}" =~ ^(fixup\!\ |squash\!\ ) ]]; then + skip_large_commit_body_check=1 subject=${subject#*! } fi conventional_subject_pattern='^(feat|fix|docs|refactor|test|chore)(\([^)[:space:]]+\))?: .+' -if [[ ! "${subject}" =~ ${conventional_subject_pattern} ]]; then +if (( ! skip_conventional_subject_check )) && [[ ! "${subject}" =~ ${conventional_subject_pattern} ]]; then cat >&2 <<'EOF' commit message subject must use one of: feat: summary @@ -66,3 +66,76 @@ if (( subject_length > 72 )); then "warning: commit subject is ${subject_length} characters; keep it within 72 when possible" \ >&2 fi + +trailers="$(printf '%s\n' "${cleaned_message}" | git interpret-trailers --parse)" +signed_off_by_pattern='^Signed-off-by: .+ <.+>$' + +if ! grep -Eq "${signed_off_by_pattern}" <<< "${trailers}"; then + cat >&2 <<'EOF' +commit message must include a Signed-off-by trailer. + +Use `git commit -s` (or `git commit --signoff`) so Git appends: + Signed-off-by: Your Name +EOF + exit 1 +fi + +commit_file_count=${CRIEW_COMMIT_FILE_COUNT:-} +commit_line_count=${CRIEW_COMMIT_LINE_COUNT:-} + +if [[ -z "${commit_file_count}" || -z "${commit_line_count}" ]]; then + if [[ -n "${CRIEW_COMMIT_SHA:-}" ]]; then + diff_numstat="$(git show --format= --numstat --find-renames "${CRIEW_COMMIT_SHA}")" + elif git rev-parse --verify --quiet HEAD >/dev/null; then + diff_numstat="$(git diff --cached --numstat --find-renames HEAD)" + else + diff_numstat="$(git diff --cached --numstat --find-renames --root)" + fi + + read -r commit_file_count commit_line_count <<< "$( + printf '%s\n' "${diff_numstat}" | awk ' + NF >= 3 { + files += 1 + if ($1 != "-") { + lines += $1 + } + if ($2 != "-") { + lines += $2 + } + } + END { + print files + 0, lines + 0 + } + ' + )" +fi + +is_large_commit=0 +if (( commit_file_count >= large_commit_file_threshold || commit_line_count >= large_commit_line_threshold )); then + is_large_commit=1 +fi + +body_lines="$(printf '%s\n' "${cleaned_message}" | sed '1d')" +has_bullet_body=0 +if grep -Eq '^[[:space:]]*[-*][[:space:]]+\S' <<< "${body_lines}"; then + has_bullet_body=1 +fi + +if (( is_large_commit )) && (( ! skip_large_commit_body_check )); then + if (( ! has_bullet_body )); then + cat >&2 < "${commit_message_file}" printf '%s\n' "checking commit ${commit_sha}: ${commit_subject}" - if ! "${hook_path}" "${commit_message_file}"; then + if ! CRIEW_COMMIT_SHA="${commit_sha}" "${hook_path}" "${commit_message_file}"; then status=1 fi From 55b60677db988526b327c300a078b2ce1d6ecd6f Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Sat, 7 Mar 2026 23:36:23 +0800 Subject: [PATCH 04/29] feat(tui): add configurable main-page keymaps - add UiKeymap enum (Default / Vim) to RuntimeConfig and config parser - implement vim-style h/l focus + j/k navigation alongside default i/k + j/l - wire keymap into input handler, render footer shortcuts, and config palette - add config set/get support for ui_keymap with live keymap switching Signed-off-by: Chao Liu --- docs/code-preview-vim-prototype.md | 10 ++- docs/config.example.toml | 5 ++ docs/design.md | 8 +- src/app/patch.rs | 1 + src/app/sync.rs | 6 ++ src/infra/config.rs | 30 ++++++- src/infra/sendmail.rs | 1 + src/ui/tui.rs | 30 ++++++- src/ui/tui/config.rs | 11 +++ src/ui/tui/input.rs | 57 ++++++++++++-- src/ui/tui/render.rs | 23 ++++-- src/ui/tui/tests.rs | 121 ++++++++++++++++++++++++++++- 12 files changed, 279 insertions(+), 24 deletions(-) diff --git a/docs/code-preview-vim-prototype.md b/docs/code-preview-vim-prototype.md index 1b84cf6..bfde398 100644 --- a/docs/code-preview-vim-prototype.md +++ b/docs/code-preview-vim-prototype.md @@ -3,9 +3,11 @@ ## 1. 背景与目标 当前 `Code Browser` 页面的 `Source Preview` 是只读预览。 -本原型目标是在不离开 `Code Preview` 的前提下提供 Vim 风格编辑体验,并避免和现有全局键位冲突: +本原型目标是在不离开 `Code Preview` 的前提下提供 Vim 风格编辑体验,并避免和主页面全局键位冲突: -- 默认仍是浏览模式,沿用现有键位(`j/l` 焦点切换、`i/k` 移动) +- 默认仍是浏览模式,沿用当前配置选中的主页面键位 +- `ui.keymap = "default"` 时为 `j/l` 焦点切换、`i/k` 移动 +- `ui.keymap = "vim"` 时为 `h/l` 焦点切换、`j/k` 移动 - 仅在用户按 `e` 后,进入 Vim-like 编辑模式 - 编辑完成后可保存并回到浏览模式,预览立即显示最新内容 @@ -17,7 +19,7 @@ - 提供 `NORMAL` / `INSERT` / `COMMAND` 三种模式 - 支持基础移动、插入、删除、保存、退出 - 支持最小 `:` 命令:`:w`、`:q`、`:wq` -- 不改变默认模式下的全局键位语义 +- 不改变默认模式下当前配置所选择的全局键位语义 不在 P0 范围内: @@ -154,7 +156,7 @@ ### 6.2 回归测试 -- 未进入编辑模式时,`j/l/i/k`、`Tab`、命令栏、搜索、patch 操作行为不变 +- 未进入编辑模式时,主页面当前配置键位、`Tab`、命令栏、搜索、patch 操作行为不变 - Code Browser 的 tree 展开/收起行为不变 ## 7. 后续迭代(P1/P2) diff --git a/docs/config.example.toml b/docs/config.example.toml index 71d9093..b8a9060 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -55,6 +55,11 @@ filter = "info" # Default: true # startup_sync = true +# Optional. Main-page navigation scheme. +# Supported: "default" (j/l focus, i/k move), "vim" (h/l focus, j/k move). +# Default: "default" +# keymap = "default" + # Optional. Seconds between `My Inbox` background auto-sync runs while TUI stays open. # Must be greater than 0. Default: 30 # inbox_auto_sync_interval_secs = 30 diff --git a/docs/design.md b/docs/design.md index 0493843..55a9968 100644 --- a/docs/design.md +++ b/docs/design.md @@ -231,8 +231,8 @@ kernel 邮件列表协作场景,目标是把「订阅 -> 阅读 -> 过滤 -> 关键操作: -- `j/l`: 页面焦点移动(在左/中/右面板间切换) -- `i/k`: 当前聚焦页面内上下移动(列表项或正文滚动) +- `ui.keymap = "default"`(默认):`j/l` 页面焦点移动,`i/k` 当前聚焦页面内上下移动 +- `ui.keymap = "vim"`:`h/l` 页面焦点移动,`j/k` 当前聚焦页面内上下移动 - `:`: 打开/关闭命令栏(首选) - `Ctrl + Backtick (\`)`: 打开/关闭命令栏(兼容后备) - `y`: 启用左栏当前订阅 @@ -314,8 +314,8 @@ MVP 范围与阶段目标已迁移至独立文档: - CLI 命令固定为:`tui`、`sync`、`doctor`、`version`。 - 配置读取采用 TOML,支持 `--config` 路径覆盖和默认目录策略。 - 启动阶段统一执行目录引导与 SQLite 初始化迁移,`schema_version` 作为版本入口。 -- b4 检查顺序固定:配置路径 -> `CRIEW_B4_PATH` -> `vendor/b4/b4.sh` -> 运行时展开的 embedded `vendor/b4/b4.sh` -> `PATH` 中 `b4`。 -- TUI 先交付三栏骨架与状态栏,键位实现 `j/l` 焦点切换和 `i/k` 面板内移动。 +- b4 检查顺序固定:配置路径 -> `CRIEW_B4_PATH` -> `vendor/b4/b4.sh` -> `PATH` 中 `b4`。 +- TUI 三栏骨架与状态栏已交付;主页面键位支持 `default`(`j/l` + `i/k`)与 `vim`(`h/l` + `j/k`)两套方案。 ### 14.2 风险与后续动作 diff --git a/src/app/patch.rs b/src/app/patch.rs index 39a3cf1..afcf666 100644 --- a/src/app/patch.rs +++ b/src/app/patch.rs @@ -1167,6 +1167,7 @@ mod tests { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees, diff --git a/src/app/sync.rs b/src/app/sync.rs index 1f1d790..55b8001 100644 --- a/src/app/sync.rs +++ b/src/app/sync.rs @@ -605,6 +605,7 @@ mod tests { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -731,6 +732,7 @@ mod tests { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -793,6 +795,7 @@ mod tests { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -853,6 +856,7 @@ mod tests { }, lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -895,6 +899,7 @@ mod tests { }, lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -937,6 +942,7 @@ mod tests { }, lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), diff --git a/src/infra/config.rs b/src/infra/config.rs index a82d28f..6d3557f 100644 --- a/src/infra/config.rs +++ b/src/infra/config.rs @@ -31,6 +31,7 @@ mailbox = "linux-kernel" [ui] startup_sync = true +# keymap = "default" # Supported: default, vim # inbox_auto_sync_interval_secs = 30 [logging] @@ -56,6 +57,23 @@ impl ImapEncryption { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UiKeymap { + #[default] + Default, + Vim, +} + +impl UiKeymap { + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Vim => "vim", + } + } +} + #[derive(Debug, Clone, Default)] pub struct ImapConfig { pub email: Option, @@ -133,6 +151,7 @@ pub struct RuntimeConfig { pub imap: ImapConfig, pub lore_base_url: String, pub startup_sync: bool, + pub ui_keymap: UiKeymap, pub inbox_auto_sync_interval_secs: u64, pub kernel_trees: Vec, } @@ -197,6 +216,7 @@ struct SourceConfig { #[derive(Debug, Default, Deserialize)] struct UiConfig { startup_sync: Option, + keymap: Option, inbox_auto_sync_interval_secs: Option, } @@ -346,6 +366,7 @@ fn build_runtime_config( let lore_base_url = normalize_optional_string(file_config.source.lore_base_url) .unwrap_or_else(|| DEFAULT_LORE_BASE_URL.to_string()); let startup_sync = file_config.ui.startup_sync.unwrap_or(true); + let ui_keymap = file_config.ui.keymap.unwrap_or_default(); let inbox_auto_sync_interval_secs = file_config .ui .inbox_auto_sync_interval_secs @@ -383,6 +404,7 @@ fn build_runtime_config( imap, lore_base_url, startup_sync, + ui_keymap, inbox_auto_sync_interval_secs, kernel_trees, }) @@ -610,8 +632,8 @@ mod tests { use super::{ DEFAULT_CONFIG_FILE_NAME, DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, IMAP_INBOX_MAILBOX, - ImapFileConfig, LEGACY_CONFIG_FILE_NAME, SelfEmailSource, build_imap_config_with_env, load, - load_with_home, resolve_self_email_with, + ImapFileConfig, LEGACY_CONFIG_FILE_NAME, SelfEmailSource, UiKeymap, + build_imap_config_with_env, load, load_with_home, resolve_self_email_with, }; fn temp_dir(label: &str) -> PathBuf { @@ -651,6 +673,7 @@ lore_base_url = "https://lore.kernel.org" [ui] startup_sync = false +keymap = "vim" inbox_auto_sync_interval_secs = 45 [kernel] @@ -672,6 +695,7 @@ trees = ["./linux-next"] assert_eq!(loaded.source_mailbox, "linux-kernel"); assert_eq!(loaded.lore_base_url, "https://lore.kernel.org"); assert!(!loaded.startup_sync); + assert_eq!(loaded.ui_keymap, UiKeymap::Vim); assert_eq!(loaded.inbox_auto_sync_interval_secs, 45); assert_eq!( loaded.kernel_trees, @@ -697,6 +721,7 @@ trees = ["./linux-next"] assert_eq!(loaded.patch_dir, home.join(".criew/patches")); assert_eq!(loaded.database_path, home.join(".criew/db/criew.db")); assert!(loaded.startup_sync); + assert_eq!(loaded.ui_keymap, UiKeymap::Default); assert_eq!( loaded.inbox_auto_sync_interval_secs, DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS @@ -765,7 +790,6 @@ imapencryption = "tls" loaded.imap.encryption.map(|value| value.as_str()), Some("tls") ); - assert_eq!(loaded.imap.proxy, None); assert!(loaded.imap.is_complete()); assert_eq!(loaded.default_active_mailbox(), IMAP_INBOX_MAILBOX); diff --git a/src/infra/sendmail.rs b/src/infra/sendmail.rs index 42e1716..d920f06 100644 --- a/src/infra/sendmail.rs +++ b/src/infra/sendmail.rs @@ -827,6 +827,7 @@ mod tests { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), diff --git a/src/ui/tui.rs b/src/ui/tui.rs index fb4fc92..ad75cdd 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -21,7 +21,7 @@ use crate::domain::subscriptions::{ DEFAULT_SUBSCRIPTIONS, SubscriptionCategory, category_for_mailbox, }; use crate::infra::bootstrap::BootstrapState; -use crate::infra::config::{IMAP_INBOX_MAILBOX, RuntimeConfig}; +use crate::infra::config::{IMAP_INBOX_MAILBOX, RuntimeConfig, UiKeymap}; use crate::infra::error::{CriewError, ErrorCode, Result}; use crate::infra::mail_store::{self, ThreadRow}; use crate::infra::reply_store::{self, ReplySendRecordRequest, ReplySendStatus}; @@ -186,6 +186,7 @@ const CONFIG_GET_KEYS: &[&str] = &[ "imap.proxy", "source.lore_base_url", "ui.startup_sync", + "ui.keymap", "ui.inbox_auto_sync_interval_secs", "kernel.tree", "kernel.trees", @@ -209,6 +210,7 @@ const CONFIG_SET_KEYS: &[&str] = &[ "imap.proxy", "source.lore_base_url", "ui.startup_sync", + "ui.keymap", "ui.inbox_auto_sync_interval_secs", "kernel.tree", "kernel.trees", @@ -226,6 +228,10 @@ const CONFIG_EDITOR_FIELDS: &[ConfigEditorField] = &[ key: "ui.startup_sync", description: "Whether enabled subscriptions start syncing automatically after TUI launch.", }, + ConfigEditorField { + key: "ui.keymap", + description: "Main-page navigation scheme. default=j/l focus+i/k move, vim=h/l focus+j/k move.", + }, ConfigEditorField { key: "ui.inbox_auto_sync_interval_secs", description: "Seconds between My Inbox background auto-sync runs while TUI stays open.", @@ -298,6 +304,28 @@ const CONFIG_EDITOR_FIELDS: &[ConfigEditorField] = &[ const CODE_EDIT_ENTRY_HINT: &str = "select a source file in Source pane, then press e"; const EXTERNAL_EDITOR_ENTRY_HINT: &str = "select a source file in Source pane, then press E"; +fn main_page_focus_shortcuts(keymap: UiKeymap) -> &'static str { + match keymap { + UiKeymap::Default => "j/l", + UiKeymap::Vim => "h/l", + } +} + +fn main_page_move_shortcuts(keymap: UiKeymap) -> &'static str { + match keymap { + UiKeymap::Default => "i/k", + UiKeymap::Vim => "j/k", + } +} + +fn main_page_navigation_shortcuts(keymap: UiKeymap) -> String { + format!( + "{} focus | {} move", + main_page_focus_shortcuts(keymap), + main_page_move_shortcuts(keymap) + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ExternalEditorProcessResult { success: bool, diff --git a/src/ui/tui/config.rs b/src/ui/tui/config.rs index 012a704..9a5d22e 100644 --- a/src/ui/tui/config.rs +++ b/src/ui/tui/config.rs @@ -610,6 +610,7 @@ fn effective_config_value(state: &AppState, key: &str) -> Option { "imap.proxy" => state.runtime.imap.proxy.clone(), "source.lore_base_url" => Some(state.runtime.lore_base_url.clone()), "ui.startup_sync" => Some(state.runtime.startup_sync.to_string()), + "ui.keymap" => Some(state.runtime.ui_keymap.as_str().to_string()), "ui.inbox_auto_sync_interval_secs" => { Some(state.runtime.inbox_auto_sync_interval_secs.to_string()) } @@ -826,6 +827,16 @@ fn config_value_suggestions(state: &AppState, key: Option<&String>) -> Vec [ + ("default", "j/l focus, i/k move"), + ("vim", "h/l focus, j/k move"), + ] + .iter() + .map(|(value, description)| PaletteSuggestion { + value: (*value).to_string(), + description: Some((*description).to_string()), + }) + .collect(), "ui.inbox_auto_sync_interval_secs" => ["15", "30", "60", "300"] .iter() .map(|value| PaletteSuggestion { diff --git a/src/ui/tui/input.rs b/src/ui/tui/input.rs index cdd14df..135cb32 100644 --- a/src/ui/tui/input.rs +++ b/src/ui/tui/input.rs @@ -19,6 +19,49 @@ pub(super) enum LoopAction { Restart, } +fn handle_main_page_navigation_key(state: &mut AppState, key: KeyEvent) -> bool { + match state.runtime.ui_keymap { + UiKeymap::Default => match key.code { + KeyCode::Char('j') => { + state.move_focus_previous(); + true + } + KeyCode::Char('l') => { + state.move_focus_next(); + true + } + KeyCode::Char('i') => { + state.move_up(); + true + } + KeyCode::Char('k') => { + state.move_down(); + true + } + _ => false, + }, + UiKeymap::Vim => match key.code { + KeyCode::Char('h') => { + state.move_focus_previous(); + true + } + KeyCode::Char('l') => { + state.move_focus_next(); + true + } + KeyCode::Char('k') => { + state.move_up(); + true + } + KeyCode::Char('j') => { + state.move_down(); + true + } + _ => false, + }, + } +} + pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { tracing::debug!( key = ?key, @@ -63,6 +106,10 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio return LoopAction::Continue; } + if handle_main_page_navigation_key(state, key) { + return LoopAction::Continue; + } + match key.code { KeyCode::Char('/') => { if matches!(state.ui_page, UiPage::Mail) { @@ -102,10 +149,6 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio state.open_external_editor(); } KeyCode::Tab => state.toggle_ui_page(), - KeyCode::Char('j') => state.move_focus_previous(), - KeyCode::Char('l') => state.move_focus_next(), - KeyCode::Char('i') => state.move_up(), - KeyCode::Char('k') => state.move_down(), KeyCode::Char(character) if matches!(state.ui_page, UiPage::Mail) && matches!(state.focus, Pane::Threads) @@ -544,7 +587,11 @@ fn handle_palette_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { "quit" | "exit" => return LoopAction::Exit, "restart" => return LoopAction::Restart, "help" => { - state.status = "commands: quit, exit, restart, help, sync [mailbox], config ..., vim, ! | keys: j/l focus, i/k move, y/n enable, a apply, d download, u undo apply, e reply/inline edit, r reply, E external vim".to_string(); + state.status = format!( + "commands: quit, exit, restart, help, sync [mailbox], config ..., vim, ! | keys: {} focus, {} move, y/n enable, a apply, d download, u undo apply, e reply/inline edit, r reply, E external vim", + main_page_focus_shortcuts(state.runtime.ui_keymap), + main_page_move_shortcuts(state.runtime.ui_keymap) + ); } value if value.split_whitespace().next() == Some("sync") => { run_palette_sync(state, value); diff --git a/src/ui/tui/render.rs b/src/ui/tui/render.rs index 2d23126..55be784 100644 --- a/src/ui/tui/render.rs +++ b/src/ui/tui/render.rs @@ -116,24 +116,35 @@ pub(super) fn draw( .as_ref() .is_some_and(|panel| panel.preview_open) { - "j/k scroll preview | Enter/c confirm | Esc close | S send" + "j/k scroll preview | Enter/c confirm | Esc close | S send".to_string() } else if state .reply_panel .as_ref() .is_some_and(|panel| panel.reply_notice.is_some()) { - "Enter/Esc close notice | P preview | S send" + "Enter/Esc close notice | P preview | S send".to_string() } else { - "Esc normal/close | Enter/o open below+insert | h/j/k/l move | i insert | x delete | p send preview | S send | :preview :send :q :q!" + "Esc normal/close | Enter/o open below+insert | h/j/k/l move | i insert | x delete | p send preview | S send | :preview :send :q :q!".to_string() } } - UiPage::Mail => "/ search | Tab page | : palette | Enter | e/r reply", + UiPage::Mail if state.palette.open => { + "/ search | Tab page | : palette | Enter | e/r reply".to_string() + } + UiPage::Mail => format!( + "/ search | Tab page | : palette | Enter | e/r reply | {}", + main_page_navigation_shortcuts(state.runtime.ui_keymap) + ), UiPage::CodeBrowser if state.is_code_edit_active() => { - "Esc normal/exit | h/j/k/l move | i insert | x delete | s save | E external vim | :w :q :q! :wq :vim" + "Esc normal/exit | h/j/k/l move | i insert | x delete | s save | E external vim | :w :q :q! :wq :vim".to_string() } - UiPage::CodeBrowser => { + UiPage::CodeBrowser if state.palette.open => { "Tab page | : palette | Enter expand/collapse | e inline edit | E external vim" + .to_string() } + UiPage::CodeBrowser => format!( + "Tab page | : palette | Enter expand/collapse | e inline edit | E external vim | {}", + main_page_navigation_shortcuts(state.runtime.ui_keymap) + ), }; let footer_background = Paragraph::new("").style(Style::default().fg(Color::White).bg(Color::DarkGray)); diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 2451d2c..54c9acc 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -16,7 +16,7 @@ use ratatui::layout::{Constraint, Direction, Layout, Position, Rect}; use crate::domain::subscriptions::SubscriptionCategory; use crate::infra::bootstrap::BootstrapState; -use crate::infra::config::{IMAP_INBOX_MAILBOX, RuntimeConfig}; +use crate::infra::config::{IMAP_INBOX_MAILBOX, RuntimeConfig, UiKeymap}; use crate::infra::db; use crate::infra::db::DatabaseState; use crate::infra::mail_parser; @@ -115,6 +115,7 @@ fn test_runtime_in(root: PathBuf) -> RuntimeConfig { imap: crate::infra::config::ImapConfig::default(), lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, + ui_keymap: UiKeymap::Default, inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), } @@ -685,6 +686,23 @@ fn command_palette_help_includes_keyboard_shortcuts() { assert!(state.status.contains("u undo apply")); } +#[test] +fn command_palette_help_uses_vim_keymap_labels() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new(vec![], runtime); + state.palette.open = true; + state.palette.input = "help".to_string(); + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(action, LoopAction::Continue)); + assert!(state.status.contains("h/l focus")); + assert!(state.status.contains("j/k move")); +} + #[test] fn config_palette_get_and_set_roundtrip() { let root = temp_dir("palette-config"); @@ -734,6 +752,65 @@ mailbox = "inbox" let _ = fs::remove_dir_all(root); } +#[test] +fn config_palette_set_keymap_updates_navigation_immediately() { + let root = temp_dir("palette-keymap"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[ui] +keymap = "default" +"#, + ) + .expect("write config file"); + + let mut runtime = test_runtime(); + runtime.config_path = config_path.clone(); + let stale_runtime = runtime.clone(); + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); + + state.palette.open = true; + state.palette.input = "config set ui.keymap vim".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert_eq!(state.runtime.ui_keymap, UiKeymap::Vim); + let persisted = fs::read_to_string(&config_path).expect("read config file"); + assert!(persisted.contains("keymap = \"vim\"")); + state.palette.open = false; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); + assert_eq!(state.thread_index, 1); + + let bootstrap = test_bootstrap(&stale_runtime); + let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &stale_runtime, &bootstrap)) + .expect("draw updated keymap footer"); + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("h/l focus | j/k move")); + assert!(!rendered.contains("j/l focus | i/k move")); + + let _ = fs::remove_dir_all(root); +} + #[test] fn config_command_opens_visual_editor() { let mut state = AppState::new(vec![], test_runtime()); @@ -2512,6 +2589,48 @@ fn jl_focus_and_ik_move_selection() { assert!(matches!(state.focus, Pane::Subscriptions)); } +#[test] +fn vim_keymap_uses_hl_focus_and_jk_move_selection() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); + state.subscription_index = 1; + + let action_l = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(action_l, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Threads)); + + let action_j = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(action_j, LoopAction::Continue)); + assert_eq!(state.thread_index, 1); + + let action_k = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert!(matches!(action_k, LoopAction::Continue)); + assert_eq!(state.thread_index, 0); + + let action_h = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ); + assert!(matches!(action_h, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Subscriptions)); +} + #[test] fn a_d_and_u_require_patch_series_or_apply_snapshot_on_thread_focus() { let mut state = AppState::new( From 5147a5b9ab887bf704d1192d98d85236ec12bd72 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Sat, 7 Mar 2026 23:36:33 +0800 Subject: [PATCH 05/29] fix(sendmail): escape fake git fixture newline Signed-off-by: Chao Liu --- src/infra/sendmail.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/infra/sendmail.rs b/src/infra/sendmail.rs index d920f06..ce2229e 100644 --- a/src/infra/sendmail.rs +++ b/src/infra/sendmail.rs @@ -395,8 +395,9 @@ fn git_candidates(command_path: Option<&Path>) -> Vec { let mut candidates = Vec::new(); if let Some(path) = command_path { candidates.push(GitCandidate::Path(path.to_path_buf())); + } else { + candidates.push(GitCandidate::Program("git")); } - candidates.push(GitCandidate::Program("git")); candidates } @@ -920,7 +921,7 @@ mod tests { let fake_git = write_fake_git( &root, &format!( - "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n printf '%s\n' \"$@\" > '{}'\n last=''\n for arg in \"$@\"; do\n last=\"$arg\"\n done\n cp \"$last\" '{}'\n echo 'sent'\n exit 0\nfi\nexit 1\n", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n printf '%s\\n' \"$@\" > '{}'\n last=''\n for arg in \"$@\"; do\n last=\"$arg\"\n done\n cp \"$last\" '{}'\n echo 'sent'\n exit 0\nfi\nexit 1\n", capture_args.display(), capture.display() ), From 1cbb570351fe9cb41483dc078a041c81bae31a37 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Mon, 9 Mar 2026 09:03:53 +0800 Subject: [PATCH 06/29] fix(doctor): include ui_keymap in doctor output - print the active ui_keymap value in criew doctor so users can verify their config file keymap setting is loaded correctly Signed-off-by: Chao Liu --- src/app/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index a93db96..c9ce6e4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -83,6 +83,7 @@ pub fn run() -> Result<()> { ); println!(" lore_base_url: {}", runtime.lore_base_url); println!(" startup_sync: {}", runtime.startup_sync); + println!(" ui_keymap: {}", runtime.ui_keymap.as_str()); println!( " inbox_auto_sync_interval_secs: {}", runtime.inbox_auto_sync_interval_secs From a50516de95493e6da7ac4a84789091bddd3dc1a0 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Mon, 9 Mar 2026 09:04:01 +0800 Subject: [PATCH 07/29] test(tui): add keymap config loading and navigation coverage - add config_get_ui_keymap_returns_current_value to verify palette reports the active keymap value correctly - add loaded_vim_keymap_drives_navigation_keys to verify vim-mode j/k navigation and confirm default-mode i key does not trigger movement Signed-off-by: Chao Liu --- src/ui/tui/tests.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 54c9acc..7bed172 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -811,6 +811,86 @@ keymap = "default" let _ = fs::remove_dir_all(root); } +#[test] +fn config_get_ui_keymap_returns_current_value() { + let root = temp_dir("get-keymap"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[ui] +keymap = "vim" +"#, + ) + .expect("write config file"); + + let mut runtime = test_runtime(); + runtime.config_path = config_path.clone(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new(vec![], runtime); + + state.palette.open = true; + state.palette.input = "config get ui.keymap".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!( + state.status.contains("vim"), + "config get should report vim, got: {}", + state.status + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn loaded_vim_keymap_drives_navigation_keys() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); + state.subscription_index = 1; + + // Default keymap key 'j' would move focus left; in vim mode it moves down. + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert_eq!(state.thread_index, 1, "j should move down in vim keymap"); + + // 'i' should NOT navigate (it is not a vim navigation key). + let prev_index = state.thread_index; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + assert_eq!( + state.thread_index, prev_index, + "i should not navigate in vim keymap" + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ); + assert!( + matches!(state.focus, Pane::Subscriptions), + "h should move focus left in vim keymap" + ); +} + #[test] fn config_command_opens_visual_editor() { let mut state = AppState::new(vec![], test_runtime()); From 6adc4b9969b784e7e7e6a0425f570def9bfe607f Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Mon, 9 Mar 2026 02:15:33 +0800 Subject: [PATCH 08/29] feat: expand coverage for patch and mailbox workflows - add IMAP doctor probing and archive sync behavior covered by the new tests - extend patch, sendmail, and TUI regression coverage across mailbox workflows - harden temporary command execution against transient executable-busy failures Signed-off-by: Chen Miao --- .codex/skills/criew-development/SKILL.md | 7 + src/app/mod.rs | 634 +++++++-- src/app/patch.rs | 487 ++++++- src/infra/b4.rs | 329 ++++- src/infra/b4_vendor.rs | 47 + src/infra/bootstrap.rs | 95 ++ src/infra/db.rs | 119 ++ src/infra/imap.rs | 1568 ++++++++++++++++++++-- src/infra/mail_store.rs | 614 ++++++++- src/infra/sendmail.rs | 325 ++++- src/ui/tui/reply.rs | 148 +- src/ui/tui/tests.rs | 868 ++++++++++++ 12 files changed, 4919 insertions(+), 322 deletions(-) diff --git a/.codex/skills/criew-development/SKILL.md b/.codex/skills/criew-development/SKILL.md index 25f10be..d48d680 100644 --- a/.codex/skills/criew-development/SKILL.md +++ b/.codex/skills/criew-development/SKILL.md @@ -78,6 +78,13 @@ For larger commits, add a body with bullet points that summarize the main change Run the full repository validation set after non-trivial changes when the environment allows it. +- Keep overall repository coverage at or above 70%. +- Keep newly added code at or above 80% coverage. +- Keep critical-component coverage at or above 85% for critical workflow code that the current change directly touches or materially expands. Treat sync, reply, patch, and other core user-facing workflow paths in scope as critical unless the task clearly falls outside those paths. +- Do not add tests only to tick uncovered lines. Coverage work must defend a real behavior, regression, failure mode, or workflow contract that matters to users or operators. +- Prefer behavior-driven regression tests over line-chasing. If a remaining gap is not worth a brittle or artificial test, leave it uncovered and report the tradeoff clearly. +- Use `./scripts/check-coverage.sh` plus the generated summary report to verify the thresholds above, and review file-level or workflow-level reports for the in-scope critical components. Treat threshold regressions as incomplete work. + - `cargo fmt --all -- --check` - `cargo clippy --all-targets --all-features -- -D warnings` - `cargo test --all-targets --all-features` diff --git a/src/app/mod.rs b/src/app/mod.rs index c9ce6e4..dc0bc5e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -7,18 +7,26 @@ pub mod cli; pub mod patch; pub mod sync; +use std::path::PathBuf; + use clap::Parser; use crate::infra::b4::{self, B4Status}; use crate::infra::bootstrap; use crate::infra::config::{self, IMAP_INBOX_MAILBOX}; use crate::infra::error::Result; -use crate::infra::imap::{ImapClient, RemoteImapClient}; +use crate::infra::imap::{ImapClient, MailboxSnapshot, RemoteImapClient}; use crate::infra::logging; -use crate::infra::sendmail::{self, GitSendEmailStatus}; +use crate::infra::sendmail::{self, GitSendEmailCheck, GitSendEmailStatus, ReplyIdentity}; const DEFAULT_SYNC_RECONNECT_ATTEMPTS: u8 = 3; +enum DoctorImapStatus { + Skipped(Vec), + Connected(MailboxSnapshot), + Error(String), +} + pub fn run() -> Result<()> { let cli = cli::Cli::parse(); let command = cli.command.unwrap_or(cli::Command::Tui); @@ -55,12 +63,13 @@ pub fn run() -> Result<()> { uidvalidity, reconnect_attempts, } => { - let request = sync::SyncRequest { - mailbox: mailbox.unwrap_or_else(|| runtime.source_mailbox.clone()), + let request = build_sync_request( + &runtime, + mailbox, fixture_dir, uidvalidity, - reconnect_attempts: reconnect_attempts.unwrap_or(DEFAULT_SYNC_RECONNECT_ATTEMPTS), - }; + reconnect_attempts, + ); run_sync_command(&runtime, &bootstrap_state, request, true)?; Ok(()) } @@ -109,120 +118,19 @@ pub fn run() -> Result<()> { bootstrap_state.db.created ); let self_email = config::resolve_self_email(&runtime); + let imap_status = probe_doctor_imap(&runtime); println!( - " self_email: {}", - self_email.email.as_deref().unwrap_or("") - ); - println!( - " self_email_source: {}", - self_email - .source - .map(|source| source.as_str()) - .unwrap_or("") - ); - if let Some(error) = self_email.git_error.as_deref() { - println!(" self_email_lookup: error ({error})"); - } - println!( - " imap_config_status: {}", - if runtime.imap.is_complete() { - "complete" - } else { - "incomplete" - } + "{}", + format_doctor_report( + &runtime, + &bootstrap_state, + &self_email, + &imap_status, + &send_email_status, + &reply_identity, + &b4_status, + ) ); - if runtime.imap.is_complete() { - println!( - " imap_connection_target: {}:{} ({})", - runtime.imap.server.as_deref().unwrap_or(""), - runtime.imap.server_port.unwrap_or_default(), - runtime - .imap - .encryption - .map(|value| value.as_str()) - .unwrap_or("") - ); - - match RemoteImapClient::new(runtime.imap.clone()).and_then(|mut client| { - client.connect()?; - client.select_mailbox(IMAP_INBOX_MAILBOX) - }) { - Ok(snapshot) => { - println!(" imap_connect_status: ok"); - println!(" imap_select_mailbox: {}", IMAP_INBOX_MAILBOX); - println!(" imap_uidvalidity: {}", snapshot.uidvalidity); - println!(" imap_highest_uid: {}", snapshot.highest_uid); - println!( - " imap_highest_modseq: {}", - snapshot - .highest_modseq - .map(|value| value.to_string()) - .unwrap_or_else(|| "".to_string()) - ); - } - Err(error) => { - println!(" imap_connect_status: error ({error})"); - } - } - } else { - let missing = runtime.imap.missing_required_fields(); - println!( - " imap_missing_fields: {}", - if missing.is_empty() { - "".to_string() - } else { - missing.join(", ") - } - ); - println!(" imap_connect_status: skipped"); - } - - match send_email_status.status { - GitSendEmailStatus::Available { path, version } => { - println!(" git_send_email_path: {}", path.display()); - println!(" git_send_email_version: {}", version); - println!(" git_send_email_status: ok"); - } - GitSendEmailStatus::Broken { path, reason } => { - println!(" git_send_email_path: {}", path.display()); - println!(" git_send_email_status: broken ({reason})"); - } - GitSendEmailStatus::Missing => { - println!(" git_send_email_path: "); - println!(" git_send_email_status: missing"); - } - } - - match reply_identity { - Ok(identity) => { - println!(" reply_from: {}", identity.display); - println!(" reply_from_email: {}", identity.email); - println!(" reply_from_source: {}", identity.source.as_str()); - println!(" reply_identity_status: ok"); - } - Err(error) => { - println!(" reply_from: "); - println!(" reply_from_email: "); - println!(" reply_from_source: "); - println!(" reply_identity_status: error ({error})"); - } - } - - match b4_status.status { - B4Status::Available { path, version } => { - println!(" b4_path: {}", path.display()); - println!(" b4_version: {}", version); - println!(" b4_status: ok"); - } - B4Status::Broken { path, reason } => { - println!(" b4_path: {}", path.display()); - println!(" b4_status: broken ({reason})"); - } - B4Status::Missing => { - println!(" b4_path: "); - println!(" b4_status: missing"); - } - } Ok(()) } @@ -250,24 +158,480 @@ fn run_sync_command( ); if print_summary { - println!( - "sync complete: mailbox={} source={} fetched={} inserted={} updated={} rebuilt_roots={} uidvalidity={} last_seen_uid={} highest_modseq={:?} synced_at={} mailbox_rebuilt={}", - summary.mailbox, - summary.source, - summary.fetched, - summary.inserted, - summary.updated, - summary.rebuilt_roots, - summary.uidvalidity, - summary.checkpoint_last_seen_uid, - summary.checkpoint_highest_modseq, - summary - .checkpoint_synced_at - .as_deref() - .unwrap_or(""), - summary.mailbox_rebuilt - ); + println!("{}", format_sync_summary(&summary)); } Ok(summary) } + +fn build_sync_request( + runtime: &config::RuntimeConfig, + mailbox: Option, + fixture_dir: Option, + uidvalidity: Option, + reconnect_attempts: Option, +) -> sync::SyncRequest { + sync::SyncRequest { + mailbox: mailbox.unwrap_or_else(|| runtime.source_mailbox.clone()), + fixture_dir, + uidvalidity, + reconnect_attempts: reconnect_attempts.unwrap_or(DEFAULT_SYNC_RECONNECT_ATTEMPTS), + } +} + +fn probe_doctor_imap(runtime: &config::RuntimeConfig) -> DoctorImapStatus { + if !runtime.imap.is_complete() { + return DoctorImapStatus::Skipped( + runtime + .imap + .missing_required_fields() + .into_iter() + .map(ToOwned::to_owned) + .collect(), + ); + } + + match RemoteImapClient::new(runtime.imap.clone()).and_then(|mut client| { + client.connect()?; + client.select_mailbox(IMAP_INBOX_MAILBOX) + }) { + Ok(snapshot) => DoctorImapStatus::Connected(snapshot), + Err(error) => DoctorImapStatus::Error(error.to_string()), + } +} + +fn format_doctor_report( + runtime: &config::RuntimeConfig, + bootstrap_state: &bootstrap::BootstrapState, + self_email: &config::SelfEmailResolution, + imap_status: &DoctorImapStatus, + send_email_status: &GitSendEmailCheck, + reply_identity: &std::result::Result, + b4_status: &b4::B4Check, +) -> String { + let mut lines = vec![ + "criew doctor".to_string(), + format!(" config_path: {}", runtime.config_path.display()), + format!(" data_dir: {}", runtime.data_dir.display()), + format!(" database_path: {}", runtime.database_path.display()), + format!(" raw_mail_dir: {}", runtime.raw_mail_dir.display()), + format!(" patch_dir: {}", runtime.patch_dir.display()), + format!(" log_dir: {}", runtime.log_dir.display()), + format!(" source_mailbox: {}", runtime.source_mailbox), + format!( + " default_active_mailbox: {}", + runtime.default_active_mailbox() + ), + format!(" lore_base_url: {}", runtime.lore_base_url), + format!(" startup_sync: {}", runtime.startup_sync), + format!( + " inbox_auto_sync_interval_secs: {}", + runtime.inbox_auto_sync_interval_secs + ), + ]; + if runtime.kernel_trees.is_empty() { + lines.push(" kernel_trees: ".to_string()); + } else { + lines.extend( + runtime + .kernel_trees + .iter() + .map(|tree| format!(" kernel_tree: {}", tree.display())), + ); + } + lines.push(format!( + " schema_version: {}", + bootstrap_state.db.schema_version + )); + lines.push(format!( + " schema_version_expected: {}", + crate::infra::db::CURRENT_SCHEMA_VERSION + )); + lines.push(format!( + " migrations_applied: {:?}", + bootstrap_state.db.applied_migrations + )); + lines.push(format!( + " database_created_this_run: {}", + bootstrap_state.db.created + )); + lines.push(format!( + " self_email: {}", + self_email.email.as_deref().unwrap_or("") + )); + lines.push(format!( + " self_email_source: {}", + self_email + .source + .map(|source| source.as_str()) + .unwrap_or("") + )); + if let Some(error) = self_email.git_error.as_deref() { + lines.push(format!(" self_email_lookup: error ({error})")); + } + lines.push(format!( + " imap_config_status: {}", + if runtime.imap.is_complete() { + "complete" + } else { + "incomplete" + } + )); + match imap_status { + DoctorImapStatus::Skipped(missing_fields) => { + lines.push(format!( + " imap_missing_fields: {}", + if missing_fields.is_empty() { + "".to_string() + } else { + missing_fields.join(", ") + } + )); + lines.push(" imap_connect_status: skipped".to_string()); + } + DoctorImapStatus::Connected(snapshot) => { + lines.push(format!( + " imap_connection_target: {}:{} ({})", + runtime.imap.server.as_deref().unwrap_or(""), + runtime.imap.server_port.unwrap_or_default(), + runtime + .imap + .encryption + .map(|value| value.as_str()) + .unwrap_or("") + )); + lines.push(" imap_connect_status: ok".to_string()); + lines.push(format!(" imap_select_mailbox: {}", IMAP_INBOX_MAILBOX)); + lines.push(format!(" imap_uidvalidity: {}", snapshot.uidvalidity)); + lines.push(format!(" imap_highest_uid: {}", snapshot.highest_uid)); + lines.push(format!( + " imap_highest_modseq: {}", + snapshot + .highest_modseq + .map(|value| value.to_string()) + .unwrap_or_else(|| "".to_string()) + )); + } + DoctorImapStatus::Error(error) => { + lines.push(format!( + " imap_connection_target: {}:{} ({})", + runtime.imap.server.as_deref().unwrap_or(""), + runtime.imap.server_port.unwrap_or_default(), + runtime + .imap + .encryption + .map(|value| value.as_str()) + .unwrap_or("") + )); + lines.push(format!(" imap_connect_status: error ({error})")); + } + } + match &send_email_status.status { + GitSendEmailStatus::Available { path, version } => { + lines.push(format!(" git_send_email_path: {}", path.display())); + lines.push(format!(" git_send_email_version: {}", version)); + lines.push(" git_send_email_status: ok".to_string()); + } + GitSendEmailStatus::Broken { path, reason } => { + lines.push(format!(" git_send_email_path: {}", path.display())); + lines.push(format!(" git_send_email_status: broken ({reason})")); + } + GitSendEmailStatus::Missing => { + lines.push(" git_send_email_path: ".to_string()); + lines.push(" git_send_email_status: missing".to_string()); + } + } + match reply_identity { + Ok(identity) => { + lines.push(format!(" reply_from: {}", identity.display)); + lines.push(format!(" reply_from_email: {}", identity.email)); + lines.push(format!(" reply_from_source: {}", identity.source.as_str())); + lines.push(" reply_identity_status: ok".to_string()); + } + Err(error) => { + lines.push(" reply_from: ".to_string()); + lines.push(" reply_from_email: ".to_string()); + lines.push(" reply_from_source: ".to_string()); + lines.push(format!(" reply_identity_status: error ({error})")); + } + } + match &b4_status.status { + B4Status::Available { path, version } => { + lines.push(format!(" b4_path: {}", path.display())); + lines.push(format!(" b4_version: {}", version)); + lines.push(" b4_status: ok".to_string()); + } + B4Status::Broken { path, reason } => { + lines.push(format!(" b4_path: {}", path.display())); + lines.push(format!(" b4_status: broken ({reason})")); + } + B4Status::Missing => { + lines.push(" b4_path: ".to_string()); + lines.push(" b4_status: missing".to_string()); + } + } + lines.join("\n") +} + +fn format_sync_summary(summary: &sync::SyncSummary) -> String { + format!( + "sync complete: mailbox={} source={} fetched={} inserted={} updated={} rebuilt_roots={} uidvalidity={} last_seen_uid={} highest_modseq={:?} synced_at={} mailbox_rebuilt={}", + summary.mailbox, + summary.source, + summary.fetched, + summary.inserted, + summary.updated, + summary.rebuilt_roots, + summary.uidvalidity, + summary.checkpoint_last_seen_uid, + summary.checkpoint_highest_modseq, + summary + .checkpoint_synced_at + .as_deref() + .unwrap_or(""), + summary.mailbox_rebuilt + ) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::infra::b4::B4Status; + use crate::infra::bootstrap::BootstrapState; + use crate::infra::config::{ + DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, ImapConfig, ImapEncryption, RuntimeConfig, + SelfEmailResolution, SelfEmailSource, UiKeymap, + }; + use crate::infra::db::DatabaseState; + use crate::infra::imap::MailboxSnapshot; + use crate::infra::sendmail::{ + GitSendEmailCheck, GitSendEmailStatus, ReplyIdentity, ReplyIdentitySource, + }; + + use super::{ + DoctorImapStatus, build_sync_request, format_doctor_report, format_sync_summary, + probe_doctor_imap, + }; + + fn test_runtime() -> RuntimeConfig { + RuntimeConfig { + config_path: PathBuf::from("/tmp/criew-config.toml"), + data_dir: PathBuf::from("/tmp/criew"), + database_path: PathBuf::from("/tmp/criew/criew.db"), + raw_mail_dir: PathBuf::from("/tmp/criew/raw"), + patch_dir: PathBuf::from("/tmp/criew/patches"), + log_dir: PathBuf::from("/tmp/criew/logs"), + b4_path: None, + log_filter: "info".to_string(), + source_mailbox: "linux-kernel".to_string(), + imap: ImapConfig::default(), + lore_base_url: "https://lore.kernel.org".to_string(), + startup_sync: true, + ui_keymap: UiKeymap::Default, + inbox_auto_sync_interval_secs: DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, + kernel_trees: Vec::new(), + } + } + + fn test_bootstrap_state() -> BootstrapState { + BootstrapState { + db: DatabaseState { + path: PathBuf::from("/tmp/criew/criew.db"), + schema_version: crate::infra::db::CURRENT_SCHEMA_VERSION, + created: false, + applied_migrations: vec![1, 2, 3, crate::infra::db::CURRENT_SCHEMA_VERSION], + }, + } + } + + #[test] + fn build_sync_request_uses_runtime_defaults_and_overrides() { + let runtime = test_runtime(); + + let default_request = build_sync_request(&runtime, None, None, None, None); + assert_eq!(default_request.mailbox, "linux-kernel"); + assert_eq!( + default_request.reconnect_attempts, + super::DEFAULT_SYNC_RECONNECT_ATTEMPTS + ); + + let overridden_request = build_sync_request( + &runtime, + Some("INBOX".to_string()), + Some(PathBuf::from("/tmp/fixtures")), + Some(42), + Some(7), + ); + assert_eq!(overridden_request.mailbox, "INBOX"); + assert_eq!( + overridden_request.fixture_dir, + Some(PathBuf::from("/tmp/fixtures")) + ); + assert_eq!(overridden_request.uidvalidity, Some(42)); + assert_eq!(overridden_request.reconnect_attempts, 7); + } + + #[test] + fn format_sync_summary_renders_unknown_synced_at_fallback() { + let summary = crate::app::sync::SyncSummary { + mailbox: "linux-kernel".to_string(), + source: "fixture".to_string(), + fetched: 3, + inserted: 2, + updated: 1, + rebuilt_roots: 4, + mailbox_rebuilt: false, + uidvalidity: 11, + checkpoint_last_seen_uid: 99, + checkpoint_highest_modseq: Some(123), + checkpoint_synced_at: None, + }; + + let rendered = format_sync_summary(&summary); + + assert!(rendered.contains("mailbox=linux-kernel")); + assert!(rendered.contains("source=fixture")); + assert!(rendered.contains("synced_at=")); + } + + #[test] + fn format_doctor_report_covers_incomplete_imap_and_missing_tools() { + let runtime = test_runtime(); + let self_email = SelfEmailResolution { + email: None, + source: None, + git_error: Some("git config failed".to_string()), + }; + let report = format_doctor_report( + &runtime, + &test_bootstrap_state(), + &self_email, + &DoctorImapStatus::Skipped(vec!["imap.user".to_string(), "imap.pass".to_string()]), + &GitSendEmailCheck { + status: GitSendEmailStatus::Missing, + }, + &Err("missing reply identity".to_string()), + &crate::infra::b4::B4Check { + status: B4Status::Missing, + }, + ); + + assert!(report.contains("criew doctor")); + assert!(report.contains("kernel_trees: ")); + assert!(report.contains("self_email_lookup: error (git config failed)")); + assert!(report.contains("imap_config_status: incomplete")); + assert!(report.contains("imap_missing_fields: imap.user, imap.pass")); + assert!(report.contains("imap_connect_status: skipped")); + assert!(report.contains("git_send_email_status: missing")); + assert!(report.contains("reply_identity_status: error (missing reply identity)")); + assert!(report.contains("b4_status: missing")); + } + + #[test] + fn format_doctor_report_covers_connected_imap_and_available_tools() { + let mut runtime = test_runtime(); + runtime.kernel_trees = vec![PathBuf::from("/tmp/linux")]; + runtime.imap = ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }; + let self_email = SelfEmailResolution { + email: Some("me@example.com".to_string()), + source: Some(SelfEmailSource::CriewImapConfig), + git_error: None, + }; + let report = format_doctor_report( + &runtime, + &test_bootstrap_state(), + &self_email, + &DoctorImapStatus::Connected(MailboxSnapshot { + uidvalidity: 7, + highest_uid: 88, + highest_modseq: Some(99), + }), + &GitSendEmailCheck { + status: GitSendEmailStatus::Available { + path: PathBuf::from("/usr/bin/git"), + version: "2.45".to_string(), + }, + }, + &Ok(ReplyIdentity { + display: "CRIEW ".to_string(), + email: "me@example.com".to_string(), + source: ReplyIdentitySource::SendEmailFrom, + }), + &crate::infra::b4::B4Check { + status: B4Status::Available { + path: PathBuf::from("/usr/bin/b4"), + version: "0.14".to_string(), + }, + }, + ); + + assert!(report.contains("kernel_tree: /tmp/linux")); + assert!(report.contains("imap_config_status: complete")); + assert!(report.contains("imap_connection_target: imap.example.com:993 (tls)")); + assert!(report.contains("imap_connect_status: ok")); + assert!(report.contains("imap_uidvalidity: 7")); + assert!(report.contains("git_send_email_status: ok")); + assert!(report.contains("reply_identity_status: ok")); + assert!(report.contains("b4_status: ok")); + } + + #[test] + fn probe_doctor_imap_skips_incomplete_config_without_network_access() { + let status = probe_doctor_imap(&test_runtime()); + + match status { + DoctorImapStatus::Skipped(missing_fields) => { + assert!(missing_fields.contains(&"imap.user".to_string())); + assert!(missing_fields.contains(&"imap.pass".to_string())); + } + _ => panic!("expected incomplete IMAP config to skip probing"), + } + } + + #[test] + fn format_doctor_report_covers_imap_error_and_broken_tools() { + let mut runtime = test_runtime(); + runtime.imap = ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }; + + let report = format_doctor_report( + &runtime, + &test_bootstrap_state(), + &SelfEmailResolution::default(), + &DoctorImapStatus::Error("connect failed".to_string()), + &GitSendEmailCheck { + status: GitSendEmailStatus::Broken { + path: PathBuf::from("/usr/bin/git"), + reason: "missing send-email".to_string(), + }, + }, + &Err("missing reply identity".to_string()), + &crate::infra::b4::B4Check { + status: B4Status::Broken { + path: PathBuf::from("/usr/bin/b4"), + reason: "bad runtime".to_string(), + }, + }, + ); + + assert!(report.contains("imap_connect_status: error (connect failed)")); + assert!(report.contains("git_send_email_status: broken (missing send-email)")); + assert!(report.contains("b4_status: broken (bad runtime)")); + } +} diff --git a/src/app/patch.rs b/src/app/patch.rs index afcf666..6c84c64 100644 --- a/src/app/patch.rs +++ b/src/app/patch.rs @@ -1127,19 +1127,29 @@ fn format_seq(values: &[u32]) -> String { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::fs; - use std::path::PathBuf; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; use std::process::Command as ProcessCommand; use std::time::{SystemTime, UNIX_EPOCH}; + use rusqlite::{Connection, params}; + + use crate::domain::models::PatchSeriesStatus; use crate::infra::config::RuntimeConfig; + use crate::infra::db; + use crate::infra::error::ErrorCode; use crate::infra::mail_store::ThreadRow; + use crate::infra::patch_store; use super::{ APPLY_ARTIFACTS_DIR, PatchAction, SeriesIntegrity, action_args, action_subcommand, - action_working_dir, build_series_index, download_series_name, parse_patch_subject, - parse_seq_total_token, parse_version_token, relocate_new_apply_artifacts, - snapshot_apply_artifacts, subject_is_patch_related, undo_last_apply, + action_working_dir, build_series_index, download_series_name, hydrate_series_statuses, + load_latest_report, parse_patch_subject, parse_seq_total_token, parse_version_token, + relocate_new_apply_artifacts, run_action, snapshot_apply_artifacts, + subject_is_patch_related, undo_last_apply, }; fn temp_dir(label: &str) -> PathBuf { @@ -1147,7 +1157,10 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("system time") .as_nanos(); - let path = std::env::temp_dir().join(format!("criew-patch-{label}-{nonce}")); + let path = std::env::temp_dir().join(format!( + "criew-patch-{label}-{}-{nonce}", + std::process::id() + )); fs::create_dir_all(&path).expect("create temp dir"); path } @@ -1174,6 +1187,27 @@ mod tests { } } + fn runtime_in(root: &Path, kernel_trees: Vec) -> RuntimeConfig { + RuntimeConfig { + config_path: root.join("config.toml"), + data_dir: root.join("data"), + database_path: root.join("data").join("criew.db"), + raw_mail_dir: root.join("data").join("raw"), + patch_dir: root.join("data").join("patches"), + log_dir: root.join("data").join("logs"), + b4_path: None, + log_filter: "info".to_string(), + source_mailbox: "io-uring".to_string(), + imap: crate::infra::config::ImapConfig::default(), + lore_base_url: "https://lore.kernel.org".to_string(), + startup_sync: true, + ui_keymap: crate::infra::config::UiKeymap::Default, + inbox_auto_sync_interval_secs: + crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, + kernel_trees, + } + } + fn run_git(repo: &PathBuf, args: &[&str]) { let output = ProcessCommand::new("git") .arg("-C") @@ -1205,6 +1239,41 @@ mod tests { String::from_utf8_lossy(&output.stdout).trim().to_string() } + fn write_script(root: &Path, name: &str, body: &str) -> PathBuf { + let path = root.join(name); + fs::write(&path, body).expect("write script"); + #[cfg(unix)] + { + let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).expect("mark executable"); + } + path + } + + fn seed_mail_rows(path: &Path, rows: &[(i64, &str)]) { + let connection = Connection::open(path).expect("open db"); + for (id, message_id) in rows { + connection + .execute( + " +INSERT INTO mail(id, message_id, subject, from_addr, imap_mailbox, imap_uid) +VALUES (?1, ?2, ?3, ?4, 'io-uring', ?1) +", + params![id, message_id, format!("subject-{id}"), "alice@example.com"], + ) + .expect("insert mail row"); + } + } + + fn initialize_patch_runtime(runtime: &RuntimeConfig, mail_rows: &[(i64, &str)]) { + if let Some(parent) = runtime.database_path.parent() { + fs::create_dir_all(parent).expect("create db parent"); + } + let _ = db::initialize(&runtime.database_path).expect("initialize db"); + seed_mail_rows(&runtime.database_path, mail_rows); + } + fn thread_row( thread_id: i64, mail_id: i64, @@ -1251,6 +1320,162 @@ mod tests { } } + #[test] + fn integrity_helpers_match_user_visible_patch_status_contract() { + let mut summary = sample_summary("[PATCH 1/1] io_uring: demo", 1); + + assert_eq!(summary.present_count(), 1); + assert_eq!(summary.status_label(), "new"); + assert_eq!(SeriesIntegrity::Complete.as_str(), "complete"); + assert_eq!(SeriesIntegrity::Complete.short_label(), "ready"); + assert!(SeriesIntegrity::Complete.is_ready()); + assert_eq!(summary.integrity_reason(), None); + + summary.integrity = SeriesIntegrity::Missing; + summary.missing_seq = vec![2, 4]; + assert_eq!(summary.integrity.as_str(), "missing"); + assert_eq!(summary.integrity.short_label(), "missing"); + assert!(!summary.integrity.is_ready()); + assert_eq!( + summary.integrity_reason().as_deref(), + Some("missing patch index: 2,4") + ); + + summary.integrity = SeriesIntegrity::Duplicate; + summary.duplicate_seq = vec![1]; + assert_eq!(summary.integrity.as_str(), "duplicate"); + assert_eq!(summary.integrity.short_label(), "duplicate"); + assert_eq!( + summary.integrity_reason().as_deref(), + Some("duplicate patch index: 1") + ); + + summary.integrity = SeriesIntegrity::OutOfOrder; + assert_eq!(summary.integrity.as_str(), "out-of-order"); + assert_eq!(summary.integrity.short_label(), "out-of-order"); + assert_eq!( + summary.integrity_reason().as_deref(), + Some("patch order is out-of-order in thread") + ); + + summary.integrity = SeriesIntegrity::Invalid; + assert_eq!(summary.integrity.as_str(), "invalid"); + assert_eq!(summary.integrity.short_label(), "invalid"); + assert_eq!( + summary.integrity_reason().as_deref(), + Some("no valid [PATCH vN M/N] sequence found") + ); + + summary.status = PatchSeriesStatus::Conflict; + assert_eq!(summary.status_label(), "conflict"); + } + + #[test] + fn hydrate_series_statuses_overlays_only_visible_threads() { + let root = temp_dir("hydrate-status"); + let runtime = runtime_in(&root, Vec::new()); + initialize_patch_runtime(&runtime, &[(1, "patch@example.com")]); + + let series = patch_store::upsert_series( + &runtime.database_path, + &patch_store::UpsertSeriesRequest { + mailbox: "io-uring".to_string(), + thread_id: 42, + version: 1, + expected_total: 1, + author: "Alice".to_string(), + subject: "demo".to_string(), + anchor_message_id: "patch@example.com".to_string(), + integrity: "complete".to_string(), + missing_seq: Vec::new(), + duplicate_seq: Vec::new(), + out_of_order: false, + items: vec![patch_store::UpsertSeriesItem { + seq: 1, + total: 1, + mail_id: 1, + message_id: "patch@example.com".to_string(), + subject: "demo".to_string(), + raw_path: None, + sort_ord: 0, + }], + }, + ) + .expect("persist series"); + patch_store::update_series_result( + &runtime.database_path, + series.id, + &patch_store::SeriesResultUpdate { + status: PatchSeriesStatus::Applied, + last_error: None, + last_command: Some("b4 am patch@example.com".to_string()), + last_exit_code: Some(0), + last_stdout: Some("applied".to_string()), + last_stderr: None, + output_path: None, + }, + ) + .expect("update series status"); + + let mut visible_summaries = HashMap::from([ + (42, sample_summary("[PATCH 1/1] io_uring: demo", 1)), + ( + 99, + super::SeriesSummary { + thread_id: 99, + status: PatchSeriesStatus::Failed, + ..sample_summary("[PATCH 1/1] io_uring: another", 1) + }, + ), + ]); + + hydrate_series_statuses(&runtime.database_path, "io-uring", &mut visible_summaries) + .expect("hydrate statuses"); + + assert_eq!( + visible_summaries.get(&42).map(|summary| summary.status), + Some(PatchSeriesStatus::Applied) + ); + assert_eq!( + visible_summaries.get(&99).map(|summary| summary.status), + Some(PatchSeriesStatus::Failed) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn load_latest_report_returns_none_when_thread_has_no_patch_state() { + let root = temp_dir("latest-report-none"); + let runtime = runtime_in(&root, Vec::new()); + initialize_patch_runtime(&runtime, &[]); + + let report = + load_latest_report(&runtime.database_path, "io-uring", 42).expect("load latest report"); + assert!(report.is_none()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_action_rejects_incomplete_series_before_persisting_or_running_b4() { + let root = temp_dir("reject-incomplete"); + let runtime = runtime_in(&root, Vec::new()); + let mut summary = sample_summary("[PATCH 1/2] io_uring: demo", 1); + summary.integrity = SeriesIntegrity::Missing; + summary.expected_total = 2; + summary.missing_seq = vec![2]; + + let error = run_action(&runtime, &summary, PatchAction::Apply).expect_err("reject apply"); + + assert_eq!(error.code(), ErrorCode::Command); + assert!(error.to_string().contains("cannot apply series")); + assert!(error.to_string().contains("missing patch index: 2")); + assert!(!runtime.database_path.exists()); + + let _ = fs::remove_dir_all(root); + } + #[test] fn parses_patch_subject_with_version_and_seq() { let parsed = parse_patch_subject("[PATCH v5 2/9] io_uring: demo").expect("parsed"); @@ -1360,6 +1585,59 @@ mod tests { assert_eq!(series.integrity, SeriesIntegrity::Complete); } + #[test] + fn build_series_index_detects_duplicate_and_out_of_order_series() { + let duplicate_rows = vec![ + thread_row(20, 1, "[PATCH v2 1/2] first", "a@example.com", 0), + thread_row(20, 2, "[PATCH v2 1/2] reroll", "b@example.com", 1), + thread_row(20, 3, "[PATCH v2 2/2] second", "c@example.com", 1), + ]; + let duplicate_index = build_series_index("io-uring", &duplicate_rows); + let duplicate = duplicate_index.get(&20).expect("duplicate series exists"); + assert_eq!(duplicate.integrity, SeriesIntegrity::Duplicate); + assert_eq!(duplicate.duplicate_seq, vec![1]); + assert_eq!( + duplicate.integrity_reason().as_deref(), + Some("duplicate patch index: 1") + ); + assert_eq!(duplicate.integrity.short_label(), "duplicate"); + + let out_of_order_rows = vec![ + thread_row(21, 1, "[PATCH v1 2/2] second", "p2@example.com", 0), + thread_row(21, 2, "[PATCH v1 1/2] first", "p1@example.com", 1), + ]; + let out_of_order_index = build_series_index("io-uring", &out_of_order_rows); + let out_of_order = out_of_order_index + .get(&21) + .expect("out-of-order series exists"); + assert_eq!(out_of_order.integrity, SeriesIntegrity::OutOfOrder); + assert!(out_of_order.out_of_order); + assert_eq!( + out_of_order.integrity_reason().as_deref(), + Some("patch order is out-of-order in thread") + ); + } + + #[test] + fn build_series_index_marks_cover_only_series_invalid() { + let rows = vec![thread_row( + 22, + 1, + "[PATCH v4 0/2] io_uring: cover letter only", + "cover@example.com", + 0, + )]; + let index = build_series_index("io-uring", &rows); + let series = index.get(&22).expect("series exists"); + assert_eq!(series.integrity, SeriesIntegrity::Invalid); + assert!(series.items.is_empty()); + assert_eq!(series.anchor_message_id, "cover@example.com"); + assert_eq!( + series.integrity_reason().as_deref(), + Some("no valid [PATCH vN M/N] sequence found") + ); + } + #[test] fn apply_requires_kernel_tree_configuration() { let runtime = runtime_with_kernel_trees(Vec::new()); @@ -1367,6 +1645,24 @@ mod tests { assert!(error.to_string().contains("[kernel].tree")); } + #[test] + fn apply_requires_existing_kernel_tree_directory() { + let root = temp_dir("apply-missing-tree"); + let missing = root.join("missing-tree"); + let runtime = runtime_in(&root, vec![missing.clone()]); + + let error = action_working_dir(&runtime, PatchAction::Apply).expect_err("missing tree"); + assert!(error.to_string().contains("does not exist")); + + let file_path = root.join("not-a-directory"); + fs::write(&file_path, "file").expect("write file"); + let runtime = runtime_in(&root, vec![file_path.clone()]); + let error = action_working_dir(&runtime, PatchAction::Apply).expect_err("file tree"); + assert!(error.to_string().contains("is not")); + + let _ = fs::remove_dir_all(root); + } + #[test] fn apply_uses_first_kernel_tree_directory() { let first = temp_dir("apply-first"); @@ -1387,6 +1683,187 @@ mod tests { assert!(working_dir.is_none()); } + #[test] + fn run_action_download_records_reviewing_report() { + let root = temp_dir("run-action-download"); + let b4_script = write_script( + &root, + "fake-b4.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 0.14.0'\n exit 0\nfi\nif [ \"$1\" = \"am\" ]; then\n echo 'downloaded patch series'\n exit 0\nfi\nexit 1\n", + ); + let mut runtime = runtime_in(&root, Vec::new()); + runtime.b4_path = Some(b4_script); + initialize_patch_runtime(&runtime, &[(1, "patch@example.com")]); + let summary = sample_summary("io_uring: exported series", 2); + + let result = run_action(&runtime, &summary, PatchAction::Download).expect("download runs"); + + assert_eq!(result.status, PatchSeriesStatus::Reviewing); + assert_eq!(result.exit_code, Some(0)); + assert!(!result.timed_out); + assert!(result.command_line.contains("am")); + assert!(result.summary.starts_with("series downloaded to ")); + let output_path = result.output_path.expect("download path"); + assert!(output_path.starts_with(&runtime.patch_dir)); + assert!(output_path.is_dir()); + + let latest = load_latest_report(&runtime.database_path, "io-uring", summary.thread_id) + .expect("load latest report") + .expect("report exists"); + assert_eq!(latest.status, PatchSeriesStatus::Reviewing); + assert_eq!(latest.last_error, None); + assert_eq!(latest.last_exit_code, Some(0)); + assert_eq!( + latest.last_summary.as_deref(), + Some(result.summary.as_str()) + ); + assert_eq!( + latest.last_command.as_deref(), + Some(result.command_line.as_str()) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_action_apply_marks_conflicts_in_latest_report() { + let root = temp_dir("run-action-conflict"); + let repo = root.join("linux"); + fs::create_dir_all(&repo).expect("create repo"); + run_git(&repo, &["init"]); + run_git(&repo, &["config", "user.name", "CRIEW Test"]); + run_git(&repo, &["config", "user.email", "criew@example.com"]); + fs::write(repo.join("base.txt"), "base\n").expect("write base"); + run_git(&repo, &["add", "base.txt"]); + run_git(&repo, &["commit", "-m", "base"]); + + let b4_script = write_script( + &root, + "fake-b4.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 0.14.0'\n exit 0\nfi\nif [ \"$1\" = \"shazam\" ]; then\n echo 'patch failed at 0001 demo' >&2\n exit 1\nfi\nexit 1\n", + ); + let mut runtime = runtime_in(&root, vec![repo.clone()]); + runtime.b4_path = Some(b4_script); + initialize_patch_runtime(&runtime, &[(1, "patch@example.com")]); + let summary = sample_summary("io_uring: conflict series", 1); + + let result = run_action(&runtime, &summary, PatchAction::Apply).expect("apply runs"); + + assert_eq!(result.status, PatchSeriesStatus::Conflict); + assert_eq!(result.exit_code, Some(1)); + assert!(result.summary.contains("series apply conflict")); + assert!(result.head_before.is_none()); + assert!(result.head_after.is_none()); + + let latest = load_latest_report(&runtime.database_path, "io-uring", summary.thread_id) + .expect("load latest report") + .expect("report exists"); + assert_eq!(latest.status, PatchSeriesStatus::Conflict); + assert_eq!(latest.last_error.as_deref(), Some(result.summary.as_str())); + assert_eq!( + latest.last_summary.as_deref(), + Some(result.summary.as_str()) + ); + assert_eq!(latest.last_exit_code, Some(1)); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_action_apply_rejects_noop_success_when_head_does_not_move() { + let root = temp_dir("run-action-noop"); + let repo = root.join("linux"); + fs::create_dir_all(&repo).expect("create repo"); + run_git(&repo, &["init"]); + run_git(&repo, &["config", "user.name", "CRIEW Test"]); + run_git(&repo, &["config", "user.email", "criew@example.com"]); + fs::write(repo.join("base.txt"), "base\n").expect("write base"); + run_git(&repo, &["add", "base.txt"]); + run_git(&repo, &["commit", "-m", "base"]); + + let b4_script = write_script( + &root, + "fake-b4.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 0.14.0'\n exit 0\nfi\nif [ \"$1\" = \"shazam\" ]; then\n echo 'applied without commit'\n exit 0\nfi\nexit 1\n", + ); + let mut runtime = runtime_in(&root, vec![repo.clone()]); + runtime.b4_path = Some(b4_script); + initialize_patch_runtime(&runtime, &[(1, "patch@example.com")]); + let summary = sample_summary("io_uring: noop series", 1); + + let result = run_action(&runtime, &summary, PatchAction::Apply).expect("apply runs"); + + assert_eq!(result.status, PatchSeriesStatus::Failed); + assert_eq!(result.exit_code, Some(0)); + assert!(result.summary.contains("git HEAD did not move")); + assert!(result.head_before.is_none()); + assert!(result.head_after.is_none()); + + let latest = load_latest_report(&runtime.database_path, "io-uring", summary.thread_id) + .expect("load latest report") + .expect("report exists"); + assert_eq!(latest.status, PatchSeriesStatus::Failed); + assert_eq!(latest.last_error.as_deref(), Some(result.summary.as_str())); + assert_eq!( + latest.last_summary.as_deref(), + Some(result.summary.as_str()) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_action_apply_records_head_change_and_moves_artifacts() { + let root = temp_dir("run-action-apply-success"); + let repo = root.join("linux"); + fs::create_dir_all(&repo).expect("create repo"); + run_git(&repo, &["init"]); + run_git(&repo, &["config", "user.name", "CRIEW Test"]); + run_git(&repo, &["config", "user.email", "criew@example.com"]); + fs::write(repo.join("base.txt"), "base\n").expect("write base"); + run_git(&repo, &["add", "base.txt"]); + run_git(&repo, &["commit", "-m", "base"]); + + let b4_script = write_script( + &root, + "fake-b4.sh", + "#!/bin/sh\nset -e\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 0.14.0'\n exit 0\nfi\nif [ \"$1\" = \"shazam\" ]; then\n printf 'applied by fake b4\\n' > applied.txt\n git add applied.txt\n git commit -m 'apply-series' >/dev/null 2>&1\n printf 'mbx\\n' > demo-series.mbx\n printf 'cover\\n' > demo-series.cover\n exit 0\nfi\nexit 1\n", + ); + let mut runtime = runtime_in(&root, vec![repo.clone()]); + runtime.b4_path = Some(b4_script); + initialize_patch_runtime(&runtime, &[(1, "patch@example.com")]); + let summary = sample_summary("io_uring: applied series", 1); + + let result = run_action(&runtime, &summary, PatchAction::Apply).expect("apply runs"); + + assert_eq!(result.status, PatchSeriesStatus::Applied); + assert_eq!(result.exit_code, Some(0)); + assert!(result.summary.contains("series applied by b4 shazam")); + assert!(result.summary.contains("artifacts moved to")); + let head_before = result.head_before.expect("head before"); + let head_after = result.head_after.expect("head after"); + assert_ne!(head_before, head_after); + + let output_path = result.output_path.expect("artifact path"); + assert!(output_path.starts_with(runtime.patch_dir.join(APPLY_ARTIFACTS_DIR))); + assert!(output_path.join("demo-series.mbx").exists()); + assert!(output_path.join("demo-series.cover").exists()); + assert!(!repo.join("demo-series.mbx").exists()); + assert!(!repo.join("demo-series.cover").exists()); + + let latest = load_latest_report(&runtime.database_path, "io-uring", summary.thread_id) + .expect("load latest report") + .expect("report exists"); + assert_eq!(latest.status, PatchSeriesStatus::Applied); + assert_eq!(latest.last_error, None); + assert_eq!( + latest.last_summary.as_deref(), + Some(result.summary.as_str()) + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn undo_last_apply_resets_head_to_previous_commit() { let repo = temp_dir("undo-apply"); diff --git a/src/infra/b4.rs b/src/infra/b4.rs index 903bdbe..b007efc 100644 --- a/src/infra/b4.rs +++ b/src/infra/b4.rs @@ -13,6 +13,9 @@ use std::time::{Duration, Instant}; use super::b4_vendor; use crate::infra::error::{CriewError, ErrorCode, Result}; +const EXECUTABLE_BUSY_RETRY_ATTEMPTS: u8 = 5; +const EXECUTABLE_BUSY_RETRY_DELAY: Duration = Duration::from_millis(10); + #[derive(Debug, Clone)] pub struct B4Check { pub status: B4Status, @@ -47,7 +50,10 @@ struct ResolvedCommand { } pub fn check(configured_path: Option<&Path>, runtime_data_dir: Option<&Path>) -> B4Check { - let candidates = candidates(configured_path, runtime_data_dir); + check_from_candidates(candidates(configured_path, runtime_data_dir)) +} + +fn check_from_candidates(candidates: Vec) -> B4Check { let mut last_failure: Option<(PathBuf, String)> = None; for candidate in candidates { @@ -84,7 +90,16 @@ pub fn run( working_dir: Option<&Path>, ) -> Result { let resolved = resolve_command(configured_path, runtime_data_dir)?; + run_with_resolved_command(&resolved, subcommand, args, timeout, working_dir) +} +fn run_with_resolved_command( + resolved: &ResolvedCommand, + subcommand: &str, + args: &[String], + timeout: Duration, + working_dir: Option<&Path>, +) -> Result { let mut command = Command::new(&resolved.command); if let Some(working_dir) = working_dir { command.current_dir(working_dir); @@ -96,7 +111,7 @@ pub fn run( command.stdout(Stdio::piped()).stderr(Stdio::piped()); let command_line = render_command_line(&resolved.command, subcommand, args); - let mut child = command.spawn().map_err(|error| { + let mut child = spawn_command_with_retry(&mut command).map_err(|error| { CriewError::with_source( ErrorCode::B4, format!( @@ -152,6 +167,22 @@ pub fn run( } fn candidates(configured_path: Option<&Path>, runtime_data_dir: Option<&Path>) -> Vec { + let env_b4_path = env::var_os("CRIEW_B4_PATH").map(PathBuf::from); + let cwd = env::current_dir().ok(); + candidates_with( + configured_path, + runtime_data_dir, + env_b4_path.as_deref(), + cwd.as_deref(), + ) +} + +fn candidates_with( + configured_path: Option<&Path>, + runtime_data_dir: Option<&Path>, + env_b4_path: Option<&Path>, + cwd: Option<&Path>, +) -> Vec { let mut values = Vec::new(); // Discovery order is from most explicit to most implicit so user config @@ -160,14 +191,13 @@ fn candidates(configured_path: Option<&Path>, runtime_data_dir: Option<&Path>) - values.push(Candidate::Path(path.to_path_buf())); } - if let Ok(env_path) = env::var("CRIEW_B4_PATH") { - let path = PathBuf::from(env_path); + if let Some(path) = env_b4_path { if !path.as_os_str().is_empty() { - values.push(Candidate::Path(path)); + values.push(Candidate::Path(path.to_path_buf())); } } - if let Ok(cwd) = env::current_dir() { + if let Some(cwd) = cwd { values.push(Candidate::Path(cwd.join("vendor/b4/b4.sh"))); } @@ -219,11 +249,26 @@ fn probe(candidate: &Candidate) -> Probe { } } +fn spawn_command_with_retry(command: &mut Command) -> std::io::Result { + let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS; + + loop { + match command.spawn() { + Ok(child) => return Ok(child), + Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => { + attempts_remaining -= 1; + thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY); + } + Err(error) => return Err(error), + } + } +} + fn run_probe(command: T, label: &Path, command_value: String) -> Probe where T: AsRef, { - match Command::new(command).arg("--version").output() { + match output_with_retry(Command::new(command).arg("--version")) { Ok(output) if output.status.success() => { let version = normalize_output(&output.stdout) .or_else(|| normalize_output(&output.stderr)) @@ -253,12 +298,35 @@ where } } +fn output_with_retry(command: &mut Command) -> std::io::Result { + let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS; + + loop { + match command.output() { + Ok(output) => return Ok(output), + Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => { + attempts_remaining -= 1; + thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY); + } + Err(error) => return Err(error), + } + } +} + +fn is_retryable_executable_busy(error: &std::io::Error) -> bool { + error.kind() == std::io::ErrorKind::ExecutableFileBusy +} + fn resolve_command( configured_path: Option<&Path>, runtime_data_dir: Option<&Path>, ) -> Result { + resolve_from_candidates(candidates(configured_path, runtime_data_dir)) +} + +fn resolve_from_candidates(candidates: Vec) -> Result { let mut last_failure: Option<(PathBuf, String)> = None; - for candidate in candidates(configured_path, runtime_data_dir) { + for candidate in candidates { match probe(&candidate) { Probe::Available { command, path, .. } => { return Ok(ResolvedCommand { @@ -318,3 +386,248 @@ fn normalize_output(bytes: &[u8]) -> Option { .find(|line| !line.is_empty()) .map(ToOwned::to_owned) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::Write; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use crate::infra::error::ErrorCode; + + use super::{ + B4Status, Candidate, Probe, candidates_with, check_from_candidates, normalize_output, + probe, render_command_line, resolve_from_candidates, run_with_resolved_command, + }; + + fn temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let path = std::env::temp_dir().join(format!("criew-b4-{label}-{nonce}")); + fs::create_dir_all(&path).expect("create temp dir"); + path + } + + fn write_script(root: &Path, name: &str, body: &str) -> PathBuf { + let path = root.join(name); + let staging_path = root.join(format!(".{name}.tmp")); + let mut staging_file = fs::File::create(&staging_path).expect("create staging script"); + staging_file + .write_all(body.as_bytes()) + .expect("write staging script"); + staging_file.sync_all().expect("sync staging script"); + drop(staging_file); + #[cfg(unix)] + { + let mut permissions = fs::metadata(&staging_path) + .expect("staging metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&staging_path, permissions).expect("mark executable"); + } + fs::rename(&staging_path, &path).expect("install script"); + path + } + + #[test] + fn check_prefers_available_configured_script() { + let root = temp_dir("configured-ok"); + let configured_script = write_script( + &root, + "b4-ok.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 1.2.3'\n exit 0\nfi\nexit 0\n", + ); + let fallback_script = write_script( + &root, + "b4-fallback.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 9.9.9'\n exit 0\nfi\nexit 0\n", + ); + + let result = check_from_candidates(vec![ + Candidate::Path(configured_script.clone()), + Candidate::Path(fallback_script), + ]); + + match result.status { + B4Status::Available { path, version } => { + assert_eq!(path, configured_script); + assert_eq!(version, "b4 1.2.3"); + } + status => panic!("expected available status, got {status:?}"), + } + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn check_reports_broken_configured_script_when_no_fallback_exists() { + let root = temp_dir("configured-broken"); + let script = write_script( + &root, + "b4-broken.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'broken runtime' >&2\n exit 1\nfi\nexit 1\n", + ); + + let result = check_from_candidates(vec![Candidate::Path(script.clone())]); + + match result.status { + B4Status::Broken { path, reason } => { + assert_eq!(path, script); + assert_eq!(reason, "broken runtime"); + } + status => panic!("expected broken status, got {status:?}"), + } + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_executes_configured_script_in_requested_workdir() { + let root = temp_dir("run-ok"); + let workdir = root.join("workdir"); + fs::create_dir_all(&workdir).expect("create workdir"); + let script = write_script( + &root, + "b4-run.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 2.0.0'\n exit 0\nfi\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf 'subcommand=%s\\n' \"$1\"\nprintf 'arg1=%s\\n' \"$2\"\nprintf 'arg2=%s\\n' \"$3\"\nprintf 'stderr-line\\n' >&2\n", + ); + + let resolved = resolve_from_candidates(vec![Candidate::Path(script.clone())]) + .expect("resolve configured b4"); + let result = run_with_resolved_command( + &resolved, + "am", + &["--foo".to_string(), "bar baz".to_string()], + Duration::from_secs(1), + Some(&workdir), + ) + .expect("run b4"); + + assert_eq!(result.exit_code, Some(0)); + assert!(!result.timed_out); + assert_eq!( + result.command_line, + format!("{} am --foo 'bar baz'", script.display()) + ); + assert!( + result + .stdout + .contains(&format!("cwd={}", workdir.display())) + ); + assert!(result.stdout.contains("subcommand=am")); + assert!(result.stdout.contains("arg1=--foo")); + assert!(result.stdout.contains("arg2=bar baz")); + assert!(result.stderr.contains("stderr-line")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn run_marks_timed_out_processes() { + let root = temp_dir("run-timeout"); + let script = write_script( + &root, + "b4-timeout.sh", + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'b4 2.1.0'\n exit 0\nfi\nwhile :; do :; done\n", + ); + + let resolved = + resolve_from_candidates(vec![Candidate::Path(script)]).expect("resolve timeout b4"); + let result = + run_with_resolved_command(&resolved, "am", &[], Duration::from_millis(10), None) + .expect("run b4"); + + assert!(result.timed_out); + assert_ne!(result.exit_code, Some(0)); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn candidates_include_config_env_cwd_runtime_and_program_in_order() { + let root = temp_dir("candidates"); + let cwd = root.join("cwd"); + fs::create_dir_all(&cwd).expect("create cwd"); + let configured = root.join("configured-b4"); + let env_path = root.join("env-b4"); + let runtime = root.join("runtime"); + + let values = candidates_with( + Some(&configured), + Some(&runtime), + Some(&env_path), + Some(&cwd), + ); + + assert!(matches!(&values[0], Candidate::Path(path) if path == &configured)); + assert!(matches!(&values[1], Candidate::Path(path) if path == &env_path)); + assert!( + matches!(&values[2], Candidate::Path(path) if path == &cwd.join("vendor/b4/b4.sh")) + ); + assert!(matches!(&values[3], Candidate::EmbeddedVendor(path) if path == &runtime)); + assert!(matches!(&values[4], Candidate::Program(program) if program == "b4")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn probe_reports_missing_and_embedded_vendor_failures() { + let missing = probe(&Candidate::Path(PathBuf::from("/definitely/missing/b4"))); + assert!(matches!(missing, Probe::Missing)); + + let root = temp_dir("embedded-broken"); + let vendor_root = root.join("vendor"); + fs::write(&vendor_root, "not a directory").expect("block vendor root"); + + let broken = probe(&Candidate::EmbeddedVendor(root.clone())); + match broken { + Probe::Broken { path, reason } => { + assert_eq!(path, root.join("vendor/b4/b4.sh")); + assert!(reason.contains("failed to create embedded b4 directory")); + } + _ => panic!("expected broken embedded vendor probe"), + } + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_command_reports_not_found_without_candidates() { + let error = resolve_from_candidates(vec![ + Candidate::Path(PathBuf::from("/definitely/missing/b4")), + Candidate::Program("criew-b4-definitely-missing".to_string()), + ]) + .expect_err("missing b4 should fail"); + + assert_eq!(error.code(), ErrorCode::B4); + assert!(error.to_string().contains("b4 executable not found")); + } + + #[test] + fn render_command_line_quotes_special_tokens() { + let rendered = render_command_line( + "/tmp/demo path/b4.sh", + "am", + &["bar baz".to_string(), "quote'char".to_string()], + ); + + assert_eq!( + rendered, + "'/tmp/demo path/b4.sh' am 'bar baz' 'quote'\\''char'" + ); + } + + #[test] + fn normalize_output_returns_first_non_empty_trimmed_line() { + assert_eq!( + normalize_output(b"\n \n b4 3.0.0 \nnext line\n"), + Some("b4 3.0.0".to_string()) + ); + assert_eq!(normalize_output(b"\n\t \n"), None); + } +} diff --git a/src/infra/b4_vendor.rs b/src/infra/b4_vendor.rs index 044fe4c..c8773cc 100644 --- a/src/infra/b4_vendor.rs +++ b/src/infra/b4_vendor.rs @@ -131,4 +131,51 @@ mod tests { let _ = fs::remove_dir_all(&temp_root); } + + #[test] + fn ensure_installed_is_idempotent_for_existing_runtime_tree() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time before unix epoch") + .as_nanos(); + let temp_root = std::env::temp_dir().join(format!( + "criew-b4-vendor-repeat-test-{}-{nonce}", + std::process::id() + )); + + let first = ensure_installed(&temp_root).expect("first embedded b4 install"); + let second = ensure_installed(&temp_root).expect("second embedded b4 install"); + + assert_eq!(first, second); + if let Some(script) = second { + assert_eq!(script, script_path(&temp_root)); + assert!(script.exists(), "embedded b4 script should still exist"); + } + + let _ = fs::remove_dir_all(&temp_root); + } + + #[test] + fn ensure_installed_reports_directory_conflicts() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time before unix epoch") + .as_nanos(); + let temp_root = std::env::temp_dir().join(format!( + "criew-b4-vendor-conflict-test-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&temp_root).expect("create temp root"); + fs::write(temp_root.join("vendor"), "not a directory").expect("write blocking vendor file"); + + let error = ensure_installed(&temp_root).expect_err("conflicting vendor path should fail"); + + assert!( + error + .to_string() + .contains("failed to create embedded b4 directory") + ); + + let _ = fs::remove_dir_all(&temp_root); + } } diff --git a/src/infra/bootstrap.rs b/src/infra/bootstrap.rs index cfde67e..be912dc 100644 --- a/src/infra/bootstrap.rs +++ b/src/infra/bootstrap.rs @@ -106,3 +106,98 @@ fn ensure_runtime_dirs(config: &RuntimeConfig) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + use crate::infra::config::{DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, ImapConfig, UiKeymap}; + use crate::infra::db::CURRENT_SCHEMA_VERSION; + use crate::infra::error::ErrorCode; + + use super::{RuntimeConfig, prepare}; + + fn temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let path = std::env::temp_dir().join(format!("criew-bootstrap-{label}-{nonce}")); + fs::create_dir_all(&path).expect("create temp dir"); + path + } + + fn test_runtime_in(root: PathBuf) -> RuntimeConfig { + RuntimeConfig { + config_path: root.join("config").join("criew-config.toml"), + data_dir: root.join("data"), + database_path: root.join("data").join("db").join("criew.db"), + raw_mail_dir: root.join("data").join("raw"), + patch_dir: root.join("data").join("patches"), + log_dir: root.join("logs"), + b4_path: None, + log_filter: "info".to_string(), + source_mailbox: "linux-kernel".to_string(), + imap: ImapConfig::default(), + lore_base_url: "https://lore.kernel.org".to_string(), + startup_sync: true, + ui_keymap: UiKeymap::Default, + inbox_auto_sync_interval_secs: DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, + kernel_trees: Vec::new(), + } + } + + #[test] + fn prepare_creates_runtime_state_and_is_idempotent() { + let root = temp_dir("prepare"); + let runtime = test_runtime_in(root.clone()); + + let first = prepare(&runtime).expect("prepare runtime"); + let second = prepare(&runtime).expect("prepare existing runtime"); + + assert!(runtime.config_path.parent().expect("config dir").is_dir()); + assert!(runtime.data_dir.is_dir()); + assert!(runtime.raw_mail_dir.is_dir()); + assert!(runtime.patch_dir.is_dir()); + assert!(runtime.log_dir.is_dir()); + assert!(runtime.database_path.is_file()); + + assert_eq!(first.db.path, runtime.database_path); + assert!(first.db.created); + assert_eq!(first.db.schema_version, CURRENT_SCHEMA_VERSION); + assert_eq!( + first.db.applied_migrations, + vec![1, 2, 3, CURRENT_SCHEMA_VERSION] + ); + + assert_eq!(second.db.path, runtime.database_path); + assert!(!second.db.created); + assert_eq!(second.db.schema_version, CURRENT_SCHEMA_VERSION); + assert!(second.db.applied_migrations.is_empty()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prepare_reports_runtime_directory_conflicts() { + let root = temp_dir("prepare-conflict"); + let blocked_path = root.join("blocked"); + fs::write(&blocked_path, "not a directory").expect("write blocking file"); + + let mut runtime = test_runtime_in(root.clone()); + runtime.data_dir = blocked_path.join("data"); + + let error = prepare(&runtime).expect_err("conflicting data directory should fail"); + + assert_eq!(error.code(), ErrorCode::Io); + assert!( + error + .to_string() + .contains("failed to create data directory") + ); + + let _ = fs::remove_dir_all(root); + } +} diff --git a/src/infra/db.rs b/src/infra/db.rs index ed565cc..11c34dd 100644 --- a/src/infra/db.rs +++ b/src/infra/db.rs @@ -165,6 +165,8 @@ mod tests { use rusqlite::Connection; + use crate::infra::error::ErrorCode; + use super::{CURRENT_SCHEMA_VERSION, initialize}; fn temp_dir(label: &str) -> PathBuf { @@ -200,4 +202,121 @@ mod tests { let _ = fs::remove_dir_all(root); } + + #[test] + fn initialize_is_idempotent_for_existing_database() { + let root = temp_dir("db-reinitialize"); + let db_path = root.join("criew.db"); + + let first = initialize(&db_path).expect("initialize db"); + let second = initialize(&db_path).expect("reinitialize db"); + + assert!(first.created); + assert_eq!(second.path, db_path); + assert!(!second.created); + assert_eq!(second.schema_version, CURRENT_SCHEMA_VERSION); + assert!(second.applied_migrations.is_empty()); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn initialize_reports_missing_parent_directory() { + let root = temp_dir("db-missing-parent"); + let db_path = root.join("missing").join("criew.db"); + + let error = initialize(&db_path).expect_err("missing parent directory should fail"); + + assert_eq!(error.code(), ErrorCode::Database); + assert!(error.to_string().contains("failed to open sqlite database")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn initialize_reports_schema_version_table_creation_conflicts() { + let root = temp_dir("db-schema-version-conflict"); + let db_path = root.join("criew.db"); + let connection = Connection::open(&db_path).expect("open sqlite"); + connection + .execute("CREATE VIEW schema_version AS SELECT 1 AS version", []) + .expect("create conflicting view"); + drop(connection); + + let error = initialize(&db_path).expect_err("schema_version conflict should fail"); + + assert_eq!(error.code(), ErrorCode::Database); + assert!(error.to_string().contains("schema") || error.to_string().contains("migration")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn initialize_reports_schema_version_query_failure_for_invalid_table_shape() { + let root = temp_dir("db-schema-version-shape"); + let db_path = root.join("criew.db"); + let connection = Connection::open(&db_path).expect("open sqlite"); + connection + .execute( + "CREATE TABLE schema_version (description TEXT NOT NULL)", + [], + ) + .expect("create malformed schema_version"); + drop(connection); + + let error = initialize(&db_path).expect_err("invalid schema_version shape should fail"); + + assert_eq!(error.code(), ErrorCode::Database); + assert!(error.to_string().contains("failed to query schema version")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn initialize_reports_migration_and_registration_failures() { + let root = temp_dir("db-migration-failures"); + + let conflicting_db_path = root.join("migration-sql.db"); + let connection = Connection::open(&conflicting_db_path).expect("open conflicting sqlite"); + connection + .execute( + "CREATE TABLE schema_version (version INTEGER PRIMARY KEY, description TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT '')", + [], + ) + .expect("create schema_version"); + connection + .execute("CREATE VIEW mail AS SELECT 1 AS id", []) + .expect("create conflicting mail view"); + drop(connection); + + let migration_error = + initialize(&conflicting_db_path).expect_err("migration SQL conflict should fail"); + assert_eq!(migration_error.code(), ErrorCode::Database); + assert!( + migration_error + .to_string() + .contains("failed to run migration 1") + ); + + let missing_column_db_path = root.join("migration-register.db"); + let connection = Connection::open(&missing_column_db_path).expect("open sqlite"); + connection + .execute( + "CREATE TABLE schema_version (version INTEGER PRIMARY KEY)", + [], + ) + .expect("create truncated schema_version"); + drop(connection); + + let register_error = + initialize(&missing_column_db_path).expect_err("migration registration should fail"); + assert_eq!(register_error.code(), ErrorCode::Database); + assert!( + register_error + .to_string() + .contains("failed to register migration 1") + ); + + let _ = fs::remove_dir_all(root); + } } diff --git a/src/infra/imap.rs b/src/infra/imap.rs index 400b3ad..201e9e7 100644 --- a/src/infra/imap.rs +++ b/src/infra/imap.rs @@ -31,6 +31,12 @@ const IMAP_FETCH_BATCH_SIZE: usize = 100; const GNU_ARCHIVE_INITIAL_MONTH_LIMIT: usize = 2; const GNU_ARCHIVE_UID_STRIDE: u32 = 1_000_000; +#[cfg(test)] +trait TestTransportIo: Read + Write + Send {} + +#[cfg(test)] +impl TestTransportIo for T where T: Read + Write + Send {} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] pub enum ImapErrorKind { @@ -371,7 +377,7 @@ impl LoreImapClient { ) })?; - let status = response.status(); + let status_code = response.status().as_u16(); let body = response.text().map_err(|error| { CriewError::with_source( ErrorCode::Imap, @@ -380,43 +386,19 @@ impl LoreImapClient { ) })?; - if !status.is_success() { - return Err(imap_error( - ImapErrorKind::MailboxSelection, - format!("failed to fetch lore feed {url}: HTTP {status}"), - )); - } - - parse_lore_atom_entries(&body) + parse_lore_feed_response(&url, status_code, &body) } fn fetch_raw_mail(&self, message_url: &str) -> Result> { - let mut last_error: Option = None; - - for raw_url in lore_raw_url_candidates(message_url) { - let response = match self.client.get(&raw_url).send() { - Ok(response) => response, - Err(error) => { - last_error = Some(CriewError::with_source( - ErrorCode::Imap, - format!("failed to fetch lore raw message {raw_url}"), - error, - )); - continue; - } - }; - - if !response.status().is_success() { - last_error = Some(imap_error( - ImapErrorKind::Protocol, - format!( - "failed to fetch lore raw message {raw_url}: HTTP {}", - response.status() - ), - )); - continue; - } - + fetch_lore_raw_with(message_url, |raw_url| { + let response = self.client.get(raw_url).send().map_err(|error| { + CriewError::with_source( + ErrorCode::Imap, + format!("failed to fetch lore raw message {raw_url}"), + error, + ) + })?; + let status_code = response.status().as_u16(); let bytes = response.bytes().map_err(|error| { CriewError::with_source( ErrorCode::Imap, @@ -424,24 +406,88 @@ impl LoreImapClient { error, ) })?; + Ok((status_code, bytes.to_vec())) + }) + } +} - if !bytes.is_empty() { - return Ok(bytes.to_vec()); +fn parse_lore_feed_response(url: &str, status_code: u16, body: &str) -> Result> { + if !(200..300).contains(&status_code) { + return Err(imap_error( + ImapErrorKind::MailboxSelection, + format!("failed to fetch lore feed {url}: HTTP {status_code}"), + )); + } + + parse_lore_atom_entries(body) +} + +fn fetch_lore_raw_with(message_url: &str, mut fetch_response: F) -> Result> +where + F: FnMut(&str) -> Result<(u16, Vec)>, +{ + let mut last_error: Option = None; + + for raw_url in lore_raw_url_candidates(message_url) { + let (status_code, bytes) = match fetch_response(&raw_url) { + Ok(response) => response, + Err(error) => { + last_error = Some(error); + continue; } + }; + if !(200..300).contains(&status_code) { last_error = Some(imap_error( ImapErrorKind::Protocol, - format!("lore raw message is empty: {raw_url}"), + format!("failed to fetch lore raw message {raw_url}: HTTP {status_code}"), )); + continue; } - Err(last_error.unwrap_or_else(|| { - imap_error( - ImapErrorKind::Protocol, - format!("failed to resolve raw message URL for {message_url}"), - ) - })) + if !bytes.is_empty() { + return Ok(bytes); + } + + last_error = Some(imap_error( + ImapErrorKind::Protocol, + format!("lore raw message is empty: {raw_url}"), + )); + } + + Err(last_error.unwrap_or_else(|| { + imap_error( + ImapErrorKind::Protocol, + format!("failed to resolve raw message URL for {message_url}"), + ) + })) +} + +fn build_lore_incremental_mails( + entries: Vec, + since_modseq: Option, + mut fetch_raw_mail: F, +) -> Result> +where + F: FnMut(&str) -> Result>, +{ + let mut fetched = Vec::new(); + for entry in entries { + if since_modseq.is_some_and(|checkpoint| entry.modseq <= checkpoint) { + continue; + } + + let raw = fetch_raw_mail(&entry.message_url)?; + fetched.push(RemoteMail { + uid: 0, + modseq: Some(entry.modseq), + flags: Vec::new(), + raw, + }); } + + fetched.sort_by_key(|mail| mail.modseq.unwrap_or(0)); + Ok(fetched) } impl ImapClient for LoreImapClient { @@ -470,24 +516,9 @@ impl ImapClient for LoreImapClient { ) -> Result> { self.ensure_connected()?; let entries = self.fetch_feed_entries(mailbox)?; - - let mut fetched = Vec::new(); - for entry in entries { - if since_modseq.is_some_and(|checkpoint| entry.modseq <= checkpoint) { - continue; - } - - let raw = self.fetch_raw_mail(&entry.message_url)?; - fetched.push(RemoteMail { - uid: 0, - modseq: Some(entry.modseq), - flags: Vec::new(), - raw, - }); - } - - fetched.sort_by_key(|mail| mail.modseq.unwrap_or(0)); - Ok(fetched) + build_lore_incremental_mails(entries, since_modseq, |message_url| { + self.fetch_raw_mail(message_url) + }) } } @@ -553,7 +584,7 @@ impl GnuArchiveClient { ) })?; - let status = response.status(); + let status_code = response.status().as_u16(); let body = response.text().map_err(|error| { CriewError::with_source( ErrorCode::Imap, @@ -562,14 +593,7 @@ impl GnuArchiveClient { ) })?; - if !status.is_success() { - return Err(imap_error( - ImapErrorKind::MailboxSelection, - format!("failed to fetch GNU archive index {url}: HTTP {status}"), - )); - } - - parse_gnu_archive_month_entries(&body) + parse_gnu_archive_index_response(&url, status_code, &body) } fn fetch_month_mbox(&self, mailbox: &str, month_key: &str) -> Result> { @@ -582,15 +606,8 @@ impl GnuArchiveClient { ) })?; - let status = response.status(); - if !status.is_success() { - return Err(imap_error( - ImapErrorKind::Protocol, - format!("failed to fetch GNU archive mbox {url}: HTTP {status}"), - )); - } - - response + let status_code = response.status().as_u16(); + let bytes = response .bytes() .map(|bytes| bytes.to_vec()) .map_err(|error| { @@ -599,8 +616,74 @@ impl GnuArchiveClient { format!("failed to read GNU archive mbox body {url}"), error, ) - }) + })?; + + validate_gnu_archive_mbox_response(&url, status_code, bytes) + } +} + +fn parse_gnu_archive_index_response( + url: &str, + status_code: u16, + body: &str, +) -> Result> { + if !(200..300).contains(&status_code) { + return Err(imap_error( + ImapErrorKind::MailboxSelection, + format!("failed to fetch GNU archive index {url}: HTTP {status_code}"), + )); + } + + parse_gnu_archive_month_entries(body) +} + +fn validate_gnu_archive_mbox_response( + url: &str, + status_code: u16, + body: Vec, +) -> Result> { + if !(200..300).contains(&status_code) { + return Err(imap_error( + ImapErrorKind::Protocol, + format!("failed to fetch GNU archive mbox {url}: HTTP {status_code}"), + )); + } + + Ok(body) +} + +fn build_gnu_archive_incremental_mails( + months: &[GnuArchiveMonthEntry], + since_modseq: Option, + mut fetch_month_mbox: F, +) -> Result> +where + F: FnMut(&str) -> Result>, +{ + let selected_months = select_gnu_archive_months(months, since_modseq); + + let mut fetched = Vec::new(); + for month in selected_months { + let raw_mbox = fetch_month_mbox(&month.month_key)?; + for (index, raw) in parse_gnu_archive_mbox_messages(&raw_mbox) + .into_iter() + .enumerate() + { + fetched.push(RemoteMail { + uid: gnu_archive_message_uid(&month.month_key, index), + modseq: Some(month.modseq), + flags: Vec::new(), + raw, + }); + } } + + fetched.sort_by(|left, right| { + left.modseq + .cmp(&right.modseq) + .then_with(|| left.uid.cmp(&right.uid)) + }); + Ok(fetched) } impl ImapClient for GnuArchiveClient { @@ -629,30 +712,9 @@ impl ImapClient for GnuArchiveClient { ) -> Result> { self.ensure_connected()?; let months = self.fetch_month_entries(mailbox)?; - let selected_months = select_gnu_archive_months(&months, since_modseq); - - let mut fetched = Vec::new(); - for month in selected_months { - let raw_mbox = self.fetch_month_mbox(mailbox, &month.month_key)?; - for (index, raw) in parse_gnu_archive_mbox_messages(&raw_mbox) - .into_iter() - .enumerate() - { - fetched.push(RemoteMail { - uid: gnu_archive_message_uid(&month.month_key, index), - modseq: Some(month.modseq), - flags: Vec::new(), - raw, - }); - } - } - - fetched.sort_by(|left, right| { - left.modseq - .cmp(&right.modseq) - .then_with(|| left.uid.cmp(&right.uid)) - }); - Ok(fetched) + build_gnu_archive_incremental_mails(&months, since_modseq, |month_key| { + self.fetch_month_mbox(mailbox, month_key) + }) } } @@ -760,6 +822,8 @@ fn collect_incremental_uids( enum ImapTransport { Plain(TcpStream), Tls(Box>), + #[cfg(test)] + Mock(Box), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -795,6 +859,8 @@ impl Read for ImapTransport { match self { Self::Plain(stream) => stream.read(buf), Self::Tls(stream) => stream.read(buf), + #[cfg(test)] + Self::Mock(stream) => stream.read(buf), } } } @@ -804,6 +870,8 @@ impl Write for ImapTransport { match self { Self::Plain(stream) => stream.write(buf), Self::Tls(stream) => stream.write(buf), + #[cfg(test)] + Self::Mock(stream) => stream.write(buf), } } @@ -811,6 +879,8 @@ impl Write for ImapTransport { match self { Self::Plain(stream) => stream.flush(), Self::Tls(stream) => stream.flush(), + #[cfg(test)] + Self::Mock(stream) => stream.flush(), } } } @@ -829,6 +899,16 @@ enum GreetingKind { } impl ImapSession { + #[cfg(test)] + fn with_mock_stream(stream: impl TestTransportIo + 'static) -> Self { + Self { + transport: ImapTransport::Mock(Box::new(stream)), + read_buffer: Vec::new(), + next_tag: 1, + capabilities: HashSet::new(), + } + } + fn connect(config: &ImapConfig) -> Result { let server = config.server.as_deref().ok_or_else(|| { imap_error( @@ -875,6 +955,13 @@ impl ImapSession { "STARTTLS attempted on TLS transport", )); } + #[cfg(test)] + ImapTransport::Mock(_) => { + return Err(imap_error( + ImapErrorKind::Connection, + "STARTTLS attempted on mock transport", + )); + } }; session.read_buffer.clear(); } @@ -2157,8 +2244,10 @@ fn classify(kind: ImapErrorKind) -> &'static str { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::fs; use std::io::{Read, Write}; + use std::net::TcpListener; use std::path::PathBuf; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -2166,13 +2255,18 @@ mod tests { use crate::infra::config::{ImapConfig, ImapEncryption}; use super::{ - FixtureImapClient, GnuArchiveMonthEntry, ImapClient, ImapErrorKind, RemoteImapClient, - ensure_tagged_ok, establish_http_connect_tunnel, establish_socks5_tunnel, - format_uid_sequence_set, gnu_archive_message_uid, lore_raw_url_candidates, - parse_atom_timestamp, parse_fetch_flags, parse_fetch_modseq, parse_fetch_uid, + FixtureImapClient, GnuArchiveClient, GnuArchiveMonthEntry, GreetingKind, ImapClient, + ImapErrorKind, ImapSession, LoreImapClient, MailboxSnapshot, RemoteImapClient, + build_gnu_archive_incremental_mails, build_lore_incremental_mails, + collect_incremental_uids, ensure_tagged_ok, establish_http_connect_tunnel, + establish_socks5_tunnel, fetch_lore_raw_with, format_uid_sequence_set, + gnu_archive_message_uid, lore_raw_url_candidates, normalize_lore_message_url, + parse_atom_timestamp, parse_fetch_flags, parse_fetch_modseq, parse_fetch_uid, parse_flags, + parse_gnu_archive_index_response, parse_gnu_archive_listing_timestamp, parse_gnu_archive_mbox_messages, parse_gnu_archive_month_entries, parse_imap_proxy, - parse_literal_len, parse_lore_atom_entries, parse_status_code_u64, - select_gnu_archive_months, + parse_literal_len, parse_lore_atom_entries, parse_lore_feed_response, + parse_status_code_u64, parse_year_month_key, quote_imap_string, read_http_proxy_response, + select_gnu_archive_months, validate_gnu_archive_mbox_response, }; fn temp_dir(label: &str) -> PathBuf { @@ -2226,6 +2320,165 @@ mod tests { } } + struct WriteFailsStream; + + impl Read for WriteFailsStream { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Ok(0) + } + } + + impl Write for WriteFailsStream { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "write failed", + )) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + struct FlushFailsStream; + + impl Read for FlushFailsStream { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Ok(0) + } + } + + impl Write for FlushFailsStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "flush failed", + )) + } + } + + struct ReadFailsStream; + + impl Read for ReadFailsStream { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "read failed", + )) + } + } + + impl Write for ReadFailsStream { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[derive(Clone)] + struct StubHttpResponse { + status_code: u16, + content_type: &'static str, + body: Vec, + } + + impl StubHttpResponse { + fn text(status_code: u16, body: impl Into) -> Self { + Self { + status_code, + content_type: "text/plain; charset=utf-8", + body: body.into().into_bytes(), + } + } + + fn bytes(status_code: u16, body: impl Into>) -> Self { + Self { + status_code, + content_type: "application/octet-stream", + body: body.into(), + } + } + } + + fn start_http_server( + expected_requests: usize, + build_routes: F, + ) -> (String, thread::JoinHandle<()>) + where + F: FnOnce(&str) -> HashMap, + { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind http listener"); + let base_url = format!("http://{}", listener.local_addr().expect("listener addr")); + let routes = build_routes(&base_url); + let handle = thread::spawn(move || { + for _ in 0..expected_requests { + let (mut stream, _) = listener.accept().expect("accept http request"); + let path = read_http_request_path(&mut stream); + let response = routes + .get(&path) + .unwrap_or_else(|| panic!("unexpected HTTP path {path}")); + let reason = match response.status_code { + 200 => "OK", + 404 => "Not Found", + _ => "Response", + }; + write!( + stream, + "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n", + response.status_code, + reason, + response.body.len(), + response.content_type, + ) + .expect("write HTTP headers"); + stream.write_all(&response.body).expect("write HTTP body"); + stream.flush().expect("flush HTTP response"); + } + }); + + (base_url, handle) + } + + fn read_http_request_path(stream: &mut impl Read) -> String { + let mut request = Vec::new(); + let mut buf = [0u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + let read = stream.read(&mut buf).expect("read HTTP request"); + if read == 0 { + break; + } + request.extend_from_slice(&buf[..read]); + } + + let request_line = String::from_utf8_lossy(&request); + request_line + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/") + .to_string() + } + + fn complete_imap_config(encryption: ImapEncryption) -> ImapConfig { + ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(encryption), + proxy: None, + } + } + #[test] fn fixture_client_fetches_incremental_messages() { let root = temp_dir("incremental"); @@ -2280,53 +2533,462 @@ mod tests { } #[test] - fn remote_client_accepts_complete_config() { - let client = RemoteImapClient::new(ImapConfig { - email: Some("me@example.com".to_string()), - user: Some("imap-user".to_string()), - pass: Some("imap-pass".to_string()), - server: Some("imap.example.com".to_string()), - server_port: Some(993), - encryption: Some(ImapEncryption::Tls), - proxy: None, - }); + fn fixture_client_requires_connect_and_reports_invalid_sources() { + let missing_root = temp_dir("fixture-missing-root"); + fs::remove_dir_all(&missing_root).expect("remove root"); + let mut client = FixtureImapClient::new(missing_root.clone(), 1); + let error = client.connect().expect_err("missing root should fail"); + assert!(error.to_string().contains("does not exist")); + + let file_root = temp_dir("fixture-file-root"); + let file_path = file_root.join("fixture.eml"); + fs::write(&file_path, "mail").expect("write fixture file"); + let mut file_client = FixtureImapClient::new(file_path.clone(), 1); + let error = file_client.connect().expect_err("file root should fail"); + assert!(error.to_string().contains("is not a directory")); + + let valid_root = temp_dir("fixture-disconnected"); + fs::write( + valid_root.join("1.eml"), + "Message-ID: \nSubject: disconnected\n\nbody\n", + ) + .expect("write fixture mail"); + let mut disconnected = FixtureImapClient::new(valid_root.clone(), 1); + let error = disconnected + .select_mailbox("inbox") + .expect_err("select without connect should fail"); + assert!(error.to_string().contains("client is not connected")); - assert!(client.is_ok()); + disconnected.connect().expect("connect fixture"); + fs::remove_dir_all(&valid_root).expect("remove fixture root after connect"); + let error = disconnected + .select_mailbox("inbox") + .expect_err("missing mailbox directory should fail"); + assert!(error.to_string().contains("mailbox directory")); + + let _ = fs::remove_dir_all(file_root); } #[test] - fn remote_client_rejects_incomplete_config() { - let error = RemoteImapClient::new(ImapConfig { - email: None, - user: Some("imap-user".to_string()), - pass: None, - server: Some("imap.example.com".to_string()), - server_port: Some(993), - encryption: Some(ImapEncryption::Tls), - proxy: None, - }) - .err() - .expect("incomplete config should fail"); + fn fixture_client_scans_subdirectories_deduplicates_uids_and_parses_flags() { + let root = temp_dir("fixture-subdir"); + let mailbox_dir = root.join("inbox"); + fs::create_dir_all(&mailbox_dir).expect("create mailbox dir"); + fs::write( + mailbox_dir.join("1-a.eml"), + "Message-ID: \nSubject: a\nX-Flags: Seen, Flagged\n\nbody\n", + ) + .expect("write first mail"); + thread::sleep(Duration::from_millis(5)); + fs::write( + mailbox_dir.join("1-b.eml"), + "Message-ID: \nSubject: b\nX-Flags: Answered,\n Flagged\n\nbody\n", + ) + .expect("write second mail"); + fs::write( + mailbox_dir.join("note.eml"), + "Message-ID: \nSubject: c\n\nbody\n", + ) + .expect("write third mail"); - assert!(error.to_string().contains("imap.pass")); - } + let mut client = FixtureImapClient::new(root.clone(), 77); + client.connect().expect("connect"); + let snapshot = client.select_mailbox("inbox").expect("select mailbox"); + assert_eq!(snapshot.uidvalidity, 77); + assert_eq!(snapshot.highest_uid, 3); + assert!(snapshot.highest_modseq.is_some()); - #[test] - fn parses_imap_fetch_metadata() { - let line = "* 2 FETCH (UID 2 FLAGS (\\Seen \\Answered) MODSEQ (20) BODY[] {123}"; - assert_eq!(parse_fetch_uid(line), Some(2)); - assert_eq!(parse_fetch_modseq(line), Some(20)); - assert_eq!(parse_literal_len(line), Some(123)); + let fetched = client + .fetch_incremental("inbox", 0, None) + .expect("fetch fixture mail"); assert_eq!( - parse_fetch_flags(line), - vec!["\\Seen".to_string(), "\\Answered".to_string()] + fetched.iter().map(|mail| mail.uid).collect::>(), + vec![1, 2, 3] ); - } - - #[test] - fn parses_select_status_codes() { assert_eq!( - parse_status_code_u64("* OK [UIDVALIDITY 77] UIDs valid", "UIDVALIDITY"), + fetched[0].flags, + vec!["Seen".to_string(), "Flagged".to_string()] + ); + assert_eq!( + fetched[1].flags, + vec!["Answered".to_string(), "Flagged".to_string()] + ); + + let incremental = client + .fetch_incremental("inbox", 1, None) + .expect("fetch after uid"); + assert_eq!( + incremental.iter().map(|mail| mail.uid).collect::>(), + vec![2, 3] + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn fixture_client_reports_invalid_uidvalidity_marker() { + let root = temp_dir("uidvalidity-invalid"); + fs::write(root.join(".uidvalidity"), "not-a-number\n").expect("write marker"); + fs::write( + root.join("1.eml"), + "Message-ID: \nSubject: x\n\nbody\n", + ) + .expect("write message"); + + let mut client = FixtureImapClient::new(root.clone(), 1); + client.connect().expect("connect"); + let error = client + .select_mailbox("inbox") + .expect_err("invalid UIDVALIDITY should fail"); + assert!(error.to_string().contains("invalid UIDVALIDITY value")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn blank_uidvalidity_marker_falls_back_to_default() { + let root = temp_dir("uidvalidity-blank"); + fs::write(root.join(".uidvalidity"), " \n").expect("write marker"); + fs::write( + root.join("1.eml"), + "Message-ID: \nSubject: x\n\nbody\n", + ) + .expect("write message"); + + let mut client = FixtureImapClient::new(root.clone(), 55); + client.connect().expect("connect"); + let snapshot = client.select_mailbox("inbox").expect("select mailbox"); + assert_eq!(snapshot.uidvalidity, 55); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn remote_client_accepts_complete_config() { + let client = RemoteImapClient::new(complete_imap_config(ImapEncryption::Tls)); + + assert!(client.is_ok()); + } + + #[test] + fn remote_client_rejects_incomplete_config() { + let error = RemoteImapClient::new(ImapConfig { + email: None, + user: Some("imap-user".to_string()), + pass: None, + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }) + .err() + .expect("incomplete config should fail"); + + assert!(error.to_string().contains("imap.pass")); + } + + #[test] + fn lore_client_selects_and_fetches_incremental_from_local_server() { + let first_modseq = parse_atom_timestamp("2026-03-03T09:00:00+00:00").expect("first ts"); + let second_modseq = parse_atom_timestamp("2026-03-03T10:00:00+00:00").expect("second ts"); + let feed = r#" + + + https://lore.kernel.org/io-uring/msg-a/ + 2026-03-03T09:00:00+00:00 + + + + https://lore.kernel.org/io-uring/msg-b/ + 2026-03-03T10:00:00+00:00 + + + +"#; + let base_url = "https://lore.test"; + let mut client = LoreImapClient::new(Some(base_url)).expect("create lore client"); + let error = client + .select_mailbox("io-uring") + .expect_err("select before connect should fail"); + assert!(error.to_string().contains("client is not connected")); + + client.connect().expect("connect lore"); + assert_eq!( + client.feed_url("io-uring"), + "https://lore.test/io-uring/new.atom" + ); + let entries = parse_lore_feed_response(&client.feed_url("io-uring"), 200, feed) + .expect("parse lore feed response"); + let snapshot = MailboxSnapshot { + uidvalidity: 1, + highest_uid: 0, + highest_modseq: entries.iter().map(|entry| entry.modseq).max(), + }; + assert_eq!(snapshot.uidvalidity, 1); + assert_eq!(snapshot.highest_uid, 0); + assert_eq!(snapshot.highest_modseq, Some(second_modseq)); + + let raw_by_url = HashMap::from([( + "https://lore.kernel.org/io-uring/msg-b/raw".to_string(), + b"Message-ID: \n\nbody\n".to_vec(), + )]); + let fetched = build_lore_incremental_mails(entries, Some(first_modseq), |message_url| { + fetch_lore_raw_with(message_url, |raw_url| { + Ok((200, raw_by_url.get(raw_url).cloned().unwrap_or_default())) + }) + }) + .expect("fetch incremental lore"); + assert_eq!(fetched.len(), 1); + assert_eq!(fetched[0].modseq, Some(second_modseq)); + assert!(String::from_utf8_lossy(&fetched[0].raw).contains("msg-b@example.com")); + } + + #[test] + fn lore_client_fetches_feed_and_raw_over_http() { + let first_modseq = parse_atom_timestamp("2026-03-03T09:00:00+00:00").expect("first ts"); + let second_modseq = parse_atom_timestamp("2026-03-03T10:00:00+00:00").expect("second ts"); + let (base_url, handle) = start_http_server(4, |base_url| { + let feed = format!( + r#" + + + {base_url}/io-uring/msg-a/ + 2026-03-03T09:00:00+00:00 + + + + {base_url}/io-uring/msg-b/ + 2026-03-03T10:00:00+00:00 + + + +"# + ); + HashMap::from([ + ( + "/io-uring/new.atom".to_string(), + StubHttpResponse::text(200, feed), + ), + ( + "/io-uring/msg-b/raw".to_string(), + StubHttpResponse::text(404, ""), + ), + ( + "/io-uring/msg-b/raw/".to_string(), + StubHttpResponse::bytes( + 200, + b"Message-ID: \nSubject: message b\n\nbody\n".to_vec(), + ), + ), + ]) + }); + + let mut client = LoreImapClient::new(Some(&base_url)).expect("create lore client"); + client.connect().expect("connect lore"); + + let snapshot = client + .select_mailbox("/io-uring/") + .expect("select lore mailbox"); + assert_eq!(snapshot.uidvalidity, 1); + assert_eq!(snapshot.highest_uid, 0); + assert_eq!(snapshot.highest_modseq, Some(second_modseq)); + + let fetched = client + .fetch_incremental("io-uring", 0, Some(first_modseq)) + .expect("fetch lore mails"); + assert_eq!(fetched.len(), 1); + assert_eq!(fetched[0].modseq, Some(second_modseq)); + assert!(String::from_utf8_lossy(&fetched[0].raw).contains("msg-b@example.com")); + + handle.join().expect("join HTTP server"); + } + + #[test] + fn lore_client_reports_feed_http_errors_from_server() { + let (base_url, handle) = start_http_server(1, |_| { + HashMap::from([( + "/broken/new.atom".to_string(), + StubHttpResponse::text(404, "missing mailbox"), + )]) + }); + + let mut client = LoreImapClient::new(Some(&base_url)).expect("create lore client"); + client.connect().expect("connect lore"); + + let error = client + .select_mailbox("broken") + .expect_err("HTTP errors should fail mailbox selection"); + assert!(error.to_string().contains("HTTP 404")); + + handle.join().expect("join HTTP server"); + } + + #[test] + fn lore_client_reports_empty_raw_message_after_trying_candidates() { + let error = fetch_lore_raw_with("https://lore.kernel.org/io-uring/msg-empty/", |_| { + Ok((200, Vec::new())) + }) + .expect_err("empty raw should fail"); + assert!(error.to_string().contains("lore raw message is empty")); + } + + #[test] + fn gnu_archive_client_selects_and_fetches_incremental_from_local_server() { + let feb_modseq = + parse_gnu_archive_listing_timestamp("2026-02-26", "09:12").expect("feb timestamp"); + let html = r#" +
+2026-02 2026-02-26 09:12  855K
+2026-03 2026-03-07 06:37  341K
+
+"#; + let mbox = b"From MAILER-DAEMON Tue Mar 03 04:39:31 2026\nMessage-ID: \nSubject: one\n\nbody\nFrom MAILER-DAEMON Tue Mar 03 04:40:31 2026\nMessage-ID: \nSubject: two\n\nbody two\n"; + let mut client = + GnuArchiveClient::new(Some("https://archive.test")).expect("create archive client"); + let error = client + .select_mailbox("mailbox") + .expect_err("select before connect should fail"); + assert!(error.to_string().contains("client is not connected")); + + client.connect().expect("connect archive"); + assert_eq!(client.index_url("mailbox"), "https://archive.test/mailbox/"); + assert_eq!( + client.month_url("mailbox", "2026-03"), + "https://archive.test/mailbox/2026-03" + ); + let months = parse_gnu_archive_index_response(&client.index_url("mailbox"), 200, html) + .expect("parse archive index"); + let snapshot = MailboxSnapshot { + uidvalidity: 1, + highest_uid: 0, + highest_modseq: months.iter().map(|entry| entry.modseq).max(), + }; + assert_eq!(snapshot.uidvalidity, 1); + assert_eq!(snapshot.highest_uid, 0); + assert!(snapshot.highest_modseq.is_some()); + + let fetched = build_gnu_archive_incremental_mails(&months, Some(feb_modseq), |month_key| { + let url = client.month_url("mailbox", month_key); + validate_gnu_archive_mbox_response(&url, 200, mbox.to_vec()) + }) + .expect("fetch gnu archive"); + assert_eq!(fetched.len(), 2); + assert_eq!(fetched[0].uid, 314_000_001); + assert_eq!(fetched[1].uid, 314_000_002); + } + + #[test] + fn gnu_archive_client_fetches_index_and_mbox_over_http() { + let feb_modseq = + parse_gnu_archive_listing_timestamp("2026-02-26", "09:12").expect("feb timestamp"); + let (base_url, handle) = start_http_server(3, |_| { + let html = r#" +
+2026-02 2026-02-26 09:12  855K
+2026-03 2026-03-07 06:37  341K
+
+"#; + let mbox = b"From MAILER-DAEMON Tue Mar 03 04:39:31 2026\nMessage-ID: \nSubject: one\n\nbody\nFrom MAILER-DAEMON Tue Mar 03 04:40:31 2026\nMessage-ID: \nSubject: two\n\nbody two\n"; + HashMap::from([ + ("/mailbox/".to_string(), StubHttpResponse::text(200, html)), + ( + "/mailbox/2026-03".to_string(), + StubHttpResponse::bytes(200, mbox.to_vec()), + ), + ]) + }); + + let mut client = GnuArchiveClient::new(Some(&base_url)).expect("create archive client"); + client.connect().expect("connect archive"); + + let snapshot = client + .select_mailbox("/mailbox/") + .expect("select archive mailbox"); + assert_eq!(snapshot.uidvalidity, 1); + assert_eq!(snapshot.highest_uid, 0); + assert!(snapshot.highest_modseq.is_some()); + + let fetched = client + .fetch_incremental("mailbox", 0, Some(feb_modseq)) + .expect("fetch archive month"); + assert_eq!( + fetched.iter().map(|mail| mail.uid).collect::>(), + vec![314_000_001, 314_000_002] + ); + + handle.join().expect("join HTTP server"); + } + + #[test] + fn gnu_archive_client_reports_mbox_http_errors_from_server() { + let feb_modseq = + parse_gnu_archive_listing_timestamp("2026-02-26", "09:12").expect("feb timestamp"); + let (base_url, handle) = start_http_server(2, |_| { + let html = r#" +
+2026-02 2026-02-26 09:12  855K
+2026-03 2026-03-07 06:37  341K
+
+"#; + HashMap::from([ + ("/mailbox/".to_string(), StubHttpResponse::text(200, html)), + ( + "/mailbox/2026-03".to_string(), + StubHttpResponse::text(404, "month not found"), + ), + ]) + }); + + let mut client = GnuArchiveClient::new(Some(&base_url)).expect("create archive client"); + client.connect().expect("connect archive"); + + let error = client + .fetch_incremental("mailbox", 0, Some(feb_modseq)) + .expect_err("HTTP errors should fail archive fetch"); + assert!(error.to_string().contains("HTTP 404")); + + handle.join().expect("join HTTP server"); + } + + #[test] + fn http_clients_surface_transport_errors_before_receiving_responses() { + let mut lore_client = + LoreImapClient::new(Some("http://127.0.0.1:1")).expect("create lore client"); + lore_client.connect().expect("connect lore"); + let lore_error = lore_client + .select_mailbox("io-uring") + .expect_err("missing listener should fail feed fetch"); + assert!(lore_error.to_string().contains("failed to fetch lore feed")); + + let mut archive_client = + GnuArchiveClient::new(Some("http://127.0.0.1:1")).expect("create archive client"); + archive_client.connect().expect("connect archive"); + let archive_error = archive_client + .select_mailbox("mailbox") + .expect_err("missing listener should fail archive fetch"); + assert!( + archive_error + .to_string() + .contains("failed to fetch GNU archive index") + ); + } + + #[test] + fn parses_imap_fetch_metadata() { + let line = "* 2 FETCH (UID 2 FLAGS (\\Seen \\Answered) MODSEQ (20) BODY[] {123}"; + assert_eq!(parse_fetch_uid(line), Some(2)); + assert_eq!(parse_fetch_modseq(line), Some(20)); + assert_eq!(parse_literal_len(line), Some(123)); + assert_eq!( + parse_fetch_flags(line), + vec!["\\Seen".to_string(), "\\Answered".to_string()] + ); + } + + #[test] + fn parses_select_status_codes() { + assert_eq!( + parse_status_code_u64("* OK [UIDVALIDITY 77] UIDs valid", "UIDVALIDITY"), Some(77) ); assert_eq!( @@ -2370,6 +3032,38 @@ mod tests { assert_eq!(format_uid_sequence_set(&[9, 7, 8, 8]), "7:9"); } + #[test] + fn imap_proxy_parser_rejects_invalid_proxy_urls() { + let proxy = parse_imap_proxy("http://127.0.0.1").expect("default http port"); + assert_eq!(proxy.redacted_url(), "http://127.0.0.1:80"); + let socks_proxy = parse_imap_proxy("socks5h://127.0.0.1").expect("default socks5 port"); + assert_eq!(socks_proxy.redacted_url(), "socks5://127.0.0.1:1080"); + + let unsupported = + parse_imap_proxy("https://127.0.0.1:443").expect_err("unsupported scheme should fail"); + assert!( + unsupported + .to_string() + .contains("unsupported IMAP proxy scheme") + ); + + let auth = parse_imap_proxy("http://user:pass@127.0.0.1:8080") + .expect_err("proxy auth should fail"); + assert!(auth.to_string().contains("authentication is not supported")); + + let path_error = + parse_imap_proxy("http://127.0.0.1:8080/proxy").expect_err("path should fail"); + assert!( + path_error + .to_string() + .contains("remove path, query, and fragment") + ); + + let missing_host = + parse_imap_proxy("http:///").expect_err("missing host should fail proxy parsing"); + assert!(missing_host.to_string().contains("invalid IMAP proxy URL")); + } + #[test] fn http_proxy_connect_tunnels_imap_socket() { let proxy = parse_imap_proxy("http://127.0.0.1:7890").expect("parse proxy"); @@ -2383,6 +3077,64 @@ mod tests { assert!(request_text.contains("\r\nHost: imap.gmail.com:993\r\n")); } + #[test] + fn http_proxy_helpers_report_truncated_and_rejected_responses() { + let proxy = parse_imap_proxy("http://127.0.0.1:7890").expect("parse proxy"); + + let mut truncated = MockStream::default(); + let error = read_http_proxy_response(&mut truncated, &proxy, "imap.gmail.com:993") + .expect_err("truncated response should fail"); + assert!( + error + .to_string() + .contains("closed the connection before CONNECT") + ); + + let mut rejected = MockStream::with_reads(b"HTTP/1.1 403 Forbidden\r\n\r\n"); + let error = establish_http_connect_tunnel(&mut rejected, &proxy, "imap.gmail.com", 993) + .expect_err("non-2xx connect should fail"); + assert!(error.to_string().contains("rejected CONNECT")); + + let oversized = vec![b'a'; super::HTTP_PROXY_RESPONSE_MAX_BYTES + 1]; + let mut oversized_stream = MockStream::with_reads(&oversized); + let error = read_http_proxy_response(&mut oversized_stream, &proxy, "imap.gmail.com:993") + .expect_err("oversized responses should fail"); + assert!( + error + .to_string() + .contains("sent too much HTTP response data") + ); + + let mut read_error_stream = ReadFailsStream; + let error = read_http_proxy_response(&mut read_error_stream, &proxy, "imap.gmail.com:993") + .expect_err("proxy read failures should surface"); + assert!( + error + .to_string() + .contains("failed while reading IMAP proxy") + ); + + let mut write_error_stream = WriteFailsStream; + let error = + establish_http_connect_tunnel(&mut write_error_stream, &proxy, "imap.gmail.com", 993) + .expect_err("write failures should surface"); + assert!( + error + .to_string() + .contains("failed to send IMAP CONNECT request") + ); + + let mut flush_error_stream = FlushFailsStream; + let error = + establish_http_connect_tunnel(&mut flush_error_stream, &proxy, "imap.gmail.com", 993) + .expect_err("flush failures should surface"); + assert!( + error + .to_string() + .contains("failed to flush IMAP CONNECT request") + ); + } + #[test] fn socks5_proxy_connect_tunnels_imap_socket() { let proxy = parse_imap_proxy("socks5://127.0.0.1:7890").expect("parse proxy"); @@ -2406,6 +3158,498 @@ mod tests { ); } + #[test] + fn socks5_proxy_reports_handshake_and_connect_failures() { + let proxy = parse_imap_proxy("socks5://127.0.0.1:7890").expect("parse proxy"); + + let mut invalid_version = MockStream::with_reads(&[0x04, 0x00]); + let error = establish_socks5_tunnel(&mut invalid_version, &proxy, "imap.gmail.com", 993) + .expect_err("invalid version should fail"); + assert!(error.to_string().contains("invalid SOCKS5 version")); + + let mut unauthenticated = MockStream::with_reads(&[0x05, 0x02]); + let error = establish_socks5_tunnel(&mut unauthenticated, &proxy, "imap.gmail.com", 993) + .expect_err("auth required should fail"); + assert!(error.to_string().contains("does not allow unauthenticated")); + + let mut connect_failed = MockStream::with_reads(&[0x05, 0x00, 0x05, 0x05, 0x00]); + let error = establish_socks5_tunnel(&mut connect_failed, &proxy, "imap.gmail.com", 993) + .expect_err("connect reply should fail"); + assert!(error.to_string().contains("connection refused")); + + let mut greeting_read_error = ReadFailsStream; + let error = + establish_socks5_tunnel(&mut greeting_read_error, &proxy, "imap.gmail.com", 993) + .expect_err("greeting read failures should surface"); + assert!( + error + .to_string() + .contains("failed to read SOCKS5 greeting reply") + ); + + let mut greeting_write_error = WriteFailsStream; + let error = + establish_socks5_tunnel(&mut greeting_write_error, &proxy, "imap.gmail.com", 993) + .expect_err("greeting write failures should surface"); + assert!(error.to_string().contains("failed to send SOCKS5 greeting")); + + let too_long_host = "a".repeat(256); + let mut host_length_stream = MockStream::with_reads(&[0x05, 0x00]); + let error = establish_socks5_tunnel(&mut host_length_stream, &proxy, &too_long_host, 993) + .expect_err("long hostnames should fail"); + assert!(error.to_string().contains("too long for SOCKS5 proxying")); + } + + #[test] + fn socks5_reply_address_reports_decode_failures() { + let proxy = parse_imap_proxy("socks5://127.0.0.1:7890").expect("parse proxy"); + + let mut atyp_error = ReadFailsStream; + let error = super::read_socks5_reply_address(&mut atyp_error, &proxy) + .expect_err("ATYP read failures should surface"); + assert!( + error + .to_string() + .contains("failed to read SOCKS5 reply type") + ); + + let mut domain_length_error = MockStream::with_reads(&[0x03]); + let error = super::read_socks5_reply_address(&mut domain_length_error, &proxy) + .expect_err("domain length failures should surface"); + assert!( + error + .to_string() + .contains("failed to read SOCKS5 domain length") + ); + + let mut bind_address_error = MockStream::with_reads(&[0x01]); + let error = super::read_socks5_reply_address(&mut bind_address_error, &proxy) + .expect_err("bind address failures should surface"); + assert!( + error + .to_string() + .contains("failed to read SOCKS5 bind address") + ); + } + + #[test] + fn imap_session_executes_command_flow_over_mock_transport() { + let responses = concat!( + "* OK hello\r\n", + "* CAPABILITY IMAP4rev1 CONDSTORE\r\n", + "A0001 OK capability\r\n", + "A0002 OK login\r\n", + "* OK [UIDVALIDITY 77] valid\r\n", + "* OK [UIDNEXT 42] next\r\n", + "* OK [HIGHESTMODSEQ 9001] modseq\r\n", + "A0003 OK select\r\n", + "* SEARCH 4 5 5 9\r\n", + "A0004 OK search\r\n", + "* 1 FETCH (UID 4 FLAGS (\\Seen \\Answered) MODSEQ (20) BODY.PEEK[] {5}\r\n", + "hello\r\n", + ")\r\n", + "A0005 OK fetch\r\n" + ); + let mut session = + ImapSession::with_mock_stream(MockStream::with_reads(responses.as_bytes())); + + assert_eq!( + session.read_greeting().expect("read greeting"), + GreetingKind::Ok + ); + let capabilities = session.fetch_capabilities().expect("fetch capabilities"); + assert!(capabilities.contains("IMAP4REV1")); + assert!(capabilities.contains("CONDSTORE")); + session.capabilities = capabilities; + + let config = ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }; + session.login(&config).expect("login"); + + let snapshot = session.select_mailbox("INBOX").expect("select"); + assert_eq!(snapshot.uidvalidity, 77); + assert_eq!(snapshot.highest_uid, 41); + assert_eq!(snapshot.highest_modseq, Some(9001)); + + let uids = session.search_uid_range(4).expect("search uids"); + assert_eq!(uids, vec![4, 5, 9]); + + let fetched = session.fetch_uids(&[4], "BODY.PEEK[]").expect("fetch uids"); + assert_eq!(fetched.len(), 1); + assert_eq!(fetched[0].uid, 4); + assert_eq!(fetched[0].modseq, Some(20)); + assert_eq!( + fetched[0].flags, + vec!["\\Seen".to_string(), "\\Answered".to_string()] + ); + assert_eq!(fetched[0].raw, b"hello".to_vec()); + } + + #[test] + fn imap_session_handles_greeting_login_and_fetch_failures() { + let mut bye_session = + ImapSession::with_mock_stream(MockStream::with_reads(b"* BYE go away\r\n")); + let error = bye_session + .read_greeting() + .expect_err("BYE greeting should fail"); + assert!( + error + .to_string() + .contains("server closed connection during greeting") + ); + + let mut plaintext_session = ImapSession::with_mock_stream(MockStream::default()); + plaintext_session + .capabilities + .insert("LOGINDISABLED".to_string()); + let config = ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(143), + encryption: Some(ImapEncryption::None), + proxy: None, + }; + let error = plaintext_session + .login(&config) + .expect_err("plaintext login should be rejected"); + assert!(error.to_string().contains("disallows LOGIN over plaintext")); + + let mut fetch_session = ImapSession::with_mock_stream(MockStream::with_reads( + b"* 1 FETCH (FLAGS (\\Seen) BODY.PEEK[] {5}\r\nhello\r\n)\r\nA0001 OK fetch\r\n", + )); + let error = fetch_session + .fetch_uid_chunk(&[4], "BODY.PEEK[]") + .expect_err("missing UID should fail"); + assert!(error.to_string().contains("missing UID in FETCH response")); + + let mut eof_session = ImapSession::with_mock_stream(MockStream::default()); + let error = eof_session + .read_line_string() + .expect_err("EOF while reading line should fail"); + assert!( + error + .to_string() + .contains("unexpected EOF while reading IMAP response") + ); + } + + #[test] + fn imap_session_covers_misc_defaults_and_protocol_edges() { + let mut preauth_session = + ImapSession::with_mock_stream(MockStream::with_reads(b"* PREAUTH welcome\r\n")); + assert_eq!( + preauth_session.read_greeting().expect("PREAUTH greeting"), + GreetingKind::Preauth + ); + + let mut unexpected_session = + ImapSession::with_mock_stream(MockStream::with_reads(b"* WHAT welcome\r\n")); + let error = unexpected_session + .read_greeting() + .expect_err("unexpected greeting should fail"); + assert!(error.to_string().contains("unexpected IMAP greeting")); + + let mut missing_user_session = ImapSession::with_mock_stream(MockStream::default()); + let error = missing_user_session + .login(&ImapConfig { + email: None, + user: None, + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }) + .expect_err("missing user should fail"); + assert!(error.to_string().contains("missing imap.user")); + + let mut missing_pass_session = ImapSession::with_mock_stream(MockStream::default()); + let error = missing_pass_session + .login(&ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: None, + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }) + .expect_err("missing pass should fail"); + assert!(error.to_string().contains("missing imap.pass")); + + let mut default_select = ImapSession::with_mock_stream(MockStream::with_reads( + b"* FLAGS (\\Seen)\r\nA0001 OK [READ-WRITE] select\r\n", + )); + let snapshot = default_select + .select_mailbox("INBOX") + .expect("select mailbox with defaults"); + assert_eq!(snapshot.uidvalidity, 1); + assert_eq!(snapshot.highest_uid, 0); + assert_eq!(snapshot.highest_modseq, None); + + let mut noisy_search = ImapSession::with_mock_stream(MockStream::with_reads( + b"* OK noop\r\n* SEARCH 7 nope 9\r\nA0001 OK search\r\n", + )); + let uids = noisy_search.search_uid_range(7).expect("search with noise"); + assert_eq!(uids, vec![7, 9]); + + let mut empty_fetches = ImapSession::with_mock_stream(MockStream::default()); + assert!( + empty_fetches + .fetch_uids(&[], "BODY.PEEK[]") + .expect("empty uid list") + .is_empty() + ); + assert!( + empty_fetches + .fetch_uid_chunk(&[], "BODY.PEEK[]") + .expect("empty uid chunk") + .is_empty() + ); + } + + #[test] + fn imap_session_reports_fetch_trailer_and_io_failures() { + let mut missing_literal = ImapSession::with_mock_stream(MockStream::with_reads( + b"* 1 FETCH (UID 4 FLAGS (\\Seen) BODY.PEEK[]\r\nA0001 OK fetch\r\n", + )); + let error = missing_literal + .fetch_uid_chunk(&[4], "BODY.PEEK[]") + .expect_err("missing literal should fail"); + assert!(error.to_string().contains("missing literal size")); + + let mut truncated_trailer = ImapSession::with_mock_stream(MockStream::with_reads( + b"* 1 FETCH (UID 4 FLAGS (\\Seen) MODSEQ (20) BODY.PEEK[] {5}\r\nhello\r\nA0001 OK fetch\r\n", + )); + let error = truncated_trailer + .fetch_uid_chunk(&[4], "BODY.PEEK[]") + .expect_err("truncated trailer should fail"); + assert!(error.to_string().contains("truncated FETCH trailer")); + + let mut write_error_session = ImapSession::with_mock_stream(WriteFailsStream); + let error = write_error_session + .write_command("A0001", "NOOP") + .expect_err("write failures should surface"); + assert!(error.to_string().contains("failed to write IMAP command")); + + let mut flush_error_session = ImapSession::with_mock_stream(FlushFailsStream); + let error = flush_error_session + .write_command("A0001", "NOOP") + .expect_err("flush failures should surface"); + assert!(error.to_string().contains("failed to flush IMAP command")); + + let mut read_line_error_session = ImapSession::with_mock_stream(ReadFailsStream); + let error = read_line_error_session + .read_line_bytes() + .expect_err("socket read failures should surface"); + assert!( + error + .to_string() + .contains("failed to read from IMAP socket") + ); + + let mut read_literal_error_session = ImapSession::with_mock_stream(ReadFailsStream); + let error = read_literal_error_session + .read_exact_bytes(4) + .expect_err("literal read failures should surface"); + assert!(error.to_string().contains("failed to read IMAP literal")); + } + + #[test] + fn collect_incremental_uids_merges_uid_and_modseq_search_results() { + let responses = concat!( + "* SEARCH 7 8\r\n", + "A0001 OK search\r\n", + "* SEARCH 8 9\r\n", + "A0002 OK search\r\n" + ); + let mut session = + ImapSession::with_mock_stream(MockStream::with_reads(responses.as_bytes())); + let snapshot = MailboxSnapshot { + uidvalidity: 1, + highest_uid: 9, + highest_modseq: Some(30), + }; + + let uids = collect_incremental_uids(&mut session, snapshot, 6, Some(20)) + .expect("collect incremental uids"); + assert_eq!(uids, vec![7, 8, 9]); + } + + #[test] + fn imap_session_connect_rejects_missing_runtime_fields() { + let missing_server = ImapSession::connect(&ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: None, + server_port: Some(993), + encryption: Some(ImapEncryption::Tls), + proxy: None, + }) + .err() + .expect("missing server should fail"); + assert!(missing_server.to_string().contains("missing imap.server")); + + let missing_port = ImapSession::connect(&ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: None, + encryption: Some(ImapEncryption::Tls), + proxy: None, + }) + .err() + .expect("missing port should fail"); + assert!(missing_port.to_string().contains("missing imap.serverport")); + + let missing_encryption = ImapSession::connect(&ImapConfig { + email: Some("me@example.com".to_string()), + user: Some("imap-user".to_string()), + pass: Some("imap-pass".to_string()), + server: Some("imap.example.com".to_string()), + server_port: Some(993), + encryption: None, + proxy: None, + }) + .err() + .expect("missing encryption should fail"); + assert!( + missing_encryption + .to_string() + .contains("missing imap.encryption") + ); + } + + #[test] + fn remote_client_requires_connected_session_and_delegates_requests() { + let config = complete_imap_config(ImapEncryption::Tls); + + let mut disconnected = + RemoteImapClient::new(config.clone()).expect("create disconnected remote client"); + let error = disconnected + .select_mailbox("INBOX") + .expect_err("missing session should fail"); + assert!( + error + .to_string() + .contains("remote IMAP session is not connected") + ); + + let mut select_client = RemoteImapClient { + config: config.clone(), + session: Some(ImapSession::with_mock_stream(MockStream::with_reads( + concat!( + "* OK [UIDVALIDITY 11] valid\r\n", + "* OK [UIDNEXT 4] next\r\n", + "* OK [HIGHESTMODSEQ 22] modseq\r\n", + "A0001 OK select\r\n" + ) + .as_bytes(), + ))), + }; + let snapshot = select_client + .select_mailbox("INBOX") + .expect("delegate mailbox selection"); + assert_eq!(snapshot.uidvalidity, 11); + assert_eq!(snapshot.highest_uid, 3); + assert_eq!(snapshot.highest_modseq, Some(22)); + + let mut incremental_client = RemoteImapClient { + config: config.clone(), + session: Some(ImapSession::with_mock_stream(MockStream::with_reads( + concat!( + "* OK [UIDVALIDITY 11] valid\r\n", + "* OK [UIDNEXT 4] next\r\n", + "* OK [HIGHESTMODSEQ 22] modseq\r\n", + "A0001 OK select\r\n", + "* SEARCH 2 3\r\n", + "A0002 OK search\r\n", + "* SEARCH 3\r\n", + "A0003 OK search\r\n", + "* 1 FETCH (UID 2 FLAGS (\\Seen) MODSEQ (21) BODY.PEEK[] {5}\r\n", + "hello\r\n", + ")\r\n", + "* 2 FETCH (UID 3 FLAGS (\\Seen) MODSEQ (22) BODY.PEEK[] {5}\r\n", + "world\r\n", + ")\r\n", + "A0004 OK fetch\r\n" + ) + .as_bytes(), + ))), + }; + let fetched = incremental_client + .fetch_incremental("INBOX", 1, Some(20)) + .expect("delegate incremental fetch"); + assert_eq!( + fetched.iter().map(|mail| mail.uid).collect::>(), + vec![2, 3] + ); + + let mut header_client = RemoteImapClient { + config: config.clone(), + session: Some(ImapSession::with_mock_stream(MockStream::with_reads( + concat!( + "* OK [UIDVALIDITY 11] valid\r\n", + "* OK [UIDNEXT 8] next\r\n", + "* OK [HIGHESTMODSEQ 22] modseq\r\n", + "A0001 OK select\r\n", + "* SEARCH 7\r\n", + "A0002 OK search\r\n", + "* 1 FETCH (UID 7 FLAGS (\\Seen) MODSEQ (22) BODY.PEEK[HEADER.FIELDS (MESSAGE-ID SUBJECT FROM DATE IN-REPLY-TO REFERENCES LIST-ID)] {5}\r\n", + "headr\r\n", + ")\r\n", + "A0003 OK fetch\r\n" + ) + .as_bytes(), + ))), + }; + let header_candidates = header_client + .fetch_header_candidates("INBOX", 6, None) + .expect("delegate header fetch"); + assert_eq!( + header_candidates + .iter() + .map(|mail| mail.uid) + .collect::>(), + vec![7] + ); + + let mut full_uid_client = RemoteImapClient { + config, + session: Some(ImapSession::with_mock_stream(MockStream::with_reads( + concat!( + "* OK [UIDVALIDITY 11] valid\r\n", + "* OK [UIDNEXT 10] next\r\n", + "* OK [HIGHESTMODSEQ 22] modseq\r\n", + "A0001 OK select\r\n", + "* 1 FETCH (UID 9 FLAGS (\\Seen) MODSEQ (22) BODY.PEEK[] {4}\r\n", + "full\r\n", + ")\r\n", + "A0002 OK fetch\r\n" + ) + .as_bytes(), + ))), + }; + let full = full_uid_client + .fetch_full_uids("INBOX", &[9]) + .expect("delegate full UID fetch"); + assert_eq!( + full.iter().map(|mail| mail.uid).collect::>(), + vec![9] + ); + } + #[test] fn parses_lore_atom_entries() { let xml = r#" @@ -2448,6 +3692,32 @@ mod tests { assert!(ts > 0); } + #[test] + fn parsing_helpers_cover_url_normalization_dates_and_flags() { + assert_eq!(quote_imap_string(r#"a"b\c"#), r#""a\"b\\c""#); + assert_eq!( + normalize_lore_message_url("https://lore.kernel.org/io-uring/msg/#fragment?query=1") + .as_deref(), + Some("https://lore.kernel.org/io-uring/msg/") + ); + assert_eq!(normalize_lore_message_url("not-a-url"), None); + + assert_eq!(parse_year_month_key("2026-03"), Some((2026, 3))); + assert_eq!(parse_year_month_key("2026-13"), None); + assert!(parse_gnu_archive_listing_timestamp("2026-03-07", "06:37").is_some()); + + assert_eq!( + parse_flags( + b"Message-ID: \r\nX-Flags: Seen,\r\n Flagged Answered\r\n\r\nbody\r\n" + ), + vec![ + "Seen".to_string(), + "Flagged".to_string(), + "Answered".to_string() + ] + ); + } + #[test] fn parses_gnu_archive_month_entries() { let html = r#" diff --git a/src/infra/mail_store.rs b/src/infra/mail_store.rs index 1a022a2..fc8545b 100644 --- a/src/infra/mail_store.rs +++ b/src/infra/mail_store.rs @@ -1175,7 +1175,7 @@ mod tests { use super::{ IncomingMail, SyncBatch, apply_sync_batch, load_mailbox_state, load_thread_rows_by_mailbox, - prune_mailbox_subjects, + mailbox_message_count, prune_mailbox_subjects, rebuild_all_threads, }; fn temp_dir(label: &str) -> PathBuf { @@ -1200,6 +1200,275 @@ mod tests { } } + fn incoming_at_path(mailbox: &str, uid: u32, raw_path: PathBuf, raw: &str) -> IncomingMail { + let fallback_id = format!("synthetic-{mailbox}-{uid}@local"); + IncomingMail { + mailbox: mailbox.to_string(), + uid, + modseq: Some(uid as u64), + flags: vec!["Seen".to_string()], + raw_path, + parsed: mail_parser::parse_headers(raw.as_bytes(), fallback_id), + } + } + + fn empty_batch(mailbox: &str) -> SyncBatch { + SyncBatch { + mailbox: mailbox.to_string(), + uidvalidity: 1, + highest_uid: 0, + highest_modseq: None, + mails: Vec::new(), + } + } + + #[test] + fn mailbox_state_starts_empty_and_message_count_skips_expunged_rows() { + let root = temp_dir("mailbox-state"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let state = load_mailbox_state(&db_path, "inbox").expect("load initial state"); + assert!(state.is_none()); + + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 2, + highest_modseq: Some(2), + mails: vec![ + incoming( + "inbox", + 1, + "Message-ID: \nSubject: keep\nFrom: keep@example.com\n\nbody\n", + ), + incoming( + "inbox", + 2, + "Message-ID: \nSubject: drop\nFrom: drop@example.com\n\nbody\n", + ), + ], + }; + apply_sync_batch(&db_path, batch).expect("seed mailbox"); + + let connection = Connection::open(&db_path).expect("open db"); + connection + .execute( + "UPDATE mail SET is_expunged = 1 WHERE message_id = 'drop@example.com'", + [], + ) + .expect("mark expunged"); + drop(connection); + + let count = mailbox_message_count(&db_path, "inbox").expect("count active mail"); + assert_eq!(count, 1); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn empty_sync_batch_preserves_checkpoint_and_skips_thread_rebuild() { + let root = temp_dir("empty-batch"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let first_batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 5, + highest_modseq: Some(10), + mails: vec![incoming( + "inbox", + 5, + "Message-ID: \nSubject: seed\nFrom: seed@example.com\n\nbody\n", + )], + }; + apply_sync_batch(&db_path, first_batch).expect("first sync"); + + let second_batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 3, + highest_modseq: Some(8), + mails: Vec::new(), + }; + let result = apply_sync_batch(&db_path, second_batch).expect("empty sync"); + + assert_eq!(result.inserted, 0); + assert_eq!(result.updated, 0); + assert_eq!(result.rebuilt_roots, 0); + assert!(!result.mailbox_rebuilt); + assert_eq!(result.state.last_seen_uid, 5); + assert_eq!(result.state.highest_modseq, Some(10)); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn storage_functions_report_clear_errors_for_uninitialized_database() { + let root = temp_dir("uninitialized"); + let db_path = root.join("criew.db"); + Connection::open(&db_path).expect("create raw sqlite database"); + + let load_state_error = load_mailbox_state(&db_path, "inbox") + .expect_err("raw database should reject state lookup"); + assert!( + load_state_error + .to_string() + .contains("failed to load mailbox checkpoint") + ); + + let count_error = mailbox_message_count(&db_path, "inbox") + .expect_err("raw database should reject message counts"); + assert!(count_error.to_string().contains("failed to count mails")); + + let prune_error = prune_mailbox_subjects(&db_path, "inbox", |_| true) + .expect_err("raw database should reject prune queries"); + assert!( + prune_error + .to_string() + .contains("failed to prepare mailbox prune query") + ); + + let sync_error = apply_sync_batch(&db_path, empty_batch("inbox")) + .expect_err("raw database should reject sync writes"); + assert!( + sync_error + .to_string() + .contains("failed to load mailbox checkpoint") + ); + + let rows_error = load_thread_rows_by_mailbox(&db_path, "inbox", 10) + .expect_err("raw database should reject thread row loads"); + assert!( + rows_error + .to_string() + .contains("mailbox-specific thread query") + ); + + let rebuild_error = + rebuild_all_threads(&db_path).expect_err("raw database should reject thread rebuilds"); + assert!( + rebuild_error + .to_string() + .contains("failed to prepare mail graph query") + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn checkpoint_handles_missing_modseq_and_rejects_uidvalidity_overflow() { + let root = temp_dir("checkpoint-modseq"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let mut first_mail = incoming( + "inbox", + 1, + "Message-ID: \nSubject: root\nFrom: root@example.com\n\nbody\n", + ); + first_mail.modseq = None; + let first = apply_sync_batch( + &db_path, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: None, + mails: vec![first_mail], + }, + ) + .expect("persist checkpoint without modseq"); + assert_eq!(first.state.highest_modseq, None); + + let second = apply_sync_batch( + &db_path, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(7), + mails: Vec::new(), + }, + ) + .expect("adopt first modseq checkpoint"); + assert_eq!(second.state.highest_modseq, Some(7)); + + let third = apply_sync_batch( + &db_path, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: None, + mails: Vec::new(), + }, + ) + .expect("preserve previous modseq checkpoint"); + assert_eq!(third.state.highest_modseq, Some(7)); + + let overflow = apply_sync_batch( + &db_path, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: u64::MAX, + highest_uid: 1, + highest_modseq: Some(8), + mails: Vec::new(), + }, + ) + .expect_err("overflowing uidvalidity should fail"); + assert!(overflow.to_string().contains("overflows i64 sqlite field")); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn apply_sync_batch_reports_missing_checkpoint_after_trigger_removes_it() { + let root = temp_dir("checkpoint-trigger"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let connection = Connection::open(&db_path).expect("open db"); + connection + .execute( + " +CREATE TRIGGER delete_mailbox_checkpoint +AFTER INSERT ON imap_mailbox_state +BEGIN + DELETE FROM imap_mailbox_state WHERE mailbox = NEW.mailbox; +END +", + [], + ) + .expect("create checkpoint trigger"); + drop(connection); + + let error = apply_sync_batch( + &db_path, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming( + "inbox", + 1, + "Message-ID: \nSubject: root\nFrom: root@example.com\n\nbody\n", + )], + }, + ) + .expect_err("missing checkpoint row should fail"); + assert!( + error + .to_string() + .contains("missing mailbox checkpoint after update") + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn repeated_sync_is_idempotent() { let root = temp_dir("idempotent"); @@ -1387,6 +1656,106 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn prune_mailbox_subjects_noop_keeps_rows_unchanged() { + let root = temp_dir("prune-noop"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming( + "inbox", + 1, + "Message-ID: \nSubject: [PATCH] keep\nFrom: keep@example.com\n\nbody\n", + )], + }; + apply_sync_batch(&db_path, batch).expect("seed mailbox"); + + let pruned = prune_mailbox_subjects(&db_path, "inbox", |_| true).expect("noop prune"); + assert_eq!(pruned, 0); + assert_eq!( + mailbox_message_count(&db_path, "inbox").expect("count mails"), + 1 + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prune_mailbox_subjects_tolerates_missing_raw_files() { + let root = temp_dir("prune-missing-raw"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let raw_path = root.join("status.eml"); + fs::write(&raw_path, "raw mail").expect("write raw mail"); + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming_at_path( + "inbox", + 1, + raw_path.clone(), + "Message-ID: \nSubject: status\nFrom: status@example.com\n\nbody\n", + )], + }; + apply_sync_batch(&db_path, batch).expect("seed mailbox"); + fs::remove_file(&raw_path).expect("remove raw mail before prune"); + + let pruned = + prune_mailbox_subjects(&db_path, "inbox", |_| false).expect("prune missing raw file"); + assert_eq!(pruned, 1); + assert_eq!( + mailbox_message_count(&db_path, "inbox").expect("count remaining mail"), + 0 + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn prune_mailbox_subjects_keeps_database_progress_when_raw_cleanup_fails() { + let root = temp_dir("prune-raw-error"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let raw_dir = root.join("raw-dir"); + fs::create_dir_all(&raw_dir).expect("create raw directory"); + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming_at_path( + "inbox", + 1, + raw_dir.clone(), + "Message-ID: \nSubject: status\nFrom: status@example.com\n\nbody\n", + )], + }; + apply_sync_batch(&db_path, batch).expect("seed mailbox"); + + let pruned = prune_mailbox_subjects(&db_path, "inbox", |_| false) + .expect("cleanup failures should not roll back prune"); + assert_eq!(pruned, 1); + assert_eq!( + mailbox_message_count(&db_path, "inbox").expect("count remaining mail"), + 0 + ); + assert!( + raw_dir.exists(), + "raw cleanup failure should leave directory intact" + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn threading_prefers_references_then_in_reply_to() { let root = temp_dir("threading"); @@ -1566,4 +1935,247 @@ WHERE m.message_id = 'grand@example.com' let _ = fs::remove_dir_all(root); } + + #[test] + fn rebuild_all_threads_restores_materialized_rows_after_manual_deletion() { + let root = temp_dir("rebuild-all"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 2, + highest_modseq: Some(2), + mails: vec![ + incoming( + "inbox", + 1, + "Message-ID: \nSubject: root\nFrom: root@example.com\n\nbody\n", + ), + incoming( + "inbox", + 2, + "Message-ID: \nSubject: Re: root\nFrom: reply@example.com\nIn-Reply-To: \nReferences: \n\nbody\n", + ), + ], + }; + apply_sync_batch(&db_path, batch).expect("seed mailbox"); + + let connection = Connection::open(&db_path).expect("open db"); + connection + .execute("DELETE FROM thread_node", []) + .expect("clear thread nodes"); + connection + .execute("DELETE FROM thread", []) + .expect("clear threads"); + drop(connection); + + let rebuilt = rebuild_all_threads(&db_path).expect("rebuild threads"); + assert_eq!(rebuilt, 1); + + let rows = load_thread_rows_by_mailbox(&db_path, "inbox", 20).expect("load rebuilt rows"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].message_id, "root@example.com"); + assert_eq!(rows[1].message_id, "reply@example.com"); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn cyclic_references_degrade_to_a_stable_thread_shape() { + let root = temp_dir("cycle"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 2, + highest_modseq: Some(2), + mails: vec![ + incoming( + "inbox", + 1, + "Message-ID: \nSubject: loop a\nFrom: a@example.com\nIn-Reply-To: \nReferences: \n\nbody\n", + ), + incoming( + "inbox", + 2, + "Message-ID: \nSubject: loop b\nFrom: b@example.com\nIn-Reply-To: \nReferences: \n\nbody\n", + ), + ], + }; + apply_sync_batch(&db_path, batch).expect("sync cyclic thread"); + + let rows = load_thread_rows_by_mailbox(&db_path, "inbox", 20).expect("load thread rows"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].depth, 0); + assert_eq!(rows[1].depth, 1); + assert_eq!(rows[0].thread_id, rows[1].thread_id); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rebuild_all_threads_is_noop_for_an_empty_store() { + let root = temp_dir("rebuild-empty"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let rebuilt = rebuild_all_threads(&db_path).expect("rebuild empty store"); + assert_eq!(rebuilt, 0); + assert!( + load_thread_rows_by_mailbox(&db_path, "inbox", 10) + .expect("load empty thread rows") + .is_empty() + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn rebuild_all_threads_reports_corrupted_mail_ref_and_thread_node_tables() { + let root = temp_dir("rebuild-corruption"); + + let missing_ref_db = root.join("missing-ref.db"); + db::initialize(&missing_ref_db).expect("initialize missing-ref db"); + apply_sync_batch( + &missing_ref_db, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming( + "inbox", + 1, + "Message-ID: \nSubject: root\nFrom: root@example.com\n\nbody\n", + )], + }, + ) + .expect("seed missing-ref db"); + let connection = Connection::open(&missing_ref_db).expect("open missing-ref db"); + connection + .execute("DROP TABLE mail_ref", []) + .expect("drop mail_ref"); + drop(connection); + + let ref_error = rebuild_all_threads(&missing_ref_db) + .expect_err("missing mail_ref table should fail rebuild"); + assert!( + ref_error + .to_string() + .contains("failed to prepare mail_ref graph query") + ); + + let missing_thread_node_db = root.join("missing-thread-node.db"); + db::initialize(&missing_thread_node_db).expect("initialize missing-thread-node db"); + apply_sync_batch( + &missing_thread_node_db, + SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 1, + highest_modseq: Some(1), + mails: vec![incoming( + "inbox", + 1, + "Message-ID: \nSubject: root2\nFrom: root2@example.com\n\nbody\n", + )], + }, + ) + .expect("seed missing-thread-node db"); + let connection = + Connection::open(&missing_thread_node_db).expect("open missing-thread-node db"); + connection + .execute("DROP TABLE thread_node", []) + .expect("drop thread_node"); + drop(connection); + + let thread_node_error = rebuild_all_threads(&missing_thread_node_db) + .expect_err("missing thread_node table should fail rebuild"); + assert!( + thread_node_error + .to_string() + .contains("failed to clear thread_node") + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn late_arriving_parent_rethreads_existing_descendants() { + let root = temp_dir("late-parent"); + let db_path = root.join("criew.db"); + db::initialize(&db_path).expect("initialize db"); + + let orphan_batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 2, + highest_modseq: Some(2), + mails: vec![ + incoming( + "inbox", + 1, + "Message-ID: \nSubject: Re: root\nFrom: child@example.com\nIn-Reply-To: \nReferences: \n\nbody\n", + ), + incoming( + "inbox", + 2, + "Message-ID: \nSubject: Re: root\nFrom: grand@example.com\nIn-Reply-To: \nReferences: \n\nbody\n", + ), + ], + }; + apply_sync_batch(&db_path, orphan_batch).expect("sync orphaned descendants"); + + let orphan_rows = + load_thread_rows_by_mailbox(&db_path, "inbox", 20).expect("load orphaned rows"); + assert_eq!( + orphan_rows + .iter() + .map(|row| (row.message_id.as_str(), row.depth)) + .collect::>(), + vec![("child@example.com", 0), ("grand@example.com", 1)] + ); + + let parent_batch = SyncBatch { + mailbox: "inbox".to_string(), + uidvalidity: 1, + highest_uid: 3, + highest_modseq: Some(3), + mails: vec![incoming( + "inbox", + 3, + "Message-ID: \nSubject: root\nFrom: root@example.com\n\nbody\n", + )], + }; + let result = apply_sync_batch(&db_path, parent_batch).expect("sync late parent"); + assert_eq!(result.inserted, 1); + assert_eq!(result.updated, 0); + assert_eq!(result.rebuilt_roots, 1); + assert!(!result.mailbox_rebuilt); + + let rethreaded_rows = + load_thread_rows_by_mailbox(&db_path, "inbox", 20).expect("load rethreaded rows"); + assert_eq!( + rethreaded_rows + .iter() + .map(|row| (row.message_id.as_str(), row.depth)) + .collect::>(), + vec![ + ("root@example.com", 0), + ("child@example.com", 1), + ("grand@example.com", 2), + ] + ); + assert!( + rethreaded_rows + .windows(2) + .all(|rows| rows[0].thread_id == rows[1].thread_id) + ); + + let _ = fs::remove_dir_all(root); + } } diff --git a/src/infra/sendmail.rs b/src/infra/sendmail.rs index ce2229e..76e8a8d 100644 --- a/src/infra/sendmail.rs +++ b/src/infra/sendmail.rs @@ -15,6 +15,8 @@ use chrono::Utc; use crate::infra::config::RuntimeConfig; const DEFAULT_SEND_TIMEOUT: Duration = Duration::from_secs(60); +const EXECUTABLE_BUSY_RETRY_ATTEMPTS: u8 = 5; +const EXECUTABLE_BUSY_RETRY_DELAY: Duration = Duration::from_millis(10); const OUTBOX_DIR_NAME: &str = "reply-outbox"; const GIT_SENDEMAIL_FROM_ARGS: &[&str] = &["config", "sendemail.from"]; const GIT_USER_NAME_LOOKUP_ARGS: &[&str] = &["config", "user.name"]; @@ -180,6 +182,15 @@ fn send_with_command_path( runtime: &RuntimeConfig, request: &SendRequest, command_path: Option<&Path>, +) -> SendOutcome { + send_with_options(runtime, request, command_path, DEFAULT_SEND_TIMEOUT) +} + +fn send_with_options( + runtime: &RuntimeConfig, + request: &SendRequest, + command_path: Option<&Path>, + timeout: Duration, ) -> SendOutcome { let started_at = now_timestamp(); let message_id = generate_message_id(&request.from); @@ -250,7 +261,7 @@ fn send_with_command_path( // surface as structured outcomes the UI can record and display. .env("GIT_TERMINAL_PROMPT", "0"); - let mut child = match command.spawn() { + let mut child = match spawn_command_with_retry(&mut command) { Ok(child) => child, Err(error) => { return failed_outcome( @@ -269,7 +280,7 @@ fn send_with_command_path( match child.try_wait() { Ok(Some(_)) => break, Ok(None) => { - if start.elapsed() >= DEFAULT_SEND_TIMEOUT { + if start.elapsed() >= timeout { timed_out = true; let _ = child.kill(); break; @@ -317,7 +328,7 @@ fn send_with_command_path( stderr, error_summary: Some(format!( "git send-email timed out after {}s", - DEFAULT_SEND_TIMEOUT.as_secs() + timeout.as_secs() )), started_at, finished_at, @@ -361,6 +372,21 @@ fn send_with_command_path( } } +fn spawn_command_with_retry(command: &mut Command) -> std::io::Result { + let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS; + + loop { + match command.spawn() { + Ok(child) => return Ok(child), + Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => { + attempts_remaining -= 1; + thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY); + } + Err(error) => return Err(error), + } + } +} + #[derive(Debug, Clone)] struct ParsedIdentity { display: String, @@ -438,7 +464,7 @@ fn run_probe(command: T, args: &[&str], display_path: &Path, command_text: St where T: AsRef, { - match Command::new(command).args(args).output() { + match output_with_retry(Command::new(command).args(args)) { Ok(output) if output.status.success() => Probe::Available { path: display_path.to_path_buf(), version: normalize_output(&output.stdout) @@ -464,7 +490,7 @@ fn run_send_email_probe(command: T, display_path: &Path, command_text: String where T: AsRef + Copy, { - match Command::new(command).args(["send-email", "-h"]).output() { + match output_with_retry(Command::new(command).args(["send-email", "-h"])) { Ok(output) => { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -509,7 +535,7 @@ fn probe_git_version(command: T) -> Option where T: AsRef, { - let output = Command::new(command).arg("--version").output().ok()?; + let output = output_with_retry(Command::new(command).arg("--version")).ok()?; if !output.status.success() { return None; } @@ -577,9 +603,7 @@ fn resolve_git_binary( } fn git_config_value(command: &str, args: &[&str]) -> std::result::Result, String> { - let output = Command::new(command) - .args(args) - .output() + let output = output_with_retry(Command::new(command).args(args)) .map_err(|error| format!("failed to run git {}: {error}", args.join(" ")))?; if !output.status.success() { @@ -598,6 +622,25 @@ fn git_config_value(command: &str, args: &[&str]) -> std::result::Result std::io::Result { + let mut attempts_remaining = EXECUTABLE_BUSY_RETRY_ATTEMPTS; + + loop { + match command.output() { + Ok(output) => return Ok(output), + Err(error) if is_retryable_executable_busy(&error) && attempts_remaining > 0 => { + attempts_remaining -= 1; + thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY); + } + Err(error) => return Err(error), + } + } +} + +fn is_retryable_executable_busy(error: &std::io::Error) -> bool { + error.kind() == std::io::ErrorKind::ExecutableFileBusy +} + fn parse_identity(value: &str) -> Option { let display = normalize_header_value(value); let email = extract_email_address(&display)?; @@ -632,10 +675,7 @@ fn extract_email_address(value: &str) -> Option { fn normalize_message_id(value: &str) -> String { value .trim() - .trim_matches('<') - .trim_matches('>') - .trim_matches('"') - .trim_matches(',') + .trim_matches(|character| matches!(character, '<' | '>' | '"' | ',')) .trim() .to_string() } @@ -793,6 +833,7 @@ fn now_timestamp() -> String { #[cfg(test)] mod tests { use std::fs; + use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -800,8 +841,10 @@ mod tests { use crate::infra::config::RuntimeConfig; use super::{ - GitSendEmailStatus, SendRequest, SendStatus, check_with_command_path, - resolve_reply_identity_with_command_path, send_with_command_path, + GitSendEmailStatus, ReplyIdentitySource, SendRequest, SendStatus, check_with_command_path, + extract_email_address, generate_message_id, normalize_message_id, normalize_output, + render_command_line, render_message_file, resolve_reply_identity_with_command_path, + resolve_working_dir, send_with_command_path, send_with_options, summarize_failure, }; fn temp_dir(label: &str) -> PathBuf { @@ -809,7 +852,10 @@ mod tests { .duration_since(UNIX_EPOCH) .expect("system time") .as_nanos(); - let path = std::env::temp_dir().join(format!("criew-sendmail-{label}-{nonce}")); + let path = std::env::temp_dir().join(format!( + "criew-sendmail-{label}-{}-{nonce}", + std::process::id() + )); fs::create_dir_all(&path).expect("create temp dir"); path } @@ -837,10 +883,19 @@ mod tests { fn write_fake_git(root: &Path, body: &str) -> PathBuf { let path = root.join("fake-git.sh"); - fs::write(&path, body).expect("write fake git"); - let mut permissions = fs::metadata(&path).expect("metadata").permissions(); + let staging_path = root.join(".fake-git.sh.tmp"); + let mut staging_file = fs::File::create(&staging_path).expect("create staging fake git"); + staging_file + .write_all(body.as_bytes()) + .expect("write staging fake git"); + staging_file.sync_all().expect("sync staging fake git"); + drop(staging_file); + let mut permissions = fs::metadata(&staging_path) + .expect("staging metadata") + .permissions(); permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).expect("chmod"); + fs::set_permissions(&staging_path, permissions).expect("chmod"); + fs::rename(&staging_path, &path).expect("install fake git"); path } @@ -913,6 +968,63 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn check_reports_broken_and_missing_send_email() { + let root = temp_dir("check-broken"); + let fake_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'fatal: send-email support missing' >&2\n exit 1\nfi\nexit 1\n", + ); + + match check_with_command_path(Some(&fake_git)).status { + GitSendEmailStatus::Broken { reason, .. } => { + assert_eq!(reason, "fatal: send-email support missing"); + } + other => panic!("unexpected status: {other:?}"), + } + + match check_with_command_path(Some(&root.join("missing-git"))).status { + GitSendEmailStatus::Missing => {} + other => panic!("unexpected status: {other:?}"), + } + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn resolve_identity_falls_back_to_user_name_email_and_reports_errors() { + let root = temp_dir("identity-fallback"); + let fallback_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"sendemail.from\" ]; then\n exit 1\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.email\" ]; then\n echo 'fallback@example.com'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.name\" ]; then\n echo ' '\n exit 0\nfi\nexit 1\n", + ); + + let identity = resolve_reply_identity_with_command_path(Some(&fallback_git)) + .expect("resolve fallback identity"); + assert_eq!(identity.display, "fallback@example.com"); + assert_eq!(identity.email, "fallback@example.com"); + assert_eq!(identity.source, ReplyIdentitySource::UserNameEmail); + assert_eq!(identity.source.as_str(), "git config user.name/user.email"); + + let invalid_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"sendemail.from\" ]; then\n echo 'No Email Here'\n exit 0\nfi\nexit 1\n", + ); + let invalid = resolve_reply_identity_with_command_path(Some(&invalid_git)) + .expect_err("invalid sendemail.from should fail"); + assert!(invalid.contains("does not contain a valid email address")); + + let missing_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"sendemail.from\" ]; then\n exit 1\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.email\" ]; then\n exit 1\nfi\nif [ \"$1\" = \"config\" ] && [ \"$2\" = \"user.name\" ]; then\n exit 1\nfi\nexit 1\n", + ); + let missing = resolve_reply_identity_with_command_path(Some(&missing_git)) + .expect_err("missing user email should fail"); + assert!(missing.contains("git email identity missing")); + + let _ = fs::remove_dir_all(root); + } + #[test] fn send_success_removes_draft_and_keeps_generated_message_id() { let root = temp_dir("send-success"); @@ -977,6 +1089,118 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn send_reports_unavailable_transport_without_creating_draft() { + let root = temp_dir("send-unavailable"); + let fake_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'fatal: send-email support missing' >&2\n exit 1\nfi\nexit 1\n", + ); + let runtime = test_runtime_in(&root); + + let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git)); + + assert_eq!(outcome.status, SendStatus::Failed); + assert!(outcome.command_line.is_none()); + assert!(outcome.draft_path.is_none()); + assert!( + outcome + .error_summary + .as_deref() + .is_some_and(|summary| summary.contains("git send-email unavailable")) + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn send_failure_uses_stdout_or_exit_code_when_stderr_is_empty() { + let root = temp_dir("send-summary-fallbacks"); + let stdout_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n echo 'smtp failed on stdout'\n exit 2\nfi\nexit 1\n", + ); + let runtime = test_runtime_in(&root); + let stdout_outcome = send_with_command_path(&runtime, &sample_request(), Some(&stdout_git)); + assert_eq!(stdout_outcome.status, SendStatus::Failed); + assert_eq!( + stdout_outcome.error_summary.as_deref(), + Some("smtp failed on stdout") + ); + + let exit_code_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n exit 7\nfi\nexit 1\n", + ); + let exit_code_outcome = + send_with_command_path(&runtime, &sample_request(), Some(&exit_code_git)); + assert_eq!(exit_code_outcome.status, SendStatus::Failed); + assert_eq!( + exit_code_outcome.error_summary.as_deref(), + Some("git send-email exited with 7") + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn send_times_out_and_keeps_draft_for_retry() { + let root = temp_dir("send-timeout"); + let fake_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n sleep 1\n exit 0\nfi\nexit 1\n", + ); + let runtime = test_runtime_in(&root); + + let outcome = send_with_options( + &runtime, + &sample_request(), + Some(&fake_git), + std::time::Duration::from_millis(50), + ); + + assert_eq!(outcome.status, SendStatus::TimedOut); + assert!(outcome.timed_out); + assert!( + outcome + .draft_path + .as_ref() + .is_some_and(|path| path.exists()) + ); + assert_eq!( + outcome.error_summary.as_deref(), + Some("git send-email timed out after 0s") + ); + + let _ = fs::remove_dir_all(root); + } + + #[test] + fn send_reports_outbox_creation_failure() { + let root = temp_dir("send-outbox-fail"); + let data_file = root.join("blocked-data"); + fs::write(&data_file, "not a directory").expect("write blocking data file"); + let fake_git = write_fake_git( + &root, + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nexit 1\n", + ); + let mut runtime = test_runtime_in(&root); + runtime.data_dir = data_file; + + let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git)); + + assert_eq!(outcome.status, SendStatus::Failed); + assert!(outcome.draft_path.is_none()); + assert!( + outcome + .error_summary + .as_deref() + .is_some_and(|summary| summary.contains("failed to create reply outbox")) + ); + + let _ = fs::remove_dir_all(root); + } + #[test] fn send_prefers_configured_kernel_tree_as_working_dir() { let root = temp_dir("send-working-dir"); @@ -1003,4 +1227,67 @@ mod tests { let _ = fs::remove_dir_all(root); } + + #[test] + fn message_rendering_and_helper_outputs_follow_reply_contract() { + let mut request = sample_request(); + request.cc.clear(); + request.references = vec![ + ",".to_string(), + "patch@example.com".to_string(), + ]; + + let rendered = render_message_file(&request, "generated@example.com"); + assert!(rendered.contains("From: CRIEW Test ")); + assert!(rendered.contains("To: maintainer@example.com")); + assert!(!rendered.contains("\nCc: ")); + assert!(rendered.contains("Message-ID: ")); + assert!(rendered.contains("In-Reply-To: ")); + assert!(rendered.contains("References: ")); + assert!(rendered.ends_with("reply body\n")); + + assert_eq!( + render_command_line( + "git send-email", + &[ + "--from".to_string(), + "CRIEW Test ".to_string(), + String::new(), + ] + ), + "'git send-email' --from 'CRIEW Test ' ''" + ); + assert_eq!( + normalize_output(b"\n first line \nsecond\n"), + Some("first line".to_string()) + ); + assert_eq!( + summarize_failure(Some(9), "", ""), + Some("git send-email exited with 9".to_string()) + ); + assert_eq!( + normalize_message_id(" , "), + "older@example.com" + ); + assert_eq!( + extract_email_address("CRIEW Test "), + Some("criew@example.com".to_string()) + ); + assert_eq!(extract_email_address("invalid identity"), None); + assert!(generate_message_id("No Email").ends_with("@localhost")); + } + + #[test] + fn resolve_working_dir_falls_back_when_kernel_tree_is_missing() { + let root = temp_dir("send-working-dir-fallback"); + let runtime = test_runtime_in(&root); + let current_dir = std::env::current_dir().expect("current dir"); + assert_eq!(resolve_working_dir(&runtime), current_dir); + + let mut with_invalid_tree = test_runtime_in(&root); + with_invalid_tree.kernel_trees = vec![root.join("missing-tree")]; + assert_eq!(resolve_working_dir(&with_invalid_tree), current_dir); + + let _ = fs::remove_dir_all(root); + } } diff --git a/src/ui/tui/reply.rs b/src/ui/tui/reply.rs index 134ab6e..9532550 100644 --- a/src/ui/tui/reply.rs +++ b/src/ui/tui/reply.rs @@ -469,10 +469,7 @@ where fn normalize_message_id(value: &str) -> String { value .trim() - .trim_matches('<') - .trim_matches('>') - .trim_matches('"') - .trim_matches(',') + .trim_matches(|character| matches!(character, '<' | '>' | '"' | ',')) .trim() .to_string() } @@ -622,7 +619,7 @@ mod tests { use super::{ ReplyIdentity, ReplyPreviewRequest, build_reply_seed, extract_email_address, - normalize_reply_subject, render_reply_preview, + normalize_reply_subject, prepare_reply_message, render_reply_preview, }; fn sample_thread(subject: &str, message_id: &str) -> ThreadRow { @@ -703,6 +700,100 @@ mod tests { assert!(seed.cc.is_empty()); } + #[test] + fn build_reply_seed_falls_back_to_thread_metadata_and_empty_body() { + let raw = b"Message-ID: \r\n\r\n"; + let mut thread = sample_thread("[PATCH] fallback", "thread@example.com"); + thread.from_addr = "Thread Author ".to_string(); + thread.date = Some("Sat, 7 Mar 2026 12:00:00 +0000".to_string()); + + let seed = build_reply_seed(raw, &thread, &identity(), &[identity().email.clone()]); + + assert_eq!(seed.to, "Thread Author "); + assert!(seed.cc.is_empty()); + assert_eq!(seed.subject, "Re: [PATCH] fallback"); + assert_eq!(seed.in_reply_to, "patch@example.com"); + assert_eq!(seed.references, vec!["patch@example.com"]); + assert_eq!( + seed.body[1], + "On Sat, 7 Mar 2026 12:00:00 +0000, Thread Author wrote:" + ); + assert_eq!(seed.body[2], "> "); + } + + #[test] + fn build_reply_seed_handles_folded_headers_and_blank_body_lines() { + let raw = b"Message-ID: \r\nSubject: [PATCH] folded\r\nFrom: Alice \r\nTo: \"Doe, Jane\" ,\r\n Bob \r\nCc: Carol ;\r\n\tCRIEW Test \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nline one\r\n\r\nline two\r\n"; + let thread = sample_thread("[PATCH] folded", "patch@example.com"); + + let seed = build_reply_seed(raw, &thread, &identity(), &[identity().email.clone()]); + + assert_eq!( + seed.to, + "\"Doe, Jane\" , Bob " + ); + assert_eq!(seed.cc, "Carol "); + assert_eq!(seed.body[2], "> line one"); + assert_eq!(seed.body[3], ">"); + assert_eq!(seed.body[4], "> line two"); + } + + #[test] + fn prepare_reply_message_uses_parent_when_references_missing() { + let (message, errors) = prepare_reply_message(ReplyPreviewRequest { + from: "CRIEW Test ", + to: "Bob ", + cc: "", + subject: "[PATCH] demo", + in_reply_to: " , ", + references: &[], + body: &[ + "line one ".to_string(), + String::new(), + "line two".to_string(), + ], + self_addresses: &[identity().email.clone()], + }); + + assert!(errors.is_empty()); + assert_eq!(message.subject, "Re: [PATCH] demo"); + assert_eq!(message.in_reply_to, "parent@example.com"); + assert_eq!(message.references, vec!["parent@example.com"]); + assert_eq!(message.body, "line one\n\nline two"); + } + + #[test] + fn prepare_reply_message_adds_parent_to_existing_references_and_filters_self() { + let (message, errors) = prepare_reply_message(ReplyPreviewRequest { + from: " CRIEW Test ", + to: "Bob ; \"Doe, Jane\" ; CRIEW Test ", + cc: "Carol , criew@example.com", + subject: "fwd: [PATCH] demo", + in_reply_to: " , ", + references: &["".to_string()], + body: &["body".to_string()], + self_addresses: &[identity().email.clone(), "alias@example.com".to_string()], + }); + + assert!(errors.is_empty()); + assert_eq!( + message.to, + vec![ + "Bob ".to_string(), + "\"Doe, Jane\" ".to_string() + ] + ); + assert_eq!(message.cc, vec!["Carol ".to_string()]); + assert_eq!(message.subject, "Re: [PATCH] demo"); + assert_eq!( + message.references, + vec![ + "older@example.com".to_string(), + "parent@example.com".to_string() + ] + ); + } + #[test] fn preview_validation_reports_missing_recipients() { let preview = render_reply_preview(ReplyPreviewRequest { @@ -747,6 +838,51 @@ mod tests { ); } + #[test] + fn render_reply_preview_reports_missing_headers_and_renders_placeholders() { + let preview = render_reply_preview(ReplyPreviewRequest { + from: "CRIEW Test", + to: "", + cc: "", + subject: " ", + in_reply_to: " ", + references: &[], + body: &[" ".to_string(), String::new()], + self_addresses: &[], + }); + + assert!( + preview + .errors + .iter() + .any(|value| value == "From is missing a valid email address") + ); + assert!( + preview + .errors + .iter() + .any(|value| value == "reply preview has no recipients after removing self") + ); + assert!( + preview + .errors + .iter() + .any(|value| value == "Subject is empty") + ); + assert!( + preview + .errors + .iter() + .any(|value| value == "In-Reply-To is missing") + ); + assert!(preview.content.contains("To: ")); + assert!(preview.content.contains("Cc: ")); + assert!(preview.content.contains("Subject: Re:")); + assert!(preview.content.contains("In-Reply-To: ")); + assert!(preview.content.contains("References: ")); + assert!(preview.content.ends_with("")); + } + #[test] fn extracts_email_from_display_or_bare_value() { assert_eq!( @@ -757,5 +893,7 @@ mod tests { extract_email_address("alice@example.com"), Some("alice@example.com".to_string()) ); + assert_eq!(extract_email_address("Alice Example"), None); + assert_eq!(extract_email_address("<>"), None); } } diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 7bed172..45e4a8d 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -454,6 +454,15 @@ fn mailbox_sync_spawner_stub( receiver } +fn type_text(state: &mut AppState, text: &str) { + for character in text.chars() { + let _ = handle_key_event( + state, + KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE), + ); + } +} + #[test] fn empty_query_returns_all_palette_commands() { let all = matching_commands(""); @@ -1109,6 +1118,140 @@ inbox_auto_sync_interval_secs = 30 let _ = fs::remove_dir_all(root); } +#[test] +fn config_editor_reports_unsupported_key_hint_and_allows_keyboard_navigation() { + let mut state = AppState::new(vec![], test_runtime()); + + state.open_config_editor(Some("unsupported.key")); + + assert!(state.config_editor.open); + assert!( + state + .status + .contains("config editor does not support unsupported.key") + ); + assert_eq!(state.selected_config_editor_field().key, "source.mailbox"); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_ne!(state.selected_config_editor_field().key, "source.mailbox"); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(state.selected_config_editor_field().key, "source.mailbox"); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!state.config_editor.open); + assert_eq!(state.status, "config editor closed"); +} + +#[test] +fn config_editor_edit_mode_handles_char_backspace_tab_and_escape() { + let root = temp_dir("config-editor-keyboard"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[ui] +startup_sync = true +"#, + ) + .expect("write config file"); + + let mut runtime = test_runtime(); + runtime.config_path = config_path; + let mut state = AppState::new(vec![], runtime); + + state.open_config_editor(Some("ui.startup_sync")); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!( + state.config_editor.mode, + super::ConfigEditorMode::Edit + )); + assert_eq!(state.config_editor.input, "true"); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ); + assert_eq!(state.config_editor.input, "truex"); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + assert_eq!(state.config_editor.input, "true"); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(state.config_editor.input, "false"); + assert_eq!(state.status, "preset value selected for ui.startup_sync"); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(matches!( + state.config_editor.mode, + super::ConfigEditorMode::Browse + )); + assert!(state.config_editor.input.is_empty()); + assert_eq!(state.status, "config edit cancelled"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn config_palette_help_and_usage_are_reported() { + let mut state = AppState::new(vec![], test_runtime()); + + state.palette.open = true; + state.palette.input = "config help".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(state.status.contains("config usage:")); + + state.palette.open = true; + state.palette.input = "config get".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!(state.status, "usage: config get "); + + state.palette.open = true; + state.palette.input = "config set".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!(state.status, "usage: config set "); +} + +#[test] +fn config_palette_reports_effective_and_missing_values() { + let mut state = AppState::new(vec![], test_runtime()); + + state.palette.open = true; + state.palette.input = "config get source.mailbox".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!( + state + .status + .contains("config effective source.mailbox = inbox (default/runtime)") + ); + + state.palette.open = true; + state.palette.input = "config get no.such.key".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!(state.status, "config key not found: no.such.key"); +} + #[test] fn config_palette_set_does_not_overwrite_scalar_parent_keys() { let root = temp_dir("config-palette-scalar-parent"); @@ -2629,6 +2772,183 @@ fn slash_opens_search_and_filters_threads() { assert_eq!(selected.message_id, "b@example.com"); } +#[test] +fn search_on_code_browser_reports_mail_only_scope() { + let mut state = AppState::new(vec![], test_runtime()); + state.ui_page = UiPage::CodeBrowser; + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert!(!state.search.active); + assert_eq!(state.status, "search is only available on mail page"); +} + +#[test] +fn search_backspace_and_escape_clear_pending_query() { + let mut state = AppState::new(vec![], test_runtime()); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE), + ); + type_text(&mut state, "ab"); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + assert_eq!(state.search.input, "a"); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(!state.search.active); + assert!(state.search.input.is_empty()); + assert_eq!(state.status, "search cancelled"); +} + +#[test] +fn ctrl_backtick_closes_open_palette() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = "help".to_string(); + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('`'), KeyModifiers::CONTROL), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert!(!state.palette.open); + assert!(state.palette.input.is_empty()); + assert_eq!(state.status, "command palette closed"); +} + +#[test] +fn palette_reports_empty_and_unknown_commands() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = " ".to_string(); + + let empty_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(empty_action, LoopAction::Continue)); + assert_eq!(state.status, "empty command"); + + state.palette.open = true; + state.palette.input = "wat".to_string(); + let unknown_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(unknown_action, LoopAction::Continue)); + assert_eq!(state.status, "unknown command: wat"); +} + +#[test] +fn palette_escape_backspace_and_char_input_update_buffer() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = "he".to_string(); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + assert_eq!(state.palette.input, "h"); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + assert_eq!(state.palette.input, "hi"); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!state.palette.open); + assert!(state.palette.input.is_empty()); +} + +#[test] +fn palette_sync_command_runs_via_handle_key_event() { + let mut state = AppState::new(vec![], test_runtime()); + state.sync_request_executor = sync_request_mock_success; + state.palette.open = true; + state.palette.input = "sync io-uring".to_string(); + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert!(state.status.contains("sync ok")); +} + +#[test] +fn palette_bang_reports_empty_local_command() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = "! ".to_string(); + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert_eq!(state.status, "empty local command after !"); +} + +#[test] +fn enter_on_thread_sets_selected_status_message() { + let mut state = AppState::new( + vec![sample_thread("normal mail", "plain@example.com", 0)], + test_runtime(), + ); + state.focus = Pane::Threads; + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert_eq!(state.status, "selected plain@example.com"); +} + +#[test] +fn escape_quit_and_ctrl_c_show_exit_guidance() { + let mut state = AppState::new(vec![], test_runtime()); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + state.status, + "open command palette with : (preferred) or Ctrl+`" + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + assert_eq!( + state.status, + "q emergency exit disabled; use command palette quit/exit" + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + assert_eq!( + state.status, + "Ctrl+C is disabled, use command palette quit/exit" + ); +} + #[test] fn jl_focus_and_ik_move_selection() { let mut state = AppState::new( @@ -3321,6 +3641,554 @@ fn reply_send_preview_uses_edited_header_values() { let _ = fs::remove_dir_all(root); } +#[test] +fn mail_page_r_opens_reply_panel_from_threads_focus() { + let root = temp_dir("reply-open-r"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Threads; + state.reply_identity_resolver = reply_identity_mock; + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), + ); + + assert!(matches!(action, LoopAction::Continue)); + assert!(state.reply_panel.is_some()); + assert!( + state + .status + .contains("reply panel opened for ") + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reply_notice_escape_closes_blocked_notice() { + let root = temp_dir("reply-notice-esc"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + ); + assert!( + state + .reply_panel + .as_ref() + .and_then(|panel| panel.reply_notice.as_ref()) + .is_some() + ); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| panel.reply_notice.is_none()) + ); + assert_eq!(state.status, "reply notice closed"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reply_send_preview_scrolls_with_j_and_k() { + let root = temp_dir("reply-preview-scroll"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + ); + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| panel.preview_open) + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert_eq!( + state + .reply_panel + .as_ref() + .expect("reply panel") + .preview_scroll, + 1 + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert_eq!( + state + .reply_panel + .as_ref() + .expect("reply panel") + .preview_scroll, + 0 + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reply_send_preview_escape_closes_preview() { + let root = temp_dir("reply-preview-esc"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + ); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| !panel.preview_open) + ); + assert_eq!(state.status, "send preview closed"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reply_notice_enter_closes_blocked_notice() { + let root = temp_dir("reply-notice-enter"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| panel.reply_notice.is_none()) + ); + assert_eq!(state.status, "reply notice closed"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reply_command_mode_handles_empty_unsupported_and_discard_commands() { + let root = temp_dir("reply-command-mode"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!(state.status, "empty command"); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), + ); + type_text(&mut state, "zzz"); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert_eq!(state.status, "unsupported command: :zzz"); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), + ); + type_text(&mut state, "q!"); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(state.reply_panel.is_none()); + assert_eq!(state.status, "discarded reply draft"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reply_command_mode_escape_and_backspace_restore_normal_mode() { + let root = temp_dir("reply-command-cancel"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), + ); + type_text(&mut state, "ab"); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + assert_eq!( + state + .reply_panel + .as_ref() + .expect("reply panel") + .command_input, + "a" + ); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + let panel = state.reply_panel.as_ref().expect("reply panel"); + assert!(matches!(panel.mode, ReplyEditMode::Normal)); + assert!(panel.command_input.is_empty()); + assert_eq!(state.status, "reply command cancelled"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reply_command_q_closes_clean_panel_but_blocks_dirty_draft() { + let root = temp_dir("reply-command-q"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let mut clean_state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + clean_state.focus = Pane::Preview; + clean_state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut clean_state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut clean_state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), + ); + type_text(&mut clean_state, "q"); + let _ = handle_key_event( + &mut clean_state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(clean_state.reply_panel.is_none()); + assert_eq!(clean_state.status, "closed reply panel"); + + let mut dirty_state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + dirty_state.focus = Pane::Preview; + dirty_state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut dirty_state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + if let Some(panel) = dirty_state.reply_panel.as_mut() { + panel.mark_dirty(); + } + let _ = handle_key_event( + &mut dirty_state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), + ); + type_text(&mut dirty_state, "q"); + let _ = handle_key_event( + &mut dirty_state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(dirty_state.reply_panel.is_some()); + assert_eq!( + dirty_state.status, + "unsaved reply draft, run :q! to discard" + ); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reply_command_preview_and_preview_enter_cover_remaining_preview_shortcuts() { + let root = temp_dir("reply-command-preview"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE), + ); + type_text(&mut state, "preview"); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| panel.preview_open) + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| panel.preview_confirmed) + ); + assert_eq!(state.status, "send preview confirmed; ready to send"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reply_insert_mode_tab_and_backspace_modify_body() { + let root = temp_dir("reply-insert-tab"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + test_runtime(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + if let Some(panel) = state.reply_panel.as_mut() { + panel.section = ReplySection::Body; + panel.body = vec![String::new()]; + panel.body_row = 0; + panel.cursor_col = 0; + panel.adjust_scroll(); + } + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!( + state.reply_panel.as_ref().expect("reply panel").body[0], + " " + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), + ); + assert_eq!( + state.reply_panel.as_ref().expect("reply panel").body[0], + " " + ); + + let _ = fs::remove_dir_all(root); +} + #[test] fn reply_insert_enter_on_quote_line_starts_unquoted_reply_line() { let root = temp_dir("reply-quote-enter"); From 508c54594cb1340f1d8aca4de2d01748341129b3 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Mon, 9 Mar 2026 13:37:47 +0800 Subject: [PATCH 09/29] fix(imap): fail fast on archive HTTP errors Signed-off-by: Chen Miao --- src/infra/imap.rs | 97 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 8 deletions(-) diff --git a/src/infra/imap.rs b/src/infra/imap.rs index 201e9e7..f8dfa22 100644 --- a/src/infra/imap.rs +++ b/src/infra/imap.rs @@ -399,6 +399,10 @@ impl LoreImapClient { ) })?; let status_code = response.status().as_u16(); + if !(200..300).contains(&status_code) { + return Ok((status_code, Vec::new())); + } + let bytes = response.bytes().map_err(|error| { CriewError::with_source( ErrorCode::Imap, @@ -607,6 +611,8 @@ impl GnuArchiveClient { })?; let status_code = response.status().as_u16(); + validate_gnu_archive_mbox_response(&url, status_code)?; + let bytes = response .bytes() .map(|bytes| bytes.to_vec()) @@ -618,7 +624,7 @@ impl GnuArchiveClient { ) })?; - validate_gnu_archive_mbox_response(&url, status_code, bytes) + Ok(bytes) } } @@ -637,11 +643,7 @@ fn parse_gnu_archive_index_response( parse_gnu_archive_month_entries(body) } -fn validate_gnu_archive_mbox_response( - url: &str, - status_code: u16, - body: Vec, -) -> Result> { +fn validate_gnu_archive_mbox_response(url: &str, status_code: u16) -> Result<()> { if !(200..300).contains(&status_code) { return Err(imap_error( ImapErrorKind::Protocol, @@ -649,7 +651,7 @@ fn validate_gnu_archive_mbox_response( )); } - Ok(body) + Ok(()) } fn build_gnu_archive_incremental_mails( @@ -2447,6 +2449,30 @@ mod tests { (base_url, handle) } + fn start_http_server_with_raw_responses( + expected_requests: usize, + build_response: F, + ) -> (String, thread::JoinHandle<()>) + where + F: Fn(&str) -> Vec + Send + 'static, + { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind http listener"); + let base_url = format!("http://{}", listener.local_addr().expect("listener addr")); + let handle = thread::spawn(move || { + for _ in 0..expected_requests { + let (mut stream, _) = listener.accept().expect("accept http request"); + let path = read_http_request_path(&mut stream); + let response = build_response(&path); + stream + .write_all(&response) + .expect("write raw HTTP response"); + stream.flush().expect("flush raw HTTP response"); + } + }); + + (base_url, handle) + } + fn read_http_request_path(stream: &mut impl Read) -> String { let mut request = Vec::new(); let mut buf = [0u8; 1]; @@ -2823,6 +2849,26 @@ mod tests { handle.join().expect("join HTTP server"); } + #[test] + fn lore_client_checks_status_before_reading_error_body() { + let (base_url, handle) = start_http_server_with_raw_responses(2, move |path| { + match path { + "/io-uring/msg-b/raw" | "/io-uring/msg-b/raw/" => b"HTTP/1.1 404 Not Found\r\nContent-Length: 64\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\n\r\nshort".to_vec(), + _ => panic!("unexpected HTTP path {path}"), + } + }); + + let mut client = LoreImapClient::new(Some(&base_url)).expect("create lore client"); + client.connect().expect("connect lore"); + + let error = client + .fetch_raw_mail(&format!("{base_url}/io-uring/msg-b/")) + .expect_err("HTTP status should fail before reading body"); + assert!(error.to_string().contains("HTTP 404")); + + handle.join().expect("join HTTP server"); + } + #[test] fn lore_client_reports_empty_raw_message_after_trying_candidates() { let error = fetch_lore_raw_with("https://lore.kernel.org/io-uring/msg-empty/", |_| { @@ -2869,7 +2915,8 @@ mod tests { let fetched = build_gnu_archive_incremental_mails(&months, Some(feb_modseq), |month_key| { let url = client.month_url("mailbox", month_key); - validate_gnu_archive_mbox_response(&url, 200, mbox.to_vec()) + validate_gnu_archive_mbox_response(&url, 200)?; + Ok(mbox.to_vec()) }) .expect("fetch gnu archive"); assert_eq!(fetched.len(), 2); @@ -2950,6 +2997,40 @@ mod tests { handle.join().expect("join HTTP server"); } + #[test] + fn gnu_archive_client_checks_status_before_reading_error_body() { + let feb_modseq = + parse_gnu_archive_listing_timestamp("2026-02-26", "09:12").expect("feb timestamp"); + let index_body = r#" +
+2026-02 2026-02-26 09:12  855K
+2026-03 2026-03-07 06:37  341K
+
+"#; + let (base_url, handle) = start_http_server_with_raw_responses(2, move |path| { + match path { + "/mailbox/" => format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\n\r\n{}", + index_body.len(), + index_body, + ) + .into_bytes(), + "/mailbox/2026-03" => b"HTTP/1.1 404 Not Found\r\nContent-Length: 64\r\nContent-Type: text/plain; charset=utf-8\r\nConnection: close\r\n\r\nshort".to_vec(), + _ => panic!("unexpected HTTP path {path}"), + } + }); + + let mut client = GnuArchiveClient::new(Some(&base_url)).expect("create archive client"); + client.connect().expect("connect archive"); + + let error = client + .fetch_incremental("mailbox", 0, Some(feb_modseq)) + .expect_err("HTTP status should fail before reading body"); + assert!(error.to_string().contains("HTTP 404")); + + handle.join().expect("join HTTP server"); + } + #[test] fn http_clients_surface_transport_errors_before_receiving_responses() { let mut lore_client = From bd9cbadb90138c9d38eba005ad46b0557b9b9fe2 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Mon, 9 Mar 2026 18:40:39 +0800 Subject: [PATCH 10/29] docs: reorganize docs directory layout - group repository docs under architecture, development, milestones, reference, and specs - update README and repository skill references to the new paths - keep docs root ready for a future docs/wiki subtree Signed-off-by: Chen Miao --- .codex/skills/criew-development/SKILL.md | 20 ++--- README-zh.md | 79 +++++++++++++------ README.md | 77 ++++++++++++------ docs/{ => architecture}/design.md | 30 ++++++- docs/{ => development}/code-guildline-cn.md | 2 +- docs/{ => development}/code-guildline.md | 2 +- docs/{ => milestones}/mvp-milestones.md | 24 +++++- docs/{ => milestones}/reply-mvp-milestones.md | 9 ++- docs/{ => milestones}/vim-mvp-milestones.md | 7 +- docs/{ => reference}/config.example.toml | 0 .../{ => specs}/code-preview-vim-prototype.md | 12 +++ docs/{ => specs}/reply-format-spec.md | 10 +++ 12 files changed, 200 insertions(+), 72 deletions(-) rename docs/{ => architecture}/design.md (96%) rename docs/{ => development}/code-guildline-cn.md (99%) rename docs/{ => development}/code-guildline.md (99%) rename docs/{ => milestones}/mvp-milestones.md (95%) rename docs/{ => milestones}/reply-mvp-milestones.md (95%) rename docs/{ => milestones}/vim-mvp-milestones.md (97%) rename docs/{ => reference}/config.example.toml (100%) rename docs/{ => specs}/code-preview-vim-prototype.md (96%) rename docs/{ => specs}/reply-format-spec.md (96%) diff --git a/.codex/skills/criew-development/SKILL.md b/.codex/skills/criew-development/SKILL.md index d48d680..f62948a 100644 --- a/.codex/skills/criew-development/SKILL.md +++ b/.codex/skills/criew-development/SKILL.md @@ -1,6 +1,6 @@ --- name: criew-development -description: Repository-specific workflow and coding rules for the CRIEW codebase. Use when modifying or reviewing CRIEW Rust code, TUI behavior, sync/IMAP/reply/patch workflows, migrations, tests, docs, or config, and whenever the task must follow `docs/code-guildline.md` or `docs/code-guildline-cn.md`. +description: Repository-specific workflow and coding rules for the CRIEW codebase. Use when modifying or reviewing CRIEW Rust code, TUI behavior, sync/IMAP/reply/patch workflows, migrations, tests, docs, or config, and whenever the task must follow `docs/development/code-guildline.md` or `docs/development/code-guildline-cn.md`. --- # Criew Development @@ -14,14 +14,14 @@ Read the repository docs first, then make focused changes that preserve the curr Read the relevant repository docs before editing code. -- Read `docs/code-guildline.md` first for the canonical coding rules. -- Read `docs/code-guildline-cn.md` when the user works in Chinese or explicitly asks for the Chinese guideline. +- Read `docs/development/code-guildline.md` first for the canonical coding rules. +- Read `docs/development/code-guildline-cn.md` when the user works in Chinese or explicitly asks for the Chinese guideline. - Read `README.md` or `README-zh.md` before changing user-visible behavior, install steps, naming, or operator workflow. -- Read `docs/design.md` before changing architecture, module boundaries, sync flow, or data-model assumptions. -- Read `docs/reply-format-spec.md` before changing reply composition, quoting, headers, or send flow. -- Read `docs/config.example.toml` before changing config keys, defaults, or path semantics. +- Read `docs/architecture/design.md` before changing architecture, module boundaries, sync flow, or data-model assumptions. +- Read `docs/specs/reply-format-spec.md` before changing reply composition, quoting, headers, or send flow. +- Read `docs/reference/config.example.toml` before changing config keys, defaults, or path semantics. -Treat `docs/code-guildline.md` as the priority source when a local convention is unclear. +Treat `docs/development/code-guildline.md` as the priority source when a local convention is unclear. Then follow tool-enforced rules and the existing style in the touched module. ## Keep The CRIEW Boundaries @@ -41,7 +41,7 @@ Do not reintroduce legacy Courier naming. ## Apply The Coding Rules Directly -Implement changes in the style required by `docs/code-guildline.md`. +Implement changes in the style required by `docs/development/code-guildline.md`. - Prefer descriptive, behavior-accurate names. - Encode units in names when the type system does not. @@ -96,6 +96,6 @@ If time or environment constraints prevent a command from running, report that c Load extra repository docs only when the task needs them. -- Read `docs/mvp-milestones.md` and `docs/reply-mvp-milestones.md` for historical intent or rollout sequencing. -- Read `docs/vim-mvp-milestones.md` and `docs/code-preview-vim-prototype.md` for Vim-mode and code-preview behavior. +- Read `docs/milestones/mvp-milestones.md` and `docs/milestones/reply-mvp-milestones.md` for historical intent or rollout sequencing. +- Read `docs/milestones/vim-mvp-milestones.md` and `docs/specs/code-preview-vim-prototype.md` for Vim-mode and code-preview behavior. - Read `src/ui/tui/tests.rs` before extending TUI behavior that already has test coverage. diff --git a/README-zh.md b/README-zh.md index dec42ab..7806560 100644 --- a/README-zh.md +++ b/README-zh.md @@ -13,7 +13,16 @@ CRIEW 是一个面向 Linux kernel patch 邮件工作流的 Rust TUI 工具, English README: [README.md](README.md) -## 当前能力 +## 导航 + +- [项目概览](#项目概览) +- [安装与配置](#安装与配置) +- [使用](#使用) +- [延伸阅读](#延伸阅读) + +## 项目概览 + +### 当前能力 - 同步 `lore.kernel.org` 邮件列表,并按 checkpoint 做增量更新 - 同步真实 IMAP `INBOX`,内置 `My Inbox` 订阅 @@ -22,7 +31,7 @@ English README: [README.md](README.md) - 在 TUI 中撰写回信,并通过 `git send-email` 发送 - 浏览本地 kernel tree,支持内联 Vim-like 编辑和外部 Vim 编辑 -## 发布基线 +### 发布基线 `v0.0.1` 是 CRIEW 第一版对外支持的发布基线。 从 `v0.0.1` 开始,项目只使用 CRIEW 这一套命名: @@ -32,7 +41,7 @@ English README: [README.md](README.md) 如果你之前测试过 rename 落定前的预发布快照,或者更早打出的 `v0.0.1` tag, 请重新拉取代码或重新安装,并按当前命名重新初始化 CRIEW 运行目录。 -## 依赖 +### 依赖 - Rust stable - Git @@ -51,9 +60,11 @@ criew doctor 它会检查 `b4`、`git send-email`、git 邮件身份和 IMAP 连接状态。 -## 安装 +## 安装与配置 -### 从 crates.io 安装 +### 安装 + +#### 从 crates.io 安装 ```bash cargo install criew @@ -64,7 +75,7 @@ cargo install criew CRIEW 会在首次需要时把它展开到 `~/.criew/vendor/b4/`。 这个 fallback 仍然需要系统可用的 Python 3。 -### 从源码仓库安装 +#### 从源码仓库安装 如果你希望直接使用工作区里的 `./vendor/b4/b4.sh`,建议递归拉取子模块: @@ -80,7 +91,7 @@ cargo install --path . --locked git submodule update --init --recursive ``` -### 直接从 GitHub 安装 +#### 直接从 GitHub 安装 ```bash cargo install --git https://github.com/ChenMiaoi/CRIEW.git --locked criew @@ -89,14 +100,16 @@ cargo install --git https://github.com/ChenMiaoi/CRIEW.git --locked criew 这种方式下,建议你自行通过 `b4.path`、`CRIEW_B4_PATH` 或系统 `PATH` 提供 `b4`。 如果 checkout 里也带着 `vendor/b4`,CRIEW 也能像源码模式一样直接使用它。 -### 开发时直接运行 +#### 开发时直接运行 ```bash cargo run -- doctor cargo run -- tui ``` -## 配置 +### 配置 + +#### 默认路径 默认配置文件路径: @@ -110,7 +123,9 @@ cargo run -- tui ~/.criew/ ``` -首次运行时,CRIEW 会自动生成一个最小配置文件。完整示例见 [docs/config.example.toml](docs/config.example.toml)。 +#### 常见配置 + +首次运行时,CRIEW 会自动生成一个最小配置文件。完整示例见 [docs/reference/config.example.toml](docs/reference/config.example.toml)。 一个常见配置如下: @@ -130,6 +145,8 @@ encryption = "ssl" tree = "/path/to/linux" ``` +#### 配置说明 + 配置说明: - 相对路径会相对于配置文件所在目录解析 @@ -141,39 +158,43 @@ tree = "/path/to/linux" - `ui.inbox_auto_sync_interval_secs` 默认是 `30` - `~/.courier`、`courier-config.toml`、`courier.db`、`COURIER_B4_PATH`、`COURIER_IMAP_PROXY` 从 `v0.0.1` 起都不再受支持 -## 基本使用 +## 使用 + +### 基本使用 -### 1. 环境自检 +#### 1. 环境自检 ```bash criew doctor ``` -### 2. 同步 lore 邮箱 +#### 2. 同步 lore 邮箱 ```bash criew sync --mailbox io-uring ``` -### 3. 同步真实 IMAP 收件箱 +#### 3. 同步真实 IMAP 收件箱 ```bash criew sync --mailbox INBOX ``` -### 4. 使用本地 `.eml` fixture 调试 +#### 4. 使用本地 `.eml` fixture 调试 ```bash criew sync --mailbox test --fixture-dir ./fixtures ``` -### 5. 启动 TUI +#### 5. 启动 TUI ```bash criew tui ``` -## TUI 常用操作 +### TUI 常用操作 + +#### 键位 - `:` 打开命令栏 - `y` / `n` 启用或禁用当前订阅 @@ -184,6 +205,8 @@ criew tui - `r` 或 `e` 打开回信面板 - `Tab` 在 Mail 页面和 Code Browser 页面之间切换 +#### 命令栏命令 + 命令栏常见命令: - `sync` @@ -194,10 +217,12 @@ criew tui - `quit` - `!` +#### 后台同步 + 如果 IMAP 配置完整,`My Inbox` 会参与启动自动同步,并在 TUI 保持打开时按配置周期持续做后台增量同步。 启用的邮件列表订阅也会在 TUI 保持打开时按同一周期做后台增量同步,以持续拉取 Linux lore 和 QEMU GNU archive 上的新邮件。 -## 回复邮件 +### 回复邮件 CRIEW 的 Reply Panel 会自动填充: @@ -210,16 +235,18 @@ CRIEW 的 Reply Panel 会自动填充: 同时会生成符合 kernel 邮件习惯的引用正文模板。发送时,底层走 `git send-email`。 -## 相关文档 +## 延伸阅读 + +### 相关文档 - [README.md](README.md): 英文项目说明 -- [docs/config.example.toml](docs/config.example.toml): 配置示例 -- [docs/design.md](docs/design.md): 设计文档 -- [docs/reply-format-spec.md](docs/reply-format-spec.md): 回信格式规范 -- [docs/mvp-milestones.md](docs/mvp-milestones.md): 历史里程碑 -- [docs/reply-mvp-milestones.md](docs/reply-mvp-milestones.md): 回信功能演进记录 +- [docs/reference/config.example.toml](docs/reference/config.example.toml): 配置示例 +- [docs/architecture/design.md](docs/architecture/design.md): 设计文档 +- [docs/specs/reply-format-spec.md](docs/specs/reply-format-spec.md): 回信格式规范 +- [docs/milestones/mvp-milestones.md](docs/milestones/mvp-milestones.md): 历史里程碑 +- [docs/milestones/reply-mvp-milestones.md](docs/milestones/reply-mvp-milestones.md): 回信功能演进记录 -## 开发 +### 开发 ```bash cargo fmt --all -- --check @@ -228,7 +255,7 @@ cargo test --all-targets --all-features ./scripts/check-coverage.sh ``` -## License +### License CRIEW 自身的 Rust 代码使用 [LGPL-2.1](LICENSE) 许可证发布。 打包进来的 vendored 组件保留各自上游许可证,包括 `vendor/b4`(GPL-2.0) diff --git a/README.md b/README.md index 4f74278..5042d17 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,16 @@ It is built for developers who work on mailing-list-driven review, especially in Chinese usage guide: [README-zh.md](README-zh.md) -## Status +## Guide Map + +- [Project Overview](#project-overview) +- [Setup](#setup) +- [Usage](#usage) +- [Reference](#reference) + +## Project Overview + +### Status CRIEW is under active development. The current `develop` branch already covers the core workflow: @@ -25,7 +34,7 @@ CRIEW is under active development. The current `develop` branch already covers t - apply or export patches through `b4` - compose and send replies from the TUI through `git send-email` -## Release Baseline +### Release Baseline `v0.0.1` is the first supported public baseline for CRIEW. Starting from `v0.0.1`, the project uses only the CRIEW naming set: @@ -35,7 +44,7 @@ Earlier Courier-era names are not treated as a supported upgrade path. If you tested an older pre-release snapshot or an earlier `v0.0.1` tag before this rename settled, refresh your checkout or reinstall the binary and bootstrap a new CRIEW runtime directory. -## Features +### Features - Rust CLI with `criew tui`, `criew sync`, `criew doctor`, and `criew version` - local SQLite storage with automatic runtime bootstrap @@ -52,7 +61,9 @@ refresh your checkout or reinstall the binary and bootstrap a new CRIEW runtime - real reply delivery through `git send-email` - visual config editor, command palette completion, and structured operation logs -## Requirements +## Setup + +### Requirements - Rust stable - Git @@ -65,11 +76,11 @@ refresh your checkout or reinstall the binary and bootstrap a new CRIEW runtime `criew doctor` checks `b4`, `git send-email`, git mail identity, and IMAP connectivity. -## Installation +### Installation `crates.io` installation is the recommended path. -### Install from crates.io +#### Install from crates.io ```bash cargo install criew @@ -80,7 +91,7 @@ If `[b4].path`, `CRIEW_B4_PATH`, and `./vendor/b4/b4.sh` are all unavailable, CRIEW can materialize that fallback under `~/.criew/vendor/b4/` on first use. Python 3 is still required for that fallback. -### Install from a clone +#### Install from a clone If you want to use the repo-local `./vendor/b4/b4.sh` fallback from a checkout, clone the repository with submodules: @@ -97,7 +108,7 @@ If you already cloned the repository without submodules: git submodule update --init --recursive ``` -### Install directly from GitHub +#### Install directly from GitHub ```bash cargo install --git https://github.com/ChenMiaoi/CRIEW.git --locked criew @@ -106,26 +117,32 @@ cargo install --git https://github.com/ChenMiaoi/CRIEW.git --locked criew In this mode, you should provide `b4` through `b4.path`, `CRIEW_B4_PATH`, or your system `PATH`. If the checkout also includes `vendor/b4`, CRIEW can use it the same way as a source clone. -### Run from source +#### Run from source ```bash cargo run -- doctor cargo run -- tui ``` -## Quick Start +## Usage -### 1. Check your environment +### Quick Start + +#### 1. Check your environment ```bash criew doctor ``` -### 2. Prepare configuration +#### 2. Prepare configuration + +##### Default locations The default config file is `~/.criew/criew-config.toml`, and the default runtime directory is `~/.criew/`. CRIEW creates a minimal config file automatically on first run. -See [docs/config.example.toml](docs/config.example.toml) for a complete example. +##### Typical configuration + +See [docs/reference/config.example.toml](docs/reference/config.example.toml) for a complete example. Typical configuration: @@ -156,7 +173,9 @@ Notes: - `ui.inbox_auto_sync_interval_secs` defaults to `30` - Courier-era names such as `~/.courier`, `courier-config.toml`, `courier.db`, `COURIER_B4_PATH`, and `COURIER_IMAP_PROXY` are intentionally unsupported from `v0.0.1` onward -### 3. Sync mail +#### 3. Sync mail + +##### Sync a lore mailbox Sync a lore mailbox: @@ -164,19 +183,25 @@ Sync a lore mailbox: criew sync --mailbox io-uring ``` +##### Sync a real IMAP inbox + Sync a real IMAP inbox: ```bash criew sync --mailbox INBOX ``` +##### Use local `.eml` fixtures for debugging + Use local `.eml` fixtures for debugging: ```bash criew sync --mailbox test --fixture-dir ./fixtures ``` -### 4. Start the TUI +#### 4. Start the TUI + +##### Inside the TUI ```bash criew tui @@ -193,19 +218,23 @@ Inside the TUI: - `r` or `e` opens the reply panel - `Tab` switches between the mail page and the code browser +##### Background sync + When IMAP is configured, `My Inbox` joins startup sync and continues periodic background sync while the TUI remains open. Enabled mailing-list subscriptions also keep doing periodic background sync while the TUI remains open so Linux lore and QEMU archive mailboxes keep pulling new mail. -## Documentation +## Reference + +### Documentation - [README-zh.md](README-zh.md): Chinese usage guide -- [docs/config.example.toml](docs/config.example.toml): configuration example -- [docs/design.md](docs/design.md): design notes -- [docs/reply-format-spec.md](docs/reply-format-spec.md): reply panel and sending format -- [docs/mvp-milestones.md](docs/mvp-milestones.md): historical milestone record -- [docs/reply-mvp-milestones.md](docs/reply-mvp-milestones.md): reply workflow evolution +- [docs/reference/config.example.toml](docs/reference/config.example.toml): configuration example +- [docs/architecture/design.md](docs/architecture/design.md): design notes +- [docs/specs/reply-format-spec.md](docs/specs/reply-format-spec.md): reply panel and sending format +- [docs/milestones/mvp-milestones.md](docs/milestones/mvp-milestones.md): historical milestone record +- [docs/milestones/reply-mvp-milestones.md](docs/milestones/reply-mvp-milestones.md): reply workflow evolution -## Development +### Development Common development commands: @@ -218,7 +247,7 @@ cargo test --all-targets --all-features The repository includes GitHub Actions CI for `push` and `pull_request` with the same formatting, lint, and test checks. -## Contributing +### Contributing Issues and pull requests are welcome. @@ -232,7 +261,7 @@ cargo test --all-targets --all-features If you change user-visible behavior, commands, config keys, or workflows, update the relevant documentation in the same change. -## License +### License CRIEW's Rust code is licensed under [LGPL-2.1](LICENSE). Bundled vendored components keep their upstream licenses, including `vendor/b4` (GPL-2.0) diff --git a/docs/design.md b/docs/architecture/design.md similarity index 96% rename from docs/design.md rename to docs/architecture/design.md index 55a9968..798f478 100644 --- a/docs/design.md +++ b/docs/architecture/design.md @@ -1,5 +1,27 @@ # CRIEW 设计文档 +## 文档导航 + +1. 项目定位 +2. 目标与非目标 +3. 用户画像与关键场景 +4. 技术选型 +5. 系统架构 +6. 数据模型(逻辑) +7. 核心流程 +8. TUI 设计(MVP) +9. b4 集成策略 +10. 安全与可靠性 +11. MVP 里程碑文档 +12. 测试策略 +13. 开发优先级建议 +14. M1 已决策项与风险更新 +15. M2 已决策项与风险更新 +16. M3 已决策项与风险更新 +17. M6(已完成):真实 IMAP 接入与自邮箱订阅 +18. M7(已完成):回信编辑与预览 +19. M8(已完成):Send Email 发送链路 + ## 1. 项目定位 CRIEW 是一个基于 Rust 的现代 TUI 内核 patch 工作流工具,面向 Linux @@ -280,10 +302,10 @@ MVP 阶段: MVP 范围与阶段目标已迁移至独立文档: -- `docs/mvp-milestones.md` -- `docs/vim-mvp-milestones.md` -- `docs/reply-format-spec.md` -- `docs/reply-mvp-milestones.md` +- `docs/milestones/mvp-milestones.md` +- `docs/milestones/vim-mvp-milestones.md` +- `docs/specs/reply-format-spec.md` +- `docs/milestones/reply-mvp-milestones.md` ## 12. 测试策略 diff --git a/docs/code-guildline-cn.md b/docs/development/code-guildline-cn.md similarity index 99% rename from docs/code-guildline-cn.md rename to docs/development/code-guildline-cn.md index 002f77c..efb230a 100644 --- a/docs/code-guildline-cn.md +++ b/docs/development/code-guildline-cn.md @@ -3,7 +3,7 @@ ## 适用范围与优先级 风格与评审决策的优先级: -1. 本文档(`docs/coding-guideline-cn.md`) +1. 本文档(`docs/development/code-guildline-cn.md`) 2. 工具强制规则(`rustfmt`、`clippy`) 3. 被修改模块的既有本地约定 diff --git a/docs/code-guildline.md b/docs/development/code-guildline.md similarity index 99% rename from docs/code-guildline.md rename to docs/development/code-guildline.md index f789e2a..9e9f82c 100644 --- a/docs/code-guildline.md +++ b/docs/development/code-guildline.md @@ -3,7 +3,7 @@ ## Scope And Priority Priority order for style and review decisions: -1. This document (`docs/code-guildline.md`) +1. This document (`docs/development/code-guildline.md`) 2. Tool-enforced rules (`rustfmt`, `clippy`) 3. Existing local conventions in the modified module diff --git a/docs/mvp-milestones.md b/docs/milestones/mvp-milestones.md similarity index 95% rename from docs/mvp-milestones.md rename to docs/milestones/mvp-milestones.md index b321d62..8fa45e0 100644 --- a/docs/mvp-milestones.md +++ b/docs/milestones/mvp-milestones.md @@ -2,11 +2,27 @@ 本文档承载 CRIEW 的 MVP 范围、阶段目标、交付物与验收标准。 +## 文档导航 + +- 使用方式 +- 跨阶段约束 +- M1:基础骨架(必须) +- M2:邮件读取链路(必须,已完成) +- M3:patch 工作流(必须,已完成) +- M4:Code Preview Vim(VM1,必须) +- M5:Code Preview 外部 Vim(VM2,增强,已完成) +- M6:真实 IMAP 接入与自邮箱订阅(必须,已完成) +- M7:回信编辑与预览(必须,已完成) +- M8:Send Email 发送链路(MVP,必须,已完成) +- M9:过滤规则(必须) +- M10:配置体验增强(可选,低优先) +- 维护规则 + ## 使用方式 - 里程碑按顺序推进,默认前一阶段完成后进入下一阶段。 - 每个阶段都要求可演示、可回归、可记录,不接受“仅代码提交”。 -- 阶段完成后,同步更新 `docs/design.md` 的已决策项与风险项。 +- 阶段完成后,同步更新 `docs/architecture/design.md` 的已决策项与风险项。 ## 跨阶段约束 @@ -149,7 +165,7 @@ TUI 主循环,确保项目具备后续迭代基础。 3. 文本缓冲区:支持最小移动、插入、删除、保存能力。 4. 渲染与提示:展示 mode/dirty/command 状态,确保切换无残影。 5. 回归测试:未进入编辑态时,现有全局键位行为保持不变。 -6. 文档同步:与 `docs/vim-mvp-milestones.md`、`docs/code-preview-vim-prototype.md` 保持一致。 +6. 文档同步:与 `docs/milestones/vim-mvp-milestones.md`、`docs/specs/code-preview-vim-prototype.md` 保持一致。 ### 交付物 @@ -296,8 +312,8 @@ TUI 主循环,确保项目具备后续迭代基础。 - 标准回信头部/正文构造器(内核风格)。 - `Send Preview` 预览能力与确认门控。 - 回信相关设计文档: - - `docs/reply-format-spec.md` - - `docs/reply-mvp-milestones.md` + - `docs/specs/reply-format-spec.md` + - `docs/milestones/reply-mvp-milestones.md` ### 验收标准 diff --git a/docs/reply-mvp-milestones.md b/docs/milestones/reply-mvp-milestones.md similarity index 95% rename from docs/reply-mvp-milestones.md rename to docs/milestones/reply-mvp-milestones.md index 905ab5a..ff3a61b 100644 --- a/docs/reply-mvp-milestones.md +++ b/docs/milestones/reply-mvp-milestones.md @@ -5,7 +5,14 @@ 1. `Patch Preview` 的 Vim 模式先完成(VM1)。 2. SMTP 链路先打通(MVP 可直接走 `git send-email`)。 -详细格式规范见 `docs/reply-format-spec.md`。 +详细格式规范见 `docs/specs/reply-format-spec.md`。 + +## 文档导航 + +- RM0:前置依赖就绪(必须) +- RM1:Patch Preview 一步回信(MVP,必须,已完成) +- RM2:自实现 SMTP(增强) +- 非目标(Reply MVP 不做) ## RM0:前置依赖就绪(必须) diff --git a/docs/vim-mvp-milestones.md b/docs/milestones/vim-mvp-milestones.md similarity index 97% rename from docs/vim-mvp-milestones.md rename to docs/milestones/vim-mvp-milestones.md index 8f84878..0dbcee8 100644 --- a/docs/vim-mvp-milestones.md +++ b/docs/milestones/vim-mvp-milestones.md @@ -1,7 +1,12 @@ # CRIEW Vim MVP 里程碑 本文档定义 `Code Preview` 编辑能力的 MVP 路线:先内联 Vim-like(VM1),再切出外部 Vim(VM2)。 -详细交互原型见 `docs/code-preview-vim-prototype.md`。 +详细交互原型见 `docs/specs/code-preview-vim-prototype.md`。 + +## 文档导航 + +- VM1:Code Preview 内联 Vim-like 模式(必须) +- VM2:切出外部 Vim 会话(增强) ## VM1:Code Preview 内联 Vim-like 模式(必须) diff --git a/docs/config.example.toml b/docs/reference/config.example.toml similarity index 100% rename from docs/config.example.toml rename to docs/reference/config.example.toml diff --git a/docs/code-preview-vim-prototype.md b/docs/specs/code-preview-vim-prototype.md similarity index 96% rename from docs/code-preview-vim-prototype.md rename to docs/specs/code-preview-vim-prototype.md index bfde398..dd65c78 100644 --- a/docs/code-preview-vim-prototype.md +++ b/docs/specs/code-preview-vim-prototype.md @@ -1,5 +1,17 @@ # Code Preview Vim 内联编辑原型设计 +## 文档导航 + +1. 背景与目标 +2. 原型范围(P0) +3. 交互原型 +4. 技术方案(原型建议) +5. 代码变更点(草案) +6. 测试原型(建议) +7. 后续迭代(P1/P2) +8. 验收标准(P0) +9. VM2 外部 Vim 会话原型(增强) + ## 1. 背景与目标 当前 `Code Browser` 页面的 `Source Preview` 是只读预览。 diff --git a/docs/reply-format-spec.md b/docs/specs/reply-format-spec.md similarity index 96% rename from docs/reply-format-spec.md rename to docs/specs/reply-format-spec.md index 765c6c0..43c37e2 100644 --- a/docs/reply-format-spec.md +++ b/docs/specs/reply-format-spec.md @@ -3,6 +3,16 @@ 本文档定义 CRIEW 在 patch review 场景下的回信格式、自动填充规则与发送策略。 目标是让回复行为与内核社区邮件习惯保持一致,并与 `Patch Preview + Vim` 工作流无缝衔接。 +## 文档导航 + +1. 范围与前置条件 +2. 触发与面板行为 +3. 头部默认填充与规范化规则 +4. 正文格式规范 +5. 发送策略 +6. 发送结果与审计 +7. 合规检查清单(MVP) + ## 1. 范围与前置条件 - 本规范仅覆盖“回复已有邮件(reply)”场景,不覆盖“发起全新线程”。 From 498d63b10b10a5fc786a6b90e6e7d7da6765ddbe Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Mon, 9 Mar 2026 19:45:55 +0800 Subject: [PATCH 11/29] feat(tui): run sync in background with progress - queue palette and mailbox-open sync requests onto background workers so the TUI stays responsive - close the command palette after sync commands are queued so status updates remain visible on the main screen - render footer sync progress for startup, manual, and auto-sync states so users can track background activity Signed-off-by: Chao Liu --- src/ui/tui.rs | 759 +++++++++++++++++++++++++++++++++++------- src/ui/tui/input.rs | 1 + src/ui/tui/palette.rs | 143 +------- src/ui/tui/render.rs | 44 ++- 4 files changed, 667 insertions(+), 280 deletions(-) diff --git a/src/ui/tui.rs b/src/ui/tui.rs index ad75cdd..8399b1d 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -335,8 +335,6 @@ struct ExternalEditorProcessResult { type ExternalEditorRunner = fn(&str, &Path) -> std::result::Result; type ReplyIdentityResolver = fn() -> std::result::Result; -type SyncRequestExecutor = - fn(&RuntimeConfig, sync_worker::SyncRequest) -> Result; type ReplySendExecutor = fn(&RuntimeConfig, &SendRequest) -> SendOutcome; type MailboxSyncSpawner = fn(RuntimeConfig, Vec) -> Receiver; @@ -479,6 +477,103 @@ impl InboxAutoSyncState { } } +#[derive(Debug, Clone, Copy)] +enum ManualSyncOrigin { + PaletteCommand, + SubscriptionOpen, +} + +impl ManualSyncOrigin { + fn log_label(self) -> &'static str { + match self { + Self::PaletteCommand => "palette", + Self::SubscriptionOpen => "subscription_open", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManualSyncRequestOutcome { + Started, + AlreadySyncing, + Busy, +} + +#[derive(Debug)] +struct ManualSyncState { + receiver: Receiver, + mailbox_order: Vec, + mailboxes: HashMap, + total: usize, + completed: usize, + succeeded: usize, + failed: usize, + total_fetched: usize, + total_inserted: usize, + total_updated: usize, + first_error: Option, +} + +impl ManualSyncState { + fn pending_count(&self) -> usize { + self.mailbox_order + .iter() + .filter(|mailbox| { + matches!( + self.mailboxes.get(mailbox.as_str()), + Some(StartupSyncMailboxStatus::Pending) + ) + }) + .count() + } + + fn inflight_mailboxes_display(&self) -> String { + let running: Vec<&str> = self + .mailbox_order + .iter() + .filter_map(|mailbox| { + matches!( + self.mailboxes.get(mailbox.as_str()), + Some(StartupSyncMailboxStatus::InFlight) + ) + .then_some(mailbox.as_str()) + }) + .collect(); + if running.is_empty() { + "-".to_string() + } else { + running.join(",") + } + } + + fn progress_summary(&self) -> String { + format!( + "{}/{} ok={} fail={} queued={} running={}", + self.completed, + self.total, + self.succeeded, + self.failed, + self.pending_count(), + self.inflight_mailboxes_display() + ) + } + + fn mailbox_states_display(&self) -> String { + self.mailbox_order + .iter() + .map(|mailbox| { + let status = self + .mailboxes + .get(mailbox.as_str()) + .copied() + .unwrap_or(StartupSyncMailboxStatus::Pending); + format!("{mailbox}:{}", status.log_label()) + }) + .collect::>() + .join(" ") + } +} + #[derive(Debug)] struct SubscriptionAutoSyncState { receiver: Option>, @@ -1128,12 +1223,13 @@ struct AppState { config_editor: ConfigEditorState, external_editor_runner: ExternalEditorRunner, reply_identity_resolver: ReplyIdentityResolver, - sync_request_executor: SyncRequestExecutor, reply_send_executor: ReplySendExecutor, mailbox_sync_spawner: MailboxSyncSpawner, + manual_sync_spawner: MailboxSyncSpawner, needs_terminal_refresh: bool, startup_sync: Option, inbox_auto_sync: Option, + manual_sync: Option, subscription_auto_sync: Option, } @@ -1240,12 +1336,13 @@ impl AppState { config_editor: ConfigEditorState::default(), external_editor_runner: run_external_editor_session, reply_identity_resolver: resolve_git_reply_identity, - sync_request_executor: run_sync_request_guarded, reply_send_executor: send_reply_message, mailbox_sync_spawner: spawn_startup_sync_worker, + manual_sync_spawner: spawn_startup_sync_worker, needs_terminal_refresh: false, startup_sync: None, inbox_auto_sync: None, + manual_sync: None, subscription_auto_sync: None, }; if state.runtime.imap.is_complete() { @@ -1429,6 +1526,16 @@ impl AppState { }) } + fn manual_sync_mailbox_status(&self, mailbox: &str) -> Option { + self.manual_sync.as_ref().and_then(|state| { + state + .mailboxes + .iter() + .find(|(name, _)| same_mailbox_name(name, mailbox)) + .map(|(_, status)| *status) + }) + } + fn inbox_auto_sync_mailbox_status(&self, mailbox: &str) -> Option { mailbox .eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) @@ -1453,6 +1560,7 @@ impl AppState { fn mailbox_sync_status(&self, mailbox: &str) -> Option { self.startup_sync_mailbox_status(mailbox) + .or_else(|| self.manual_sync_mailbox_status(mailbox)) .or_else(|| self.inbox_auto_sync_mailbox_status(mailbox)) .or_else(|| self.subscription_auto_sync_mailbox_status(mailbox)) } @@ -1464,8 +1572,16 @@ impl AppState { ) } + fn manual_sync_mailbox_pending(&self, mailbox: &str) -> bool { + matches!( + self.manual_sync_mailbox_status(mailbox), + Some(StartupSyncMailboxStatus::Pending | StartupSyncMailboxStatus::InFlight) + ) + } + fn mailbox_sync_pending(&self, mailbox: &str) -> bool { self.startup_sync_mailbox_pending(mailbox) + || self.manual_sync_mailbox_pending(mailbox) || matches!( self.inbox_auto_sync_mailbox_status(mailbox), Some(StartupSyncMailboxStatus::InFlight) @@ -1476,31 +1592,95 @@ impl AppState { ) } - fn startup_sync_progress_text(&self) -> Option { - self.startup_sync + fn background_sync_progress_text(&self) -> Option { + self.manual_sync .as_ref() .map(|state| { - let running = state.inflight_mailboxes_display(); - if running == "-" { - format!("sync {}/{}", state.completed, state.total) - } else { - format!("sync {} {}/{}", running, state.completed, state.total) - } + format!( + "sync {} {}/{} {}", + self.render_progress_bar(state.completed, state.total), + state.completed, + state.total, + state.inflight_mailboxes_display() + ) + }) + .or_else(|| { + self.startup_sync.as_ref().map(|state| { + format!( + "sync {} {}/{} {}", + self.render_progress_bar(state.completed, state.total), + state.completed, + state.total, + state.inflight_mailboxes_display() + ) + }) }) .or_else(|| { self.inbox_auto_sync .as_ref() .filter(|state| state.in_flight()) - .map(|_| "sync INBOX".to_string()) + .map(|_| { + format!( + "sync {} auto {}", + self.render_indeterminate_progress_bar(), + IMAP_INBOX_MAILBOX + ) + }) }) .or_else(|| { self.subscription_auto_sync .as_ref() .filter(|state| state.in_flight()) - .map(|state| format!("sync {}", state.inflight_mailboxes_display())) + .map(|state| { + format!( + "sync {} auto {}", + self.render_indeterminate_progress_bar(), + state.inflight_mailboxes_display() + ) + }) }) } + fn render_progress_bar(&self, completed: usize, total: usize) -> String { + const PROGRESS_BAR_WIDTH: usize = 12; + + let mut cells = vec!['.'; PROGRESS_BAR_WIDTH]; + if total == 0 { + return format!("[{}]", cells.into_iter().collect::()); + } + + let filled = completed.saturating_mul(PROGRESS_BAR_WIDTH) / total; + for cell in cells.iter_mut().take(filled.min(PROGRESS_BAR_WIDTH)) { + *cell = '='; + } + if completed < total { + let pulse_width = PROGRESS_BAR_WIDTH.saturating_sub(filled).max(1); + let pulse_offset = self.sync_animation_tick() % pulse_width; + let pulse_index = (filled + pulse_offset).min(PROGRESS_BAR_WIDTH - 1); + cells[pulse_index] = '>'; + } + + format!("[{}]", cells.into_iter().collect::()) + } + + fn render_indeterminate_progress_bar(&self) -> String { + const PROGRESS_BAR_WIDTH: usize = 12; + const RUNNER_WIDTH: usize = 3; + + let mut cells = vec!['.'; PROGRESS_BAR_WIDTH]; + let start = self.sync_animation_tick() % PROGRESS_BAR_WIDTH; + for step in 0..RUNNER_WIDTH { + let index = (start + step) % PROGRESS_BAR_WIDTH; + cells[index] = '>'; + } + + format!("[{}]", cells.into_iter().collect::()) + } + + fn sync_animation_tick(&self) -> usize { + (self.started_at.elapsed().as_millis() / 200) as usize + } + fn refresh_series_summaries(&mut self) { self.series_summaries = patch_worker::build_series_index(&self.active_thread_mailbox, &self.threads); @@ -1581,39 +1761,160 @@ impl AppState { } } - fn defer_inbox_auto_sync_for_mailbox(&mut self, mailbox: &str) { - if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { - self.defer_inbox_auto_sync(); - } - } - fn defer_subscription_auto_sync(&mut self) { if let Some(state) = self.subscription_auto_sync.as_mut() { state.next_due_at = Instant::now() + self.runtime.inbox_auto_sync_interval(); } } - fn defer_subscription_auto_sync_for_mailbox(&mut self, mailbox: &str) { - if !mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { + fn start_manual_sync( + &mut self, + requested_mailboxes: Vec, + origin: ManualSyncOrigin, + ) -> ManualSyncRequestOutcome { + let requested_mailboxes = dedup_mailboxes(requested_mailboxes); + if requested_mailboxes.is_empty() { + self.status = "sync skipped: no mailbox selected".to_string(); + tracing::info!( + op = "manual_sync", + status = "skipped", + reason = "no_mailboxes", + origin = origin.log_label() + ); + return ManualSyncRequestOutcome::Busy; + } + + if let Some(sync_state) = self.manual_sync.as_ref() { + let all_tracked = requested_mailboxes.iter().all(|mailbox| { + sync_state + .mailboxes + .keys() + .any(|tracked| same_mailbox_name(tracked, mailbox)) + }); + self.status = if all_tracked { + format!( + "sync already running in background: {}", + requested_mailboxes.join(", ") + ) + } else { + format!("background sync busy: {}", sync_state.progress_summary()) + }; + tracing::info!( + op = "manual_sync", + status = "skipped", + reason = if all_tracked { + "mailboxes_already_syncing" + } else { + "manual_sync_busy" + }, + origin = origin.log_label(), + requested_mailboxes = %requested_mailboxes.join(",") + ); + return if all_tracked { + ManualSyncRequestOutcome::AlreadySyncing + } else { + ManualSyncRequestOutcome::Busy + }; + } + + let mut skipped_mailboxes = Vec::new(); + let mut queued_mailboxes = Vec::new(); + for mailbox in requested_mailboxes { + if self.mailbox_sync_pending(&mailbox) { + skipped_mailboxes.push(mailbox); + } else { + queued_mailboxes.push(mailbox); + } + } + + if queued_mailboxes.is_empty() { + self.status = format!( + "sync already running in background: {}", + skipped_mailboxes.join(", ") + ); + tracing::info!( + op = "manual_sync", + status = "skipped", + reason = "mailboxes_already_syncing", + origin = origin.log_label(), + requested_mailboxes = %skipped_mailboxes.join(",") + ); + return ManualSyncRequestOutcome::AlreadySyncing; + } + + if queued_mailboxes + .iter() + .any(|mailbox| mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX)) + { + self.defer_inbox_auto_sync(); + } + if queued_mailboxes + .iter() + .any(|mailbox| !mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX)) + { self.defer_subscription_auto_sync(); } + + let receiver = (self.manual_sync_spawner)(self.runtime.clone(), queued_mailboxes.clone()); + self.manual_sync = Some(ManualSyncState { + receiver, + mailbox_order: queued_mailboxes.clone(), + mailboxes: queued_mailboxes + .iter() + .cloned() + .map(|mailbox| (mailbox, StartupSyncMailboxStatus::Pending)) + .collect(), + total: queued_mailboxes.len(), + completed: 0, + succeeded: 0, + failed: 0, + total_fetched: 0, + total_inserted: 0, + total_updated: 0, + first_error: None, + }); + + self.status = if skipped_mailboxes.is_empty() { + format!("sync queued in background: {}", queued_mailboxes.join(", ")) + } else { + format!( + "sync queued in background: {}; skipped already-running: {}", + queued_mailboxes.join(", "), + skipped_mailboxes.join(", ") + ) + }; + if let Some(sync_state) = self.manual_sync.as_ref() { + tracing::info!( + op = "manual_sync", + status = "started", + origin = origin.log_label(), + total = sync_state.total, + completed = sync_state.completed, + succeeded = sync_state.succeeded, + failed = sync_state.failed, + queued = sync_state.pending_count(), + running = %sync_state.inflight_mailboxes_display(), + mailbox_states = %sync_state.mailbox_states_display(), + requested_mailboxes = %sync_state.mailbox_order.join(",") + ); + } + + ManualSyncRequestOutcome::Started } - fn run_sync_request( - &self, - request: sync_worker::SyncRequest, - ) -> Result { - (self.sync_request_executor)(&self.runtime, request) + fn queue_palette_sync(&mut self, requested_mailboxes: Vec) { + let _ = self.start_manual_sync(requested_mailboxes, ManualSyncOrigin::PaletteCommand); } fn maybe_start_inbox_auto_sync(&mut self) { self.reconcile_inbox_auto_sync(); - let startup_pending = self.startup_sync_mailbox_pending(IMAP_INBOX_MAILBOX); + let inbox_sync_pending = self.startup_sync_mailbox_pending(IMAP_INBOX_MAILBOX) + || self.manual_sync_mailbox_pending(IMAP_INBOX_MAILBOX); let now = Instant::now(); let Some(state) = self.inbox_auto_sync.as_mut() else { return; }; - if state.in_flight() || startup_pending || now < state.next_due_at { + if state.in_flight() || inbox_sync_pending || now < state.next_due_at { return; } @@ -1631,14 +1932,18 @@ impl AppState { fn maybe_start_subscription_auto_sync(&mut self) { self.reconcile_subscription_auto_sync(); let mailboxes = self.enabled_background_sync_mailboxes(); - let startup_pending = mailboxes - .iter() - .any(|mailbox| self.startup_sync_mailbox_pending(mailbox)); + let background_pending = mailboxes.iter().any(|mailbox| { + self.startup_sync_mailbox_pending(mailbox) || self.manual_sync_mailbox_pending(mailbox) + }); let now = Instant::now(); let Some(state) = self.subscription_auto_sync.as_mut() else { return; }; - if mailboxes.is_empty() || state.in_flight() || startup_pending || now < state.next_due_at { + if mailboxes.is_empty() + || state.in_flight() + || background_pending + || now < state.next_due_at + { return; } @@ -1688,6 +1993,51 @@ impl AppState { } } + fn pump_manual_sync_events(&mut self) { + let mut events = Vec::new(); + let mut disconnected = false; + { + let Some(sync_state) = self.manual_sync.as_ref() else { + return; + }; + loop { + match sync_state.receiver.try_recv() { + Ok(event) => events.push(event), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + + for event in events { + self.apply_manual_sync_event(event); + } + + if disconnected && self.manual_sync.is_some() { + let (completed, total, succeeded, failed) = self + .manual_sync + .as_ref() + .map(|state| (state.completed, state.total, state.succeeded, state.failed)) + .unwrap_or((0, 0, 0, 0)); + self.manual_sync = None; + self.status = format!( + "background sync worker disconnected (completed={completed}/{total} ok={succeeded} failed={failed})" + ); + tracing::warn!( + op = "manual_sync", + status = "failed", + completed, + total, + succeeded, + failed, + "manual sync worker disconnected unexpectedly" + ); + } + } + fn pump_subscription_auto_sync_events(&mut self) { let mut events = Vec::new(); let mut disconnected = false; @@ -1727,6 +2077,121 @@ impl AppState { } } + fn apply_manual_sync_event(&mut self, event: StartupSyncEvent) { + match event { + StartupSyncEvent::MailboxStarted { + mailbox, + index, + total, + } => { + if let Some(sync_state) = self.manual_sync.as_mut() { + sync_state + .mailboxes + .insert(mailbox.clone(), StartupSyncMailboxStatus::InFlight); + } + self.status = format!("sync [{index}/{total}] syncing {mailbox} in background..."); + if let Some(sync_state) = self.manual_sync.as_ref() { + tracing::info!( + op = "manual_sync", + status = "progress", + phase = "started", + mailbox = %mailbox, + index, + total, + completed = sync_state.completed, + succeeded = sync_state.succeeded, + failed = sync_state.failed, + queued = sync_state.pending_count(), + running = %sync_state.inflight_mailboxes_display(), + mailbox_states = %sync_state.mailbox_states_display() + ); + } + } + StartupSyncEvent::MailboxFinished { + mailbox, + fetched, + inserted, + updated, + } => { + if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { + self.defer_inbox_auto_sync(); + } else { + self.defer_subscription_auto_sync(); + } + if let Some(sync_state) = self.manual_sync.as_mut() { + sync_state + .mailboxes + .insert(mailbox.clone(), StartupSyncMailboxStatus::Finished); + sync_state.completed += 1; + sync_state.succeeded += 1; + sync_state.total_fetched += fetched; + sync_state.total_inserted += inserted; + sync_state.total_updated += updated; + } + + if let Some(sync_state) = self.manual_sync.as_ref() { + self.status = format!( + "sync [{}/{}] finished {}", + sync_state.completed, sync_state.total, mailbox + ); + tracing::info!( + op = "manual_sync", + status = "succeeded", + phase = "finished", + mailbox = %mailbox, + fetched, + inserted, + updated, + completed = sync_state.completed, + total = sync_state.total, + succeeded = sync_state.succeeded, + failed = sync_state.failed, + queued = sync_state.pending_count(), + running = %sync_state.inflight_mailboxes_display(), + mailbox_states = %sync_state.mailbox_states_display() + ); + } + } + StartupSyncEvent::MailboxFailed { mailbox, error } => { + if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { + self.defer_inbox_auto_sync(); + } else { + self.defer_subscription_auto_sync(); + } + if let Some(sync_state) = self.manual_sync.as_mut() { + sync_state + .mailboxes + .insert(mailbox.clone(), StartupSyncMailboxStatus::Failed); + sync_state.completed += 1; + sync_state.failed += 1; + if sync_state.first_error.is_none() { + sync_state.first_error = Some(format!("{mailbox}: {error}")); + } + } + self.status = format!("sync failed for {mailbox}: {error}"); + if let Some(sync_state) = self.manual_sync.as_ref() { + tracing::error!( + op = "manual_sync", + status = "failed", + phase = "finished", + mailbox = %mailbox, + error = %error, + completed = sync_state.completed, + total = sync_state.total, + succeeded = sync_state.succeeded, + failed = sync_state.failed, + queued = sync_state.pending_count(), + running = %sync_state.inflight_mailboxes_display(), + mailbox_states = %sync_state.mailbox_states_display() + ); + } + } + StartupSyncEvent::WorkerCompleted => {} + } + + self.maybe_finish_manual_sync(); + } + fn apply_inbox_auto_sync_event(&mut self, event: StartupSyncEvent) { match event { StartupSyncEvent::MailboxStarted { mailbox, .. } => { @@ -1747,7 +2212,17 @@ impl AppState { state.next_due_at = Instant::now() + self.runtime.inbox_auto_sync_interval(); } if same_mailbox_name(&mailbox, &self.active_thread_mailbox) { - self.reload_mailbox_threads_preserving_selection(&mailbox); + if let Err(error) = self.reload_mailbox_threads_preserving_selection(&mailbox) { + tracing::error!( + op = "inbox_auto_sync", + status = "failed", + mailbox = %mailbox, + error = %error + ); + self.status = format!( + "background sync ok but failed to reload threads for {mailbox}: {error}" + ); + } } if inserted > 0 || updated > 0 { self.status = format!( @@ -1805,7 +2280,17 @@ impl AppState { .retain(|in_flight| !same_mailbox_name(in_flight, &mailbox)); } if same_mailbox_name(&mailbox, &self.active_thread_mailbox) { - self.reload_mailbox_threads_preserving_selection(&mailbox); + if let Err(error) = self.reload_mailbox_threads_preserving_selection(&mailbox) { + tracing::error!( + op = "subscription_auto_sync", + status = "failed", + mailbox = %mailbox, + error = %error + ); + self.status = format!( + "background sync ok but failed to reload threads for {mailbox}: {error}" + ); + } } if inserted > 0 || updated > 0 { self.status = format!( @@ -2090,13 +2575,82 @@ impl AppState { ); } + fn maybe_finish_manual_sync(&mut self) { + let Some(sync_state) = self.manual_sync.as_ref() else { + return; + }; + if sync_state.completed < sync_state.total { + return; + } + + let succeeded = sync_state.succeeded; + let failed = sync_state.failed; + let total = sync_state.total; + let total_fetched = sync_state.total_fetched; + let total_inserted = sync_state.total_inserted; + let total_updated = sync_state.total_updated; + let first_error = sync_state.first_error.clone(); + let first_error_text = first_error + .clone() + .unwrap_or_else(|| "worker reported no success".to_string()); + let mailbox_states = sync_state.mailbox_states_display(); + let active_mailbox = self.active_thread_mailbox.clone(); + let should_reload_active_mailbox = sync_state.mailboxes.iter().any(|(mailbox, status)| { + same_mailbox_name(mailbox, &active_mailbox) + && *status == StartupSyncMailboxStatus::Finished + }); + + self.manual_sync = None; + + if should_reload_active_mailbox + && let Err(error) = self.reload_mailbox_threads_preserving_selection(&active_mailbox) + { + tracing::error!( + op = "manual_sync", + status = "failed", + mailbox = %active_mailbox, + error = %error + ); + self.status = + format!("sync ok but failed to reload threads for {active_mailbox}: {error}"); + return; + } + + self.status = if failed == 0 { + format!( + "sync finished: ok={succeeded} total={total} fetched={total_fetched} inserted={total_inserted} updated={total_updated}" + ) + } else if succeeded == 0 { + format!("sync failed: {first_error_text}") + } else { + format!( + "sync finished with failures: ok={succeeded} failed={failed} fetched={total_fetched} inserted={total_inserted} updated={total_updated}" + ) + }; + tracing::info!( + op = "manual_sync", + status = if failed == 0 { + "succeeded" + } else if succeeded == 0 { + "failed" + } else { + "partial" + }, + succeeded, + failed, + total, + total_fetched, + total_inserted, + total_updated, + first_error = %first_error.as_deref().unwrap_or("-"), + mailbox_states = %mailbox_states + ); + } + fn reload_active_mailbox_threads_after_sync(&mut self) { - match mail_store::load_thread_rows_by_mailbox( - &self.runtime.database_path, - &self.active_thread_mailbox, - 500, - ) { - Ok(rows) => self.replace_threads(rows), + let mailbox = self.active_thread_mailbox.clone(); + match self.reload_mailbox_threads_preserving_selection(&mailbox) { + Ok(()) => {} Err(error) => { tracing::error!( op = "startup_sync", @@ -2112,26 +2666,14 @@ impl AppState { } } - fn reload_mailbox_threads_preserving_selection(&mut self, mailbox: &str) { - match mail_store::load_thread_rows_by_mailbox(&self.runtime.database_path, mailbox, 500) { - Ok(rows) => { - if same_mailbox_name(mailbox, &self.active_thread_mailbox) { - self.replace_threads_preserving_selection(rows); - } - } - Err(error) => { - tracing::error!( - op = "background_auto_sync", - status = "failed", - mailbox = %mailbox, - error = %error - ); - self.status = format!( - "background sync ok but failed to reload threads for {}: {}", - mailbox, error - ); - } + fn reload_mailbox_threads_preserving_selection(&mut self, mailbox: &str) -> Result<()> { + let rows = + mail_store::load_thread_rows_by_mailbox(&self.runtime.database_path, mailbox, 500)?; + if same_mailbox_name(mailbox, &self.active_thread_mailbox) { + self.replace_threads_preserving_selection(rows); } + + Ok(()) } fn to_ui_state(&self) -> UiState { @@ -2478,70 +3020,18 @@ impl AppState { return; } - tracing::info!( - op = "subscription_sync", - status = "started", - mailbox = %mailbox - ); - let request = sync_worker::SyncRequest { - mailbox: mailbox.clone(), - fixture_dir: None, - uidvalidity: None, - reconnect_attempts: PALETTE_SYNC_RECONNECT_ATTEMPTS, - }; - - let sync_result = self.run_sync_request(request); - self.defer_inbox_auto_sync_for_mailbox(&mailbox); - self.defer_subscription_auto_sync_for_mailbox(&mailbox); - - match sync_result { - Ok(summary) => match mail_store::load_thread_rows_by_mailbox( - &self.runtime.database_path, - &mailbox, - 500, - ) { - Ok(fresh_rows) => { - tracing::info!( - op = "subscription_sync", - status = "succeeded", - mailbox = %mailbox, - fetched = summary.fetched, - inserted = summary.inserted, - updated = summary.updated - ); - self.show_mailbox_threads( - &mailbox, - fresh_rows, - format!( - "synced {}: fetched={} inserted={} updated={}", - mailbox, summary.fetched, summary.inserted, summary.updated - ), - true, - ); - } - Err(error) => { - tracing::error!( - mailbox = %mailbox, - error = %error, - "sync succeeded but reload thread rows failed" - ); - self.status = format!( - "sync ok but failed to reload threads for {}: {error}", - mailbox - ); - } - }, - Err(error) => { - tracing::error!( - op = "subscription_sync", - status = "failed", - mailbox = %mailbox, - error = %error, - "subscription sync failed" - ); - self.status = format!("failed to sync {}: {error}", mailbox); + let outcome = self + .start_manual_sync(vec![mailbox.clone()], ManualSyncOrigin::SubscriptionOpen); + let background_status = match outcome { + ManualSyncRequestOutcome::Started + | ManualSyncRequestOutcome::AlreadySyncing => { + format!("{mailbox} is syncing in background; page stays responsive") } - } + ManualSyncRequestOutcome::Busy => { + "another background sync is running; page stays responsive".to_string() + } + }; + self.show_mailbox_threads(&mailbox, Vec::new(), background_status, true); } Err(error) => { tracing::error!( @@ -3224,6 +3714,13 @@ impl AppState { self.status = "command palette closed".to_string(); } + fn dismiss_palette(&mut self) { + self.palette.open = false; + self.palette.input.clear(); + self.palette.clear_completion(); + self.palette.clear_local_result(); + } + fn is_code_edit_active(&self) -> bool { self.code_edit_mode.is_active() } @@ -3764,6 +4261,21 @@ fn spawn_startup_sync_worker( receiver } +fn dedup_mailboxes(mailboxes: Vec) -> Vec { + let mut deduped: Vec = Vec::new(); + for mailbox in mailboxes { + if deduped + .iter() + .any(|existing| same_mailbox_name(existing, &mailbox)) + { + continue; + } + deduped.push(mailbox); + } + + deduped +} + fn run_sync_request_guarded( runtime: &RuntimeConfig, request: sync_worker::SyncRequest, @@ -4115,6 +4627,7 @@ fn tui_loop( // Pump worker events before drawing so each frame reflects the newest // background sync state and can request a full refresh when needed. state.pump_startup_sync_events(); + state.pump_manual_sync_events(); state.pump_inbox_auto_sync_events(); state.pump_subscription_auto_sync_events(); state.maybe_start_inbox_auto_sync(); diff --git a/src/ui/tui/input.rs b/src/ui/tui/input.rs index 135cb32..639c234 100644 --- a/src/ui/tui/input.rs +++ b/src/ui/tui/input.rs @@ -595,6 +595,7 @@ fn handle_palette_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { } value if value.split_whitespace().next() == Some("sync") => { run_palette_sync(state, value); + state.dismiss_palette(); } value if value.split_whitespace().next() == Some("config") => { run_palette_config(state, value); diff --git a/src/ui/tui/palette.rs b/src/ui/tui/palette.rs index b884c55..8b46750 100644 --- a/src/ui/tui/palette.rs +++ b/src/ui/tui/palette.rs @@ -191,148 +191,7 @@ pub(super) fn run_palette_sync(state: &mut AppState, command: &str) { command = %command, mailboxes = %mailboxes.join(",") ); - - let mut success = 0usize; - let mut failed = 0usize; - let mut total_fetched = 0usize; - let mut total_inserted = 0usize; - let mut total_updated = 0usize; - let mut first_error: Option = None; - let mut defer_inbox_auto_sync = false; - let mut defer_subscription_auto_sync = false; - let total = mailboxes.len(); - - for (index, mailbox) in mailboxes.into_iter().enumerate() { - if mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { - defer_inbox_auto_sync = true; - } - if !mailbox.eq_ignore_ascii_case(IMAP_INBOX_MAILBOX) { - defer_subscription_auto_sync = true; - } - tracing::info!( - op = "sync", - status = "progress", - phase = "started", - mailbox = %mailbox, - index = index + 1, - total, - completed = success + failed, - succeeded = success, - failed - ); - let request = sync_worker::SyncRequest { - mailbox: mailbox.clone(), - fixture_dir: None, - uidvalidity: None, - reconnect_attempts: PALETTE_SYNC_RECONNECT_ATTEMPTS, - }; - - match state.run_sync_request(request) { - Ok(summary) => { - success += 1; - total_fetched += summary.fetched; - total_inserted += summary.inserted; - total_updated += summary.updated; - tracing::info!( - op = "sync", - status = "succeeded", - phase = "finished", - mailbox = %mailbox, - index = index + 1, - total, - completed = success + failed, - succeeded = success, - failed, - fetched = summary.fetched, - inserted = summary.inserted, - updated = summary.updated - ); - } - Err(error) => { - failed += 1; - if first_error.is_none() { - first_error = Some(format!("{mailbox}: {error}")); - } - tracing::error!( - op = "sync", - status = "failed", - phase = "finished", - mailbox = %mailbox, - index = index + 1, - total, - completed = success + failed, - succeeded = success, - failed, - error = %error - ); - } - } - } - - if defer_inbox_auto_sync { - state.defer_inbox_auto_sync(); - } - if defer_subscription_auto_sync { - state.defer_subscription_auto_sync(); - } - - if success > 0 - && let Ok(rows) = mail_store::load_thread_rows_by_mailbox( - &state.runtime.database_path, - &state.active_thread_mailbox, - 500, - ) - { - state.replace_threads(rows); - } - - let first_error_text = first_error - .as_deref() - .unwrap_or("unknown error") - .to_string(); - - state.status = if failed == 0 { - format!( - "sync ok: mailboxes={} fetched={} inserted={} updated={}", - success, total_fetched, total_inserted, total_updated - ) - } else if success == 0 { - format!("sync failed: {}", first_error_text) - } else { - format!( - "sync partial: ok={} failed={} fetched={} inserted={} updated={} first_error={}", - success, failed, total_fetched, total_inserted, total_updated, first_error_text - ) - }; - - tracing::info!( - op = "sync", - status = if failed == 0 { - "succeeded" - } else if success == 0 { - "failed" - } else { - "partial" - }, - command = %command, - success, - failed, - fetched = total_fetched, - inserted = total_inserted, - updated = total_updated, - first_error = %first_error_text - ); - - if failed > 0 { - tracing::error!( - op = "sync", - status = "failed", - command = %command, - success, - failed, - first_error = %first_error_text - ); - } + state.queue_palette_sync(mailboxes); } pub(super) fn apply_palette_completion(state: &mut AppState) { diff --git a/src/ui/tui/render.rs b/src/ui/tui/render.rs index 55be784..f35966a 100644 --- a/src/ui/tui/render.rs +++ b/src/ui/tui/render.rs @@ -40,7 +40,24 @@ pub(super) fn draw( .split(frame.area()); let uptime = state.started_at.elapsed().as_secs(); - let mut header = vec![ + let sync_progress_text = state + .background_sync_progress_text() + .map(|value| sanitize_inline_ui_text(&value)); + let header_sections = if let Some(progress_text) = sync_progress_text.as_ref() { + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(1), + Constraint::Length(progress_text.chars().count().min(64) as u16 + 1), + ]) + .split(areas[0]) + } else { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(1)]) + .split(areas[0]) + }; + let header = vec![ Span::styled( " CRIEW ", Style::default() @@ -81,21 +98,18 @@ pub(super) fn draw( .add_modifier(Modifier::DIM), ), ]; - if let Some(sync) = state.startup_sync_progress_text() { - header.push(Span::styled( - " | ", - Style::default().fg(Color::White).bg(HEADER_BG), - )); - header.push(Span::styled( - sanitize_inline_ui_text(&sync), - Style::default() - .fg(Color::Yellow) - .bg(HEADER_BG) - .add_modifier(Modifier::BOLD), - )); - } + let header_background = Paragraph::new("").style(Style::default().bg(HEADER_BG)); + frame.render_widget(header_background, areas[0]); + let header_widget = Paragraph::new(Line::from(header)).style(Style::default().bg(HEADER_BG)); - frame.render_widget(header_widget, areas[0]); + frame.render_widget(header_widget, header_sections[0]); + + if let Some(progress_text) = sync_progress_text { + let progress = Paragraph::new(format!("{progress_text} ")) + .alignment(Alignment::Right) + .style(Style::default().fg(Color::Yellow).bg(HEADER_BG)); + frame.render_widget(progress, header_sections[1]); + } match state.ui_page { UiPage::Mail => { From a74fee83583d0bc57917e32e4febdfc883662a85 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Mon, 9 Mar 2026 19:46:01 +0800 Subject: [PATCH 12/29] test(tui): cover background sync progress workflows - split background-sync TUI regression coverage out from the feature implementation commit - add manual-sync state-machine coverage for duplicate requests, busy workers, failures, disconnects, and completion refresh - add non-blocking mailbox-open, sync palette dismissal, and footer progress rendering coverage for startup, manual, and auto-sync states Signed-off-by: Chao Liu --- src/ui/tui/tests.rs | 575 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 536 insertions(+), 39 deletions(-) diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 45e4a8d..9afc431 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -30,12 +30,13 @@ use super::preview::preview_warning_message; use super::reply::ReplyIdentity; use super::{ AppState, CodeEditMode, CodePaneFocus, ExternalEditorProcessResult, LoopAction, MY_INBOX_LABEL, - Pane, ReplyEditMode, ReplySection, StartupSyncEvent, StartupSyncMailboxStatus, - StartupSyncState, SubscriptionItem, UiPage, catch_sync_panic, code_edit_cursor_position, draw, - extract_mail_body_preview, extract_mail_preview, handle_key_event, is_palette_open_shortcut, - is_palette_toggle, load_source_file_preview, mail_page_panes, matching_commands, - pick_external_editor, resolve_palette_local_workdir, run_external_editor_session_with, - sanitize_inline_ui_text, subscription_line, thread_line, + ManualSyncOrigin, ManualSyncRequestOutcome, ManualSyncState, Pane, ReplyEditMode, ReplySection, + StartupSyncEvent, StartupSyncMailboxStatus, StartupSyncState, SubscriptionItem, UiPage, + catch_sync_panic, code_edit_cursor_position, draw, extract_mail_body_preview, + extract_mail_preview, handle_key_event, is_palette_open_shortcut, is_palette_toggle, + load_source_file_preview, mail_page_panes, matching_commands, pick_external_editor, + resolve_palette_local_workdir, run_external_editor_session_with, sanitize_inline_ui_text, + subscription_line, thread_line, }; fn temp_dir(label: &str) -> PathBuf { @@ -101,6 +102,17 @@ fn sample_thread_in_thread( } } +fn rendered_row_text(terminal: &Terminal, row: u16) -> String { + let buffer = terminal.backend().buffer(); + let width = buffer.area().width as usize; + let start = row as usize * width; + let end = start + width; + buffer.content()[start..end] + .iter() + .map(|cell| cell.symbol()) + .collect::() +} + fn test_runtime_in(root: PathBuf) -> RuntimeConfig { RuntimeConfig { config_path: root.join("config.toml"), @@ -221,6 +233,43 @@ fn startup_sync_state(mailboxes: &[(&str, StartupSyncMailboxStatus)]) -> Startup } } +fn manual_sync_state(mailboxes: &[(&str, StartupSyncMailboxStatus)]) -> ManualSyncState { + let (_sender, receiver) = mpsc::channel(); + ManualSyncState { + receiver, + mailbox_order: mailboxes + .iter() + .map(|(mailbox, _)| (*mailbox).to_string()) + .collect(), + mailboxes: mailboxes + .iter() + .map(|(mailbox, status)| ((*mailbox).to_string(), *status)) + .collect(), + total: mailboxes.len(), + completed: mailboxes + .iter() + .filter(|(_, status)| { + matches!( + status, + StartupSyncMailboxStatus::Finished | StartupSyncMailboxStatus::Failed + ) + }) + .count(), + succeeded: mailboxes + .iter() + .filter(|(_, status)| matches!(status, StartupSyncMailboxStatus::Finished)) + .count(), + failed: mailboxes + .iter() + .filter(|(_, status)| matches!(status, StartupSyncMailboxStatus::Failed)) + .count(), + total_fetched: 0, + total_inserted: 0, + total_updated: 0, + first_error: None, + } +} + fn test_bootstrap(runtime: &RuntimeConfig) -> BootstrapState { BootstrapState { db: DatabaseState { @@ -307,6 +356,31 @@ fn inbox_auto_sync_waits_for_startup_sync_to_finish() { ); } +#[test] +fn inbox_auto_sync_waits_for_manual_sync_to_finish() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.mailbox_sync_spawner = mailbox_sync_spawner_stub; + state.manual_sync = Some(manual_sync_state(&[( + IMAP_INBOX_MAILBOX, + StartupSyncMailboxStatus::InFlight, + )])); + state + .inbox_auto_sync + .as_mut() + .expect("inbox auto-sync state") + .next_due_at = Instant::now() - Duration::from_secs(1); + + state.maybe_start_inbox_auto_sync(); + + assert!( + state + .inbox_auto_sync + .as_ref() + .and_then(|sync| sync.receiver.as_ref()) + .is_none() + ); +} + #[test] fn subscription_auto_sync_starts_when_due_for_enabled_linux_subscription() { let mut state = AppState::new(vec![], test_runtime()); @@ -367,6 +441,38 @@ fn subscription_auto_sync_waits_for_startup_sync_to_finish() { ); } +#[test] +fn subscription_auto_sync_waits_for_manual_sync_to_finish() { + let mut state = AppState::new(vec![], test_runtime()); + state.mailbox_sync_spawner = mailbox_sync_spawner_stub; + let qemu_devel_index = state + .subscriptions + .iter() + .position(|item| item.mailbox == "qemu-devel") + .expect("qemu-devel subscription exists"); + state.subscriptions[qemu_devel_index].enabled = true; + state.reconcile_subscription_auto_sync(); + state.manual_sync = Some(manual_sync_state(&[( + "qemu-devel", + StartupSyncMailboxStatus::InFlight, + )])); + state + .subscription_auto_sync + .as_mut() + .expect("subscription auto-sync state") + .next_due_at = Instant::now() - Duration::from_secs(1); + + state.maybe_start_subscription_auto_sync(); + + assert!( + state + .subscription_auto_sync + .as_ref() + .and_then(|sync| sync.receiver.as_ref()) + .is_none() + ); +} + fn external_editor_mock_success( _editor: &str, file_path: &Path, @@ -427,26 +533,15 @@ fn reply_send_mock_failure(_runtime: &RuntimeConfig, _request: &SendRequest) -> } } -fn sync_request_mock_success( - _runtime: &RuntimeConfig, - request: crate::app::sync::SyncRequest, -) -> crate::infra::error::Result { - Ok(crate::app::sync::SyncSummary { - mailbox: request.mailbox, - source: "mock".to_string(), - fetched: 1, - inserted: 1, - updated: 0, - rebuilt_roots: 0, - mailbox_rebuilt: false, - uidvalidity: 1, - checkpoint_last_seen_uid: 1, - checkpoint_highest_modseq: Some(1), - checkpoint_synced_at: Some("2026-03-07T10:00:02Z".to_string()), - }) +fn mailbox_sync_spawner_stub( + _runtime: RuntimeConfig, + _mailboxes: Vec, +) -> mpsc::Receiver { + let (_sender, receiver) = mpsc::channel(); + receiver } -fn mailbox_sync_spawner_stub( +fn manual_sync_spawner_idle( _runtime: RuntimeConfig, _mailboxes: Vec, ) -> mpsc::Receiver { @@ -454,6 +549,44 @@ fn mailbox_sync_spawner_stub( receiver } +fn manual_sync_spawner_seed_success( + runtime: RuntimeConfig, + mailboxes: Vec, +) -> mpsc::Receiver { + let (sender, receiver) = mpsc::channel(); + let total = mailboxes.len(); + + for (index, mailbox) in mailboxes.into_iter().enumerate() { + sender + .send(StartupSyncEvent::MailboxStarted { + mailbox: mailbox.clone(), + index: index + 1, + total, + }) + .expect("send mailbox started"); + seed_mailbox_thread( + &runtime.database_path, + &mailbox, + index as u32 + 1, + &format!("{mailbox}-{index}@example.com"), + &format!("{mailbox} thread"), + ); + sender + .send(StartupSyncEvent::MailboxFinished { + mailbox, + fetched: 1, + inserted: 1, + updated: 0, + }) + .expect("send mailbox finished"); + } + sender + .send(StartupSyncEvent::WorkerCompleted) + .expect("send worker completed"); + + receiver +} + fn type_text(state: &mut AppState, text: &str) { for character in text.chars() { let _ = handle_key_event( @@ -2494,9 +2627,9 @@ fn startup_sync_failure_for_empty_inbox_falls_back_to_cached_mailbox() { } #[test] -fn command_palette_sync_resets_my_inbox_auto_sync_deadline() { +fn command_palette_sync_queues_background_job_and_resets_my_inbox_auto_sync_deadline() { let mut state = AppState::new(vec![], test_runtime_with_imap()); - state.sync_request_executor = sync_request_mock_success; + state.manual_sync_spawner = manual_sync_spawner_idle; state .inbox_auto_sync .as_mut() @@ -2505,7 +2638,8 @@ fn command_palette_sync_resets_my_inbox_auto_sync_deadline() { run_palette_sync(&mut state, "sync INBOX"); - assert!(state.status.contains("sync ok")); + assert!(state.status.contains("sync queued in background")); + assert!(state.manual_sync.is_some()); assert!( state .inbox_auto_sync @@ -2517,7 +2651,7 @@ fn command_palette_sync_resets_my_inbox_auto_sync_deadline() { } #[test] -fn foreground_inbox_sync_defers_next_auto_sync_tick() { +fn opening_empty_inbox_queues_background_sync_and_defers_next_auto_sync_tick() { let root = temp_dir("imap-open-inbox-sync"); let runtime = test_runtime_with_imap_in(root.clone()); fs::create_dir_all(runtime.database_path.parent().expect("db parent")) @@ -2525,7 +2659,7 @@ fn foreground_inbox_sync_defers_next_auto_sync_tick() { db::initialize(&runtime.database_path).expect("initialize db"); let mut state = AppState::new(vec![], runtime); - state.sync_request_executor = sync_request_mock_success; + state.manual_sync_spawner = manual_sync_spawner_idle; state .inbox_auto_sync .as_mut() @@ -2534,7 +2668,10 @@ fn foreground_inbox_sync_defers_next_auto_sync_tick() { state.open_threads_for_selected_subscription(); - assert!(state.status.contains("synced INBOX")); + assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + assert!(state.threads.is_empty()); + assert!(state.status.contains("syncing in background")); + assert!(state.manual_sync.is_some()); assert!( state .inbox_auto_sync @@ -2548,9 +2685,98 @@ fn foreground_inbox_sync_defers_next_auto_sync_tick() { } #[test] -fn command_palette_sync_resets_subscription_auto_sync_deadline() { +fn manual_sync_same_mailbox_request_reports_already_syncing() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.manual_sync = Some(manual_sync_state(&[( + IMAP_INBOX_MAILBOX, + StartupSyncMailboxStatus::InFlight, + )])); + + let outcome = + state.start_manual_sync(vec!["inbox".to_string()], ManualSyncOrigin::PaletteCommand); + + assert_eq!(outcome, ManualSyncRequestOutcome::AlreadySyncing); + assert!(state.status.contains("sync already running in background")); +} + +#[test] +fn manual_sync_different_mailbox_request_reports_busy() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.manual_sync = Some(manual_sync_state(&[( + "io-uring", + StartupSyncMailboxStatus::InFlight, + )])); + + let outcome = state.start_manual_sync( + vec![IMAP_INBOX_MAILBOX.to_string()], + ManualSyncOrigin::PaletteCommand, + ); + + assert_eq!(outcome, ManualSyncRequestOutcome::Busy); + assert!(state.status.contains("background sync busy")); + assert!(state.status.contains("0/1")); +} + +#[test] +fn manual_sync_dedups_case_variants_and_defers_auto_sync_deadlines() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.manual_sync_spawner = manual_sync_spawner_idle; + let io_uring_index = state + .subscriptions + .iter() + .position(|item| item.mailbox == "io-uring") + .expect("io-uring subscription exists"); + state.subscriptions[io_uring_index].enabled = true; + state.reconcile_subscription_auto_sync(); + state + .inbox_auto_sync + .as_mut() + .expect("inbox auto-sync state") + .next_due_at = Instant::now() - Duration::from_secs(1); + state + .subscription_auto_sync + .as_mut() + .expect("subscription auto-sync state") + .next_due_at = Instant::now() - Duration::from_secs(1); + + let outcome = state.start_manual_sync( + vec![ + IMAP_INBOX_MAILBOX.to_string(), + "inbox".to_string(), + "io-uring".to_string(), + "IO-URING".to_string(), + ], + ManualSyncOrigin::PaletteCommand, + ); + + assert_eq!(outcome, ManualSyncRequestOutcome::Started); + let sync_state = state.manual_sync.as_ref().expect("manual sync state"); + assert_eq!( + sync_state.mailbox_order, + vec!["INBOX".to_string(), "io-uring".to_string()] + ); + assert!( + state + .inbox_auto_sync + .as_ref() + .expect("inbox auto-sync state") + .next_due_at + > Instant::now() + Duration::from_secs(20) + ); + assert!( + state + .subscription_auto_sync + .as_ref() + .expect("subscription auto-sync state") + .next_due_at + > Instant::now() + Duration::from_secs(20) + ); +} + +#[test] +fn command_palette_sync_queues_background_job_and_resets_subscription_auto_sync_deadline() { let mut state = AppState::new(vec![], test_runtime()); - state.sync_request_executor = sync_request_mock_success; + state.manual_sync_spawner = manual_sync_spawner_idle; let io_uring_index = state .subscriptions .iter() @@ -2566,7 +2792,8 @@ fn command_palette_sync_resets_subscription_auto_sync_deadline() { run_palette_sync(&mut state, "sync io-uring"); - assert!(state.status.contains("sync ok")); + assert!(state.status.contains("sync queued in background")); + assert!(state.manual_sync.is_some()); assert!( state .subscription_auto_sync @@ -2577,6 +2804,92 @@ fn command_palette_sync_resets_subscription_auto_sync_deadline() { ); } +#[test] +fn manual_sync_completion_refreshes_active_mailbox_after_worker_finishes() { + let root = temp_dir("manual-sync-finish-refresh"); + let runtime = test_runtime_with_imap_in(root.clone()); + fs::create_dir_all(runtime.database_path.parent().expect("db parent")) + .expect("create db parent"); + db::initialize(&runtime.database_path).expect("initialize db"); + + let mut state = AppState::new(vec![], runtime); + state.manual_sync_spawner = manual_sync_spawner_seed_success; + + state.open_threads_for_selected_subscription(); + assert!(state.threads.is_empty()); + assert!(state.manual_sync.is_some()); + + state.pump_manual_sync_events(); + + assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + assert_eq!(state.threads.len(), 1); + assert!(state.status.contains("sync finished: ok=1")); + assert!(state.manual_sync.is_none()); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn manual_sync_failure_finishes_with_first_error_summary() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.manual_sync = Some(manual_sync_state(&[( + IMAP_INBOX_MAILBOX, + StartupSyncMailboxStatus::Pending, + )])); + + state.apply_manual_sync_event(StartupSyncEvent::MailboxFailed { + mailbox: IMAP_INBOX_MAILBOX.to_string(), + error: "imap unavailable".to_string(), + }); + + assert!(state.manual_sync.is_none()); + assert_eq!(state.status, "sync failed: INBOX: imap unavailable"); +} + +#[test] +fn manual_sync_partial_failure_reports_partial_summary() { + let mut state = AppState::new(vec![], test_runtime()); + state.manual_sync = Some(manual_sync_state(&[ + ("io-uring", StartupSyncMailboxStatus::Pending), + ("kvm", StartupSyncMailboxStatus::Pending), + ])); + + state.apply_manual_sync_event(StartupSyncEvent::MailboxFinished { + mailbox: "io-uring".to_string(), + fetched: 2, + inserted: 1, + updated: 0, + }); + assert!(state.manual_sync.is_some()); + + state.apply_manual_sync_event(StartupSyncEvent::MailboxFailed { + mailbox: "kvm".to_string(), + error: "network timeout".to_string(), + }); + + assert!(state.manual_sync.is_none()); + assert!(state.status.contains("sync finished with failures")); + assert!(state.status.contains("ok=1 failed=1")); + assert!(state.status.contains("fetched=2 inserted=1 updated=0")); +} + +#[test] +fn manual_sync_worker_disconnect_reports_failure_summary() { + let mut state = AppState::new(vec![], test_runtime()); + state.manual_sync_spawner = manual_sync_spawner_idle; + + let outcome = state.start_manual_sync( + vec!["io-uring".to_string()], + ManualSyncOrigin::PaletteCommand, + ); + assert_eq!(outcome, ManualSyncRequestOutcome::Started); + + state.pump_manual_sync_events(); + + assert!(state.manual_sync.is_none()); + assert!(state.status.contains("background sync worker disconnected")); +} + #[test] fn enter_on_mailbox_pending_startup_sync_stays_non_blocking() { let root = temp_dir("imap-pending-enter"); @@ -2615,6 +2928,67 @@ fn enter_on_mailbox_pending_startup_sync_stays_non_blocking() { let _ = fs::remove_dir_all(root); } +#[test] +fn enter_on_mailbox_pending_manual_sync_stays_non_blocking() { + let root = temp_dir("imap-pending-manual-enter"); + let runtime = test_runtime_with_imap_in(root.clone()); + fs::create_dir_all(runtime.database_path.parent().expect("db parent")) + .expect("create db parent"); + db::initialize(&runtime.database_path).expect("initialize db"); + + let mut state = AppState::new_with_ui_state( + vec![], + runtime, + Some(UiState { + enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string()], + enabled_group_expanded: true, + disabled_group_expanded: true, + enabled_linux_subsystem_expanded: true, + enabled_qemu_subsystem_expanded: true, + disabled_linux_subsystem_expanded: true, + disabled_qemu_subsystem_expanded: true, + imap_defaults_initialized: true, + active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + }), + ); + state.focus = Pane::Subscriptions; + state.manual_sync = Some(manual_sync_state(&[( + IMAP_INBOX_MAILBOX, + StartupSyncMailboxStatus::InFlight, + )])); + + state.open_threads_for_selected_subscription(); + + assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + assert!(state.threads.is_empty()); + assert!(state.status.contains("syncing in background")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn opening_empty_mailbox_while_other_manual_sync_is_busy_shows_busy_hint() { + let root = temp_dir("manual-sync-busy-open"); + let runtime = test_runtime_with_imap_in(root.clone()); + fs::create_dir_all(runtime.database_path.parent().expect("db parent")) + .expect("create db parent"); + db::initialize(&runtime.database_path).expect("initialize db"); + + let mut state = AppState::new(vec![], runtime); + state.manual_sync = Some(manual_sync_state(&[( + "io-uring", + StartupSyncMailboxStatus::InFlight, + )])); + + state.open_threads_for_selected_subscription(); + + assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + assert!(state.threads.is_empty()); + assert!(state.status.contains("another background sync is running")); + + let _ = fs::remove_dir_all(root); +} + #[test] fn background_success_does_not_steal_focus_from_pending_inbox() { let root = temp_dir("imap-pending-focus"); @@ -2875,7 +3249,7 @@ fn palette_escape_backspace_and_char_input_update_buffer() { #[test] fn palette_sync_command_runs_via_handle_key_event() { let mut state = AppState::new(vec![], test_runtime()); - state.sync_request_executor = sync_request_mock_success; + state.manual_sync_spawner = manual_sync_spawner_idle; state.palette.open = true; state.palette.input = "sync io-uring".to_string(); @@ -2885,7 +3259,10 @@ fn palette_sync_command_runs_via_handle_key_event() { ); assert!(matches!(action, LoopAction::Continue)); - assert!(state.status.contains("sync ok")); + assert!(state.status.contains("sync queued in background")); + assert!(state.manual_sync.is_some()); + assert!(!state.palette.open); + assert!(state.palette.input.is_empty()); } #[test] @@ -3189,7 +3566,7 @@ fn header_shows_criew_brand_and_default_footer_hides_empty_status() { } #[test] -fn startup_sync_summary_stays_in_header_not_footer_middle() { +fn startup_sync_progress_bar_renders_at_right_edge_of_header() { let runtime = test_runtime_in(PathBuf::from("/t")); let bootstrap = test_bootstrap(&runtime); let mut state = AppState::new(vec![], runtime.clone()); @@ -3205,12 +3582,132 @@ fn startup_sync_summary_stays_in_header_not_footer_middle() { .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) .expect("draw startup sync"); let rendered = format!("{}", terminal.backend()); - let progress_summary = "sync INBOX 1/3"; + let header_row = rendered_row_text(&terminal, 0); + let footer_row = rendered_row_text(&terminal, terminal.backend().buffer().area().height - 1); + let progress_text = sanitize_inline_ui_text( + &state + .background_sync_progress_text() + .expect("background sync progress"), + ); assert!(rendered.contains("Mail / inbox")); - assert!(rendered.contains(progress_summary)); + assert!(rendered.contains("sync [")); + assert!(rendered.contains("1/3")); + assert!(rendered.contains("INBOX")); assert!(rendered.contains("startup sync [1/3] syncing INBOX...")); - assert_eq!(rendered.matches(progress_summary).count(), 1); + assert!(header_row.trim_end().ends_with(&progress_text)); + assert!(header_row.ends_with(' ')); + assert!(!footer_row.contains(&progress_text)); +} + +#[test] +fn background_sync_progress_text_prefers_manual_sync_over_other_sources() { + let mut state = AppState::new(vec![], test_runtime_with_imap()); + state.manual_sync = Some(manual_sync_state(&[( + "io-uring", + StartupSyncMailboxStatus::InFlight, + )])); + state.startup_sync = Some(startup_sync_state(&[( + IMAP_INBOX_MAILBOX, + StartupSyncMailboxStatus::InFlight, + )])); + state + .inbox_auto_sync + .as_mut() + .expect("inbox auto-sync state") + .receiver = Some(mpsc::channel().1); + + let progress = state + .background_sync_progress_text() + .expect("background progress"); + + assert!(progress.contains("0/1")); + assert!(progress.contains("io-uring")); + assert!(!progress.contains("auto INBOX")); +} + +#[test] +fn background_sync_progress_text_reports_auto_sync_sources() { + let mut inbox_state = AppState::new(vec![], test_runtime_with_imap()); + inbox_state + .inbox_auto_sync + .as_mut() + .expect("inbox auto-sync state") + .receiver = Some(mpsc::channel().1); + let inbox_progress = inbox_state + .background_sync_progress_text() + .expect("inbox progress"); + assert!(inbox_progress.contains("auto INBOX")); + assert_eq!(inbox_progress.matches('>').count(), 3); + + let mut subscription_state = AppState::new(vec![], test_runtime()); + let io_uring_index = subscription_state + .subscriptions + .iter() + .position(|item| item.mailbox == "io-uring") + .expect("io-uring subscription exists"); + subscription_state.subscriptions[io_uring_index].enabled = true; + subscription_state.reconcile_subscription_auto_sync(); + let state = subscription_state + .subscription_auto_sync + .as_mut() + .expect("subscription auto-sync state"); + state.receiver = Some(mpsc::channel().1); + state.in_flight_mailboxes.insert("io-uring".to_string()); + + let subscription_progress = subscription_state + .background_sync_progress_text() + .expect("subscription progress"); + assert!(subscription_progress.contains("auto io-uring")); + assert_eq!(subscription_progress.matches('>').count(), 3); +} + +#[test] +fn progress_bar_helpers_cover_zero_total_and_completed_states() { + let state = AppState::new(vec![], test_runtime()); + + let zero_total = state.render_progress_bar(0, 0); + let completed = state.render_progress_bar(3, 3); + let indeterminate = state.render_indeterminate_progress_bar(); + + assert_eq!(zero_total, "[............]"); + assert!(completed.starts_with('[')); + assert!(completed.ends_with(']')); + assert_eq!(completed.matches('=').count(), 12); + assert_eq!(completed.matches('>').count(), 0); + assert_eq!(indeterminate.len(), 14); + assert_eq!(indeterminate.matches('>').count(), 3); +} + +#[test] +fn manual_sync_progress_bar_is_rendered_at_right_edge_of_header() { + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + state.manual_sync = Some(manual_sync_state(&[ + (IMAP_INBOX_MAILBOX, StartupSyncMailboxStatus::InFlight), + ("io-uring", StartupSyncMailboxStatus::Pending), + ])); + + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw manual sync progress"); + let rendered = format!("{}", terminal.backend()); + let header_row = rendered_row_text(&terminal, 0); + let footer_row = rendered_row_text(&terminal, terminal.backend().buffer().area().height - 1); + let progress_text = sanitize_inline_ui_text( + &state + .background_sync_progress_text() + .expect("background sync progress"), + ); + + assert!(rendered.contains("sync [")); + assert!(rendered.contains("0/2")); + assert!(rendered.contains(IMAP_INBOX_MAILBOX)); + assert!(header_row.trim_end().ends_with(&progress_text)); + assert!(header_row.ends_with(' ')); + assert!(!footer_row.contains(&progress_text)); } #[test] From 877afa602a551aee85236701d755ed9f2b12a7bf Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Mon, 9 Mar 2026 21:53:23 +0800 Subject: [PATCH 13/29] fix(reply): stabilize draft path for send-email Signed-off-by: Chen Miao --- src/infra/sendmail.rs | 54 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/infra/sendmail.rs b/src/infra/sendmail.rs index 76e8a8d..a1cffad 100644 --- a/src/infra/sendmail.rs +++ b/src/infra/sendmail.rs @@ -245,6 +245,7 @@ fn send_with_options( ); } + let draft_path = stabilize_child_path(&draft_path); let command_line = render_command_line( &resolved.display_name, &build_send_email_args(request, &draft_path), @@ -740,6 +741,16 @@ fn build_send_email_args(request: &SendRequest, draft_path: &Path) -> Vec PathBuf { + if path.is_absolute() { + return path.to_path_buf(); + } + + std::env::current_dir() + .map(|current_dir| current_dir.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) +} + fn generate_message_id(from: &str) -> String { // Reuse the sender domain when possible so generated ids look like normal // outbound mail and are easier to correlate in archives and mail clients. @@ -844,7 +855,8 @@ mod tests { GitSendEmailStatus, ReplyIdentitySource, SendRequest, SendStatus, check_with_command_path, extract_email_address, generate_message_id, normalize_message_id, normalize_output, render_command_line, render_message_file, resolve_reply_identity_with_command_path, - resolve_working_dir, send_with_command_path, send_with_options, summarize_failure, + resolve_working_dir, send_with_command_path, send_with_options, stabilize_child_path, + summarize_failure, }; fn temp_dir(label: &str) -> PathBuf { @@ -1228,6 +1240,45 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn send_passes_absolute_draft_path_when_runtime_data_dir_is_relative() { + let root = temp_dir("send-relative-data-dir"); + let kernel_tree = root.join("linux"); + fs::create_dir_all(&kernel_tree).expect("create kernel tree"); + let capture_arg = root.join("captured-draft-arg.txt"); + let fake_git = write_fake_git( + &root, + &format!( + "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'git version 2.51.0'\n exit 0\nfi\nif [ \"$1\" = \"send-email\" ] && [ \"$2\" = \"-h\" ]; then\n echo 'usage: git send-email [] ...'\n exit 129\nfi\nif [ \"$1\" = \"send-email\" ]; then\n last=''\n for arg in \"$@\"; do\n last=\"$arg\"\n done\n printf '%s' \"$last\" > '{}'\n if [ -f \"$last\" ]; then\n echo 'sent'\n exit 0\n fi\n echo \"missing draft: $last\" >&2\n exit 1\nfi\nexit 1\n", + capture_arg.display() + ), + ); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let relative_data_dir = PathBuf::from(format!( + "target/test-relative-reply-outbox-{}-{nonce}", + std::process::id() + )); + let relative_data_dir_absolute = std::env::current_dir() + .expect("current dir") + .join(&relative_data_dir); + let mut runtime = test_runtime_in(&root); + runtime.data_dir = relative_data_dir; + runtime.kernel_trees = vec![kernel_tree]; + + let outcome = send_with_command_path(&runtime, &sample_request(), Some(&fake_git)); + + assert_eq!(outcome.status, SendStatus::Sent); + let captured_arg = fs::read_to_string(&capture_arg).expect("read captured draft arg"); + assert!(Path::new(&captured_arg).is_absolute()); + assert!(captured_arg.starts_with(relative_data_dir_absolute.to_string_lossy().as_ref())); + + let _ = fs::remove_dir_all(relative_data_dir_absolute); + let _ = fs::remove_dir_all(root); + } + #[test] fn message_rendering_and_helper_outputs_follow_reply_contract() { let mut request = sample_request(); @@ -1275,6 +1326,7 @@ mod tests { ); assert_eq!(extract_email_address("invalid identity"), None); assert!(generate_message_id("No Email").ends_with("@localhost")); + assert!(stabilize_child_path(Path::new("relative/file.eml")).is_absolute()); } #[test] From 1f33bb068e9dfe12f97aee08f9eef55f9423d07b Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Mon, 9 Mar 2026 22:00:25 +0800 Subject: [PATCH 14/29] feat(tui): show active keymap scheme in header - add the custom ui.keymap value and keep its navigation fallback aligned with default bindings - render the active keymap in the header and surface the new option in config help and docs - add regression coverage for custom keymap loading and header rendering Signed-off-by: Chao Liu --- README-zh.md | 1 + README.md | 1 + docs/reference/config.example.toml | 5 ++++- src/infra/config.rs | 25 ++++++++++++++++++++++++- src/ui/tui.rs | 6 +++--- src/ui/tui/config.rs | 1 + src/ui/tui/input.rs | 2 +- src/ui/tui/render.rs | 8 ++++++++ src/ui/tui/tests.rs | 17 +++++++++++++++++ 9 files changed, 60 insertions(+), 6 deletions(-) diff --git a/README-zh.md b/README-zh.md index 7806560..9bf198b 100644 --- a/README-zh.md +++ b/README-zh.md @@ -197,6 +197,7 @@ criew tui #### 键位 - `:` 打开命令栏 +- 顶部状态栏会显示当前 keymap 方案:`default`、`vim` 或 `custom` - `y` / `n` 启用或禁用当前订阅 - `Enter` 打开当前 mailbox 或 thread - `a` apply 当前 patch series diff --git a/README.md b/README.md index 5042d17..63b065c 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,7 @@ criew tui Inside the TUI: - `:` opens the command palette +- the header shows the active keymap scheme (`default`, `vim`, or `custom`) - `y` / `n` enable or disable the selected subscription - `Enter` opens the selected mailbox or thread - `a` applies the current patch series diff --git a/docs/reference/config.example.toml b/docs/reference/config.example.toml index b8a9060..7731ed5 100644 --- a/docs/reference/config.example.toml +++ b/docs/reference/config.example.toml @@ -56,7 +56,10 @@ filter = "info" # startup_sync = true # Optional. Main-page navigation scheme. -# Supported: "default" (j/l focus, i/k move), "vim" (h/l focus, j/k move). +# Supported: +# "default" (j/l focus, i/k move) +# "vim" (h/l focus, j/k move) +# "custom" (header shows custom; navigation currently falls back to default bindings) # Default: "default" # keymap = "default" diff --git a/src/infra/config.rs b/src/infra/config.rs index 6d3557f..cb48bae 100644 --- a/src/infra/config.rs +++ b/src/infra/config.rs @@ -31,7 +31,7 @@ mailbox = "linux-kernel" [ui] startup_sync = true -# keymap = "default" # Supported: default, vim +# keymap = "default" # Supported: default, vim, custom # inbox_auto_sync_interval_secs = 30 [logging] @@ -63,6 +63,7 @@ pub enum UiKeymap { #[default] Default, Vim, + Custom, } impl UiKeymap { @@ -70,6 +71,7 @@ impl UiKeymap { match self { Self::Default => "default", Self::Vim => "vim", + Self::Custom => "custom", } } } @@ -735,6 +737,27 @@ trees = ["./linux-next"] let _ = fs::remove_dir_all(home); } + #[test] + fn loads_custom_ui_keymap_from_config() { + let base = temp_dir("config-custom-keymap"); + let config_path = base.join("config.toml"); + + fs::write( + &config_path, + r#" +[ui] +keymap = "custom" +"#, + ) + .expect("write config"); + + let loaded = load(Some(&config_path)).expect("load config"); + + assert_eq!(loaded.ui_keymap, UiKeymap::Custom); + + let _ = fs::remove_dir_all(base); + } + #[test] fn falls_back_to_config_alias_filename_when_present() { let home = temp_dir("config-legacy-home"); diff --git a/src/ui/tui.rs b/src/ui/tui.rs index 8399b1d..051eec7 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -230,7 +230,7 @@ const CONFIG_EDITOR_FIELDS: &[ConfigEditorField] = &[ }, ConfigEditorField { key: "ui.keymap", - description: "Main-page navigation scheme. default=j/l focus+i/k move, vim=h/l focus+j/k move.", + description: "Main-page navigation scheme. default=j/l+i/k, vim=h/l+j/k, custom=default fallback with custom label.", }, ConfigEditorField { key: "ui.inbox_auto_sync_interval_secs", @@ -306,14 +306,14 @@ const EXTERNAL_EDITOR_ENTRY_HINT: &str = "select a source file in Source pane, t fn main_page_focus_shortcuts(keymap: UiKeymap) -> &'static str { match keymap { - UiKeymap::Default => "j/l", + UiKeymap::Default | UiKeymap::Custom => "j/l", UiKeymap::Vim => "h/l", } } fn main_page_move_shortcuts(keymap: UiKeymap) -> &'static str { match keymap { - UiKeymap::Default => "i/k", + UiKeymap::Default | UiKeymap::Custom => "i/k", UiKeymap::Vim => "j/k", } } diff --git a/src/ui/tui/config.rs b/src/ui/tui/config.rs index 9a5d22e..cf7aa6b 100644 --- a/src/ui/tui/config.rs +++ b/src/ui/tui/config.rs @@ -830,6 +830,7 @@ fn config_value_suggestions(state: &AppState, key: Option<&String>) -> Vec [ ("default", "j/l focus, i/k move"), ("vim", "h/l focus, j/k move"), + ("custom", "custom label with default navigation fallback"), ] .iter() .map(|(value, description)| PaletteSuggestion { diff --git a/src/ui/tui/input.rs b/src/ui/tui/input.rs index 639c234..7ac9a15 100644 --- a/src/ui/tui/input.rs +++ b/src/ui/tui/input.rs @@ -21,7 +21,7 @@ pub(super) enum LoopAction { fn handle_main_page_navigation_key(state: &mut AppState, key: KeyEvent) -> bool { match state.runtime.ui_keymap { - UiKeymap::Default => match key.code { + UiKeymap::Default | UiKeymap::Custom => match key.code { KeyCode::Char('j') => { state.move_focus_previous(); true diff --git a/src/ui/tui/render.rs b/src/ui/tui/render.rs index f35966a..6161fe2 100644 --- a/src/ui/tui/render.rs +++ b/src/ui/tui/render.rs @@ -90,6 +90,14 @@ pub(super) fn draw( .add_modifier(Modifier::DIM), ), Span::styled(" | ", Style::default().fg(Color::White).bg(HEADER_BG)), + Span::styled( + format!("keymap {}", state.runtime.ui_keymap.as_str()), + Style::default() + .fg(Color::White) + .bg(HEADER_BG) + .add_modifier(Modifier::DIM), + ), + Span::styled(" | ", Style::default().fg(Color::White).bg(HEADER_BG)), Span::styled( format!("up {}s", uptime), Style::default() diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 9afc431..1126c83 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -3559,12 +3559,29 @@ fn header_shows_criew_brand_and_default_footer_hides_empty_status() { assert!(rendered.contains("CRIEW")); assert!(rendered.contains(env!("CARGO_PKG_VERSION"))); assert!(rendered.contains("Mail / inbox")); + assert!(rendered.contains("keymap default")); assert!(!rendered.contains("db schema")); assert!(!rendered.contains("db:")); assert!(!rendered.contains("status:")); assert!(!rendered.contains(" ready ")); } +#[test] +fn header_shows_custom_keymap_scheme_when_configured() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Custom; + let bootstrap = test_bootstrap(&runtime); + let state = AppState::new(vec![], runtime.clone()); + + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw custom keymap header"); + let rendered = format!("{}", terminal.backend()); + + assert!(rendered.contains("keymap custom")); +} + #[test] fn startup_sync_progress_bar_renders_at_right_edge_of_header() { let runtime = test_runtime_in(PathBuf::from("/t")); From 1bf80c283b5b6f1952f9378a5f46619f3c713a2e Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Mon, 9 Mar 2026 23:30:03 +0800 Subject: [PATCH 15/29] feat(reply): improve send preview review flow - warn when a reply draft has no authored content and highlight authored lines in preview - render send preview and reply notices as view switches within the reply panel area - add TUI regression coverage for preview styling, panel switching, and confirmation flow Signed-off-by: Chen Miao --- README-zh.md | 2 + README.md | 1 + docs/specs/reply-format-spec.md | 10 +- src/ui/tui.rs | 28 +++- src/ui/tui/render.rs | 221 ++++++++++++++++++++++++------- src/ui/tui/reply.rs | 222 +++++++++++++++++++++++++++++--- src/ui/tui/tests.rs | 161 ++++++++++++++++++++++- 7 files changed, 569 insertions(+), 76 deletions(-) diff --git a/README-zh.md b/README-zh.md index 7806560..28aff78 100644 --- a/README-zh.md +++ b/README-zh.md @@ -29,6 +29,7 @@ English README: [README.md](README.md) - 浏览 thread,识别 `[PATCH vN M/N]` patch series - 通过 `b4` apply 或导出 patch - 在 TUI 中撰写回信,并通过 `git send-email` 发送 +- `Send Preview` 会在草稿没有实际回复内容时给出警告,并高亮用户自己写的回复正文 - 浏览本地 kernel tree,支持内联 Vim-like 编辑和外部 Vim 编辑 ### 发布基线 @@ -234,6 +235,7 @@ CRIEW 的 Reply Panel 会自动填充: - `References` 同时会生成符合 kernel 邮件习惯的引用正文模板。发送时,底层走 `git send-email`。 +`Send Preview` 会把用户自己写的未引用正文高亮出来;如果预览里只有引用内容和生成的回复骨架,也会明确给出警告。 ## 延伸阅读 diff --git a/README.md b/README.md index 5042d17..0d339c7 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ refresh your checkout or reinstall the binary and bootstrap a new CRIEW runtime - kernel tree browser with source preview - inline Vim-like editing and external Vim editing - reply panel that fills `From`, `To`, `Cc`, `Subject`, `In-Reply-To`, and `References` +- send preview warnings when the draft has no authored reply text, and authored lines stand out from quoted text - real reply delivery through `git send-email` - visual config editor, command palette completion, and structured operation logs diff --git a/docs/specs/reply-format-spec.md b/docs/specs/reply-format-spec.md index 43c37e2..ff1a204 100644 --- a/docs/specs/reply-format-spec.md +++ b/docs/specs/reply-format-spec.md @@ -43,6 +43,9 @@ `Reply Panel` 发送区最小交互: - `Send Preview`:生成并展示最终待发送邮件(头部 + 正文) +- 若正文里没有任何“非空、非引用、非 `On ... wrote:`”的 authored reply line,仍允许打开预览,但必须显示显著警告 +- 预览中的 authored reply line 需要用明显不同于引用内容的视觉样式展示,方便最终人工检查 +- `Send Preview` 复用 `Reply Panel` 的同一块界面区域做视图切换,不额外再叠一个新的对话框 - `Confirm Send`:用户确认无误后触发实际发送 - `Cancel`:取消本次发送,保留当前编辑内容 @@ -137,7 +140,7 @@ MVP 通过 CRIEW 内部发送适配层完成发送: MVP 发送时序: 1. 用户在 `Reply Panel` 点击或触发 `Send Preview`。 -2. 系统渲染最终邮件快照(`From/To/Cc/Subject/In-Reply-To/References/Body`)。 +2. 系统渲染最终邮件快照(`From/To/Cc/Subject/In-Reply-To/References/Body`),并在 authored reply content 为空时显示 warning。 3. 用户执行 `Confirm Send` 后,CRIEW 调用底层发送器(MVP 为 `git send-email`)。 4. 若发送失败,停留在 `Reply Panel` 并保留内容,允许重试。 @@ -182,5 +185,6 @@ git send-email \ 3. `From` 默认来自 git email 身份,允许用户修改,但必须保持有效邮箱地址。 4. `In-Reply-To` / `References` 构造正确。 5. 正文为 `>` 引用风格;用户回复写在不带 `>` 的空白行中,引用层级由保留的历史引用体现。 -6. 用户必须先完成 `Send Preview` 确认,才允许正式发送。 -7. MVP 路径可通过底层 `git send-email` 完成实际发送并留存结果。 +6. `Send Preview` 能明确区分 authored reply line 与引用内容;若没有 authored reply line,会显示 warning,但不阻止用户完成预览确认。 +7. 用户必须先完成 `Send Preview` 确认,才允许正式发送。 +8. MVP 路径可通过底层 `git send-email` 完成实际发送并留存结果。 diff --git a/src/ui/tui.rs b/src/ui/tui.rs index 8399b1d..508588a 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -62,8 +62,8 @@ use preview::{MailPreview, load_mail_preview}; #[cfg(test)] use preview::{extract_mail_body_preview, extract_mail_preview}; use reply::{ - PreparedReplyMessage, ReplyIdentity, ReplyPreview, ReplyPreviewRequest, ReplySeed, - build_reply_seed, prepare_reply_message, render_reply_preview, + PreparedReplyMessage, ReplyIdentity, ReplyPreview, ReplyPreviewLine, ReplyPreviewRequest, + ReplySeed, build_reply_seed, prepare_reply_message, render_reply_preview, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -846,7 +846,9 @@ struct ReplyPanelState { preview_open: bool, preview_scroll: u16, preview_rendered: String, + preview_lines: Vec, preview_errors: Vec, + preview_warnings: Vec, preview_confirmed: bool, preview_confirmed_at: Option, reply_notice: Option, @@ -879,7 +881,9 @@ impl ReplyPanelState { preview_open: false, preview_scroll: 0, preview_rendered: String::new(), + preview_lines: Vec::new(), preview_errors: Vec::new(), + preview_warnings: Vec::new(), preview_confirmed: false, preview_confirmed_at: None, reply_notice: None, @@ -3154,7 +3158,12 @@ impl AppState { }; panel.reply_notice = None; - let ReplyPreview { content, errors } = render_reply_preview(ReplyPreviewRequest { + let ReplyPreview { + content, + lines, + errors, + warnings, + } = render_reply_preview(ReplyPreviewRequest { from: &panel.from, to: &panel.to, cc: &panel.cc, @@ -3165,14 +3174,21 @@ impl AppState { self_addresses: &panel.self_addresses, }); panel.preview_rendered = content; + panel.preview_lines = lines; panel.preview_errors = errors; + panel.preview_warnings = warnings; panel.preview_open = true; panel.preview_scroll = 0; - if panel.preview_errors.is_empty() { - self.status = "send preview ready; press Enter/c to confirm".to_string(); - } else { + if !panel.preview_errors.is_empty() { self.status = format!("send preview blocked: {}", panel.preview_errors.join("; ")); + } else if !panel.preview_warnings.is_empty() { + self.status = format!( + "send preview warning: {}; press Enter/c to confirm anyway", + panel.preview_warnings.join("; ") + ); + } else { + self.status = "send preview ready; press Enter/c to confirm".to_string(); } } diff --git a/src/ui/tui/render.rs b/src/ui/tui/render.rs index f35966a..900870a 100644 --- a/src/ui/tui/render.rs +++ b/src/ui/tui/render.rs @@ -17,6 +17,7 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragra use super::config::draw_config_editor; use super::palette::palette_overlay_suggestions; +use super::reply::ReplyPreviewLineKind; use super::*; const REPLY_BODY_GUIDE_COLUMN: usize = 80; @@ -748,8 +749,16 @@ fn draw_reply_panel(frame: &mut Frame<'_>, state: &AppState) { }; let area = centered_rect(88, 84, frame.area()); - frame.render_widget(Clear, area); + if panel.preview_open { + draw_send_preview_panel(frame, area, panel); + return; + } + if panel.reply_notice.is_some() { + draw_reply_notice_panel(frame, area, panel); + return; + } + frame.render_widget(Clear, area); let title = format!( "Reply Panel [{} dirty:{} confirmed:{} focus:{}]", panel.mode.label(), @@ -828,12 +837,6 @@ fn draw_reply_panel(frame: &mut Frame<'_>, state: &AppState) { if let Some(cursor) = reply_panel_cursor_position(panel, header_inner, body_content_area) { frame.set_cursor_position(cursor); } - - if panel.preview_open { - draw_send_preview_overlay(frame, panel); - } else if panel.reply_notice.is_some() { - draw_reply_notice_overlay(frame, panel); - } } fn render_reply_header_content(panel: &ReplyPanelState) -> String { @@ -1044,14 +1047,17 @@ fn reply_body_cursor_position( )) } -fn draw_send_preview_overlay(frame: &mut Frame<'_>, panel: &ReplyPanelState) { - let area = centered_rect(82, 78, frame.area()); +fn draw_send_preview_panel(frame: &mut Frame<'_>, area: Rect, panel: &ReplyPanelState) { frame.render_widget(Clear, area); - let title = if panel.preview_errors.is_empty() { - "Send Preview" - } else { + let title = if !panel.preview_errors.is_empty() { "Send Preview [invalid]" + } else if !panel.preview_warnings.is_empty() { + "Send Preview [warning]" + } else if has_authored_reply_lines(panel) { + "Send Preview [reply highlighted]" + } else { + "Send Preview" }; let block = Block::default() .title(title) @@ -1060,47 +1066,164 @@ fn draw_send_preview_overlay(frame: &mut Frame<'_>, panel: &ReplyPanelState) { let inner = block.inner(area); frame.render_widget(block, area); - let content_area = if panel.preview_errors.is_empty() { - inner - } else { - let error_text = panel - .preview_errors - .iter() - .map(|value| format!("- {value}")) - .collect::>() - .join("\n"); - let error_height = error_text - .lines() - .count() - .min(inner.height.saturating_sub(1) as usize) as u16; - let sections = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(error_height), Constraint::Min(1)]) - .split(inner); - let warning = Paragraph::new(error_text) - .style( - Style::default() - .fg(Color::Black) - .bg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - .wrap(Wrap { trim: false }); - frame.render_widget(warning, sections[0]); - sections[1] - }; - - let preview = Paragraph::new(panel.preview_rendered.clone()) + let content_area = draw_send_preview_messages(frame, inner, panel); + let preview = Paragraph::new(render_reply_preview_text(panel)) .scroll((panel.preview_scroll, 0)) .wrap(Wrap { trim: false }); frame.render_widget(preview, content_area); } -fn draw_reply_notice_overlay(frame: &mut Frame<'_>, panel: &ReplyPanelState) { +fn draw_send_preview_messages(frame: &mut Frame<'_>, area: Rect, panel: &ReplyPanelState) -> Rect { + let mut remaining_height = area.height.saturating_sub(1); + let error_height = preview_message_height(&panel.preview_errors, remaining_height); + remaining_height = remaining_height.saturating_sub(error_height); + let warning_height = preview_message_height(&panel.preview_warnings, remaining_height); + remaining_height = remaining_height.saturating_sub(warning_height); + let info_messages = preview_info_messages(panel); + let info_height = preview_message_height(&info_messages, remaining_height); + + if error_height == 0 && warning_height == 0 && info_height == 0 { + return area; + } + + let mut constraints = Vec::new(); + if error_height > 0 { + constraints.push(Constraint::Length(error_height)); + } + if warning_height > 0 { + constraints.push(Constraint::Length(warning_height)); + } + if info_height > 0 { + constraints.push(Constraint::Length(info_height)); + } + constraints.push(Constraint::Min(1)); + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + let mut section_index = 0usize; + if error_height > 0 { + draw_send_preview_message_block( + frame, + sections[section_index], + &panel.preview_errors, + Style::default() + .fg(Color::White) + .bg(Color::Red) + .add_modifier(Modifier::BOLD), + ); + section_index += 1; + } + if warning_height > 0 { + draw_send_preview_message_block( + frame, + sections[section_index], + &panel.preview_warnings, + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + section_index += 1; + } + if info_height > 0 { + draw_send_preview_message_block( + frame, + sections[section_index], + &info_messages, + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + section_index += 1; + } + + sections[section_index] +} + +fn preview_message_height(messages: &[String], remaining_height: u16) -> u16 { + if messages.is_empty() || remaining_height == 0 { + return 0; + } + + let line_count = messages + .iter() + .map(|message| message.lines().count().max(1)) + .sum::(); + line_count.min(remaining_height as usize) as u16 +} + +fn draw_send_preview_message_block( + frame: &mut Frame<'_>, + area: Rect, + messages: &[String], + style: Style, +) { + let text = messages + .iter() + .map(|value| format!("- {value}")) + .collect::>() + .join("\n"); + let paragraph = Paragraph::new(text).style(style).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + +fn render_reply_preview_text(panel: &ReplyPanelState) -> Text<'static> { + Text::from( + panel + .preview_lines + .iter() + .map(|line| { + Line::from(Span::styled( + line.text.clone(), + reply_preview_line_style(line.kind), + )) + }) + .collect::>>(), + ) +} + +fn preview_info_messages(panel: &ReplyPanelState) -> Vec { + if has_authored_reply_lines(panel) { + vec!["Your authored reply lines are highlighted below.".to_string()] + } else { + Vec::new() + } +} + +fn has_authored_reply_lines(panel: &ReplyPanelState) -> bool { + panel + .preview_lines + .iter() + .any(|line| matches!(line.kind, ReplyPreviewLineKind::Authored)) +} + +fn reply_preview_line_style(kind: ReplyPreviewLineKind) -> Style { + match kind { + ReplyPreviewLineKind::Header => Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ReplyPreviewLineKind::Blank => Style::default(), + ReplyPreviewLineKind::Authored => Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + ReplyPreviewLineKind::QuoteAttribution => Style::default().fg(Color::Cyan), + ReplyPreviewLineKind::Quoted => Style::default().fg(Color::White), + ReplyPreviewLineKind::Placeholder => Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM), + } +} + +fn draw_reply_notice_panel(frame: &mut Frame<'_>, area: Rect, panel: &ReplyPanelState) { let Some(notice) = panel.reply_notice.as_ref() else { return; }; - let area = centered_rect(62, 34, frame.area()); frame.render_widget(Clear, area); let border = match notice.kind { @@ -1115,6 +1238,14 @@ fn draw_reply_notice_overlay(frame: &mut Frame<'_>, panel: &ReplyPanelState) { frame.render_widget(block, area); frame.render_widget(Clear, inner); + let content_sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(35), + Constraint::Min(3), + Constraint::Percentage(35), + ]) + .split(inner); let text = format!("{}\n\n{}", notice.message, notice.hint); let paragraph = Paragraph::new(Text::from(text)) .alignment(Alignment::Center) @@ -1124,7 +1255,7 @@ fn draw_reply_notice_overlay(frame: &mut Frame<'_>, panel: &ReplyPanelState) { .add_modifier(Modifier::BOLD), ) .wrap(Wrap { trim: false }); - frame.render_widget(paragraph, inner); + frame.render_widget(paragraph, content_sections[1]); } fn code_edit_source_line_logical_row(buffer_row: usize) -> usize { diff --git a/src/ui/tui/reply.rs b/src/ui/tui/reply.rs index 9532550..ac04455 100644 --- a/src/ui/tui/reply.rs +++ b/src/ui/tui/reply.rs @@ -33,10 +33,28 @@ pub(super) struct ReplySeed { pub body: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ReplyPreviewLineKind { + Header, + Blank, + Authored, + QuoteAttribution, + Quoted, + Placeholder, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ReplyPreviewLine { + pub kind: ReplyPreviewLineKind, + pub text: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct ReplyPreview { pub content: String, + pub lines: Vec, pub errors: Vec, + pub warnings: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -51,6 +69,7 @@ pub(super) struct PreparedReplyMessage { pub body: String, } +#[derive(Debug, Clone, Copy)] pub(super) struct ReplyPreviewRequest<'a> { pub from: &'a str, pub to: &'a str, @@ -157,9 +176,13 @@ pub(super) fn build_reply_seed( pub(super) fn render_reply_preview(request: ReplyPreviewRequest<'_>) -> ReplyPreview { let (prepared, errors) = prepare_reply_message(request); + let body_lines = render_reply_preview_body_lines(request.body); + let lines = render_prepared_reply_preview_lines(&prepared, &body_lines); ReplyPreview { - content: render_prepared_reply_preview(&prepared), + content: render_reply_preview_text(&lines), + lines, errors, + warnings: build_reply_preview_warnings(&body_lines), } } @@ -239,17 +262,53 @@ pub(super) fn prepare_reply_message( ) } -fn render_prepared_reply_preview(message: &PreparedReplyMessage) -> String { - format!( - "From: {}\nTo: {}\nCc: {}\nSubject: {}\nIn-Reply-To: {}\nReferences: {}\n\n{}", - message.from, - render_recipient_line(&message.to), - render_recipient_line(&message.cc), - message.subject, - render_message_id(&message.in_reply_to), - render_references_line(&message.references), - message.body, - ) +fn render_prepared_reply_preview_lines( + message: &PreparedReplyMessage, + body_lines: &[ReplyPreviewLine], +) -> Vec { + let mut lines = vec![ + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!("From: {}", message.from), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!("To: {}", render_recipient_line(&message.to)), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!("Cc: {}", render_recipient_line(&message.cc)), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!("Subject: {}", message.subject), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!("In-Reply-To: {}", render_message_id(&message.in_reply_to)), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Header, + text: format!( + "References: {}", + render_references_line(&message.references) + ), + }, + ReplyPreviewLine { + kind: ReplyPreviewLineKind::Blank, + text: String::new(), + }, + ]; + lines.extend(body_lines.iter().cloned()); + lines +} + +fn render_reply_preview_text(lines: &[ReplyPreviewLine]) -> String { + lines + .iter() + .map(|line| line.text.as_str()) + .collect::>() + .join("\n") } fn git_config_value(args: &[&str]) -> std::result::Result, String> { @@ -475,18 +534,62 @@ fn normalize_message_id(value: &str) -> String { } fn render_reply_body(body: &[String]) -> String { - let rendered = body + render_reply_preview_text(&render_reply_preview_body_lines(body)) +} + +fn render_reply_preview_body_lines(body: &[String]) -> Vec { + let normalized_body = body .iter() - .map(|line| line.trim_end()) - .collect::>() - .join("\n"); - if rendered.trim().is_empty() { - "".to_string() + .map(|line| line.trim_end().to_string()) + .collect::>(); + if normalized_body.iter().all(|line| line.trim().is_empty()) { + return vec![ReplyPreviewLine { + kind: ReplyPreviewLineKind::Placeholder, + text: "".to_string(), + }]; + } + + normalized_body + .into_iter() + .map(classify_reply_preview_line) + .collect() +} + +fn classify_reply_preview_line(text: String) -> ReplyPreviewLine { + let trimmed = text.trim(); + let kind = if trimmed.is_empty() { + ReplyPreviewLineKind::Blank + } else if trimmed.starts_with('>') { + ReplyPreviewLineKind::Quoted + } else if is_quote_attribution_line(trimmed) { + ReplyPreviewLineKind::QuoteAttribution + } else { + ReplyPreviewLineKind::Authored + }; + + ReplyPreviewLine { kind, text } +} + +fn build_reply_preview_warnings(body_lines: &[ReplyPreviewLine]) -> Vec { + if body_lines + .iter() + .any(|line| matches!(line.kind, ReplyPreviewLineKind::Authored)) + { + Vec::new() } else { - rendered + vec![ + "draft has no authored reply content; only quoted text and generated scaffolding are visible" + .to_string(), + ] } } +fn is_quote_attribution_line(value: &str) -> bool { + value + .strip_prefix("On ") + .is_some_and(|remaining| remaining.ends_with(" wrote:")) +} + fn render_recipient_line(values: &[String]) -> String { if values.is_empty() { "".to_string() @@ -618,8 +721,9 @@ mod tests { use crate::infra::mail_store::ThreadRow; use super::{ - ReplyIdentity, ReplyPreviewRequest, build_reply_seed, extract_email_address, - normalize_reply_subject, prepare_reply_message, render_reply_preview, + ReplyIdentity, ReplyPreviewLineKind, ReplyPreviewRequest, build_reply_seed, + extract_email_address, normalize_reply_subject, prepare_reply_message, + render_reply_preview, }; fn sample_thread(subject: &str, message_id: &str) -> ThreadRow { @@ -814,6 +918,12 @@ mod tests { .iter() .any(|value| value.contains("no recipients")) ); + assert!( + preview + .warnings + .iter() + .any(|value| value.contains("no authored reply content")) + ); assert!(preview.content.contains("To: ")); } @@ -881,6 +991,76 @@ mod tests { assert!(preview.content.contains("In-Reply-To: ")); assert!(preview.content.contains("References: ")); assert!(preview.content.ends_with("")); + assert!( + preview + .warnings + .iter() + .any(|value| value.contains("no authored reply content")) + ); + } + + #[test] + fn render_reply_preview_warns_when_body_only_contains_generated_quote_scaffolding() { + let preview = render_reply_preview(ReplyPreviewRequest { + from: "CRIEW Test ", + to: "Bob ", + cc: "", + subject: "Re: [PATCH] demo", + in_reply_to: "patch@example.com", + references: &["patch@example.com".to_string()], + body: &[ + String::new(), + "On Fri, 6 Mar 2026 09:30:00 +0000, Alice wrote:".to_string(), + "> quoted line".to_string(), + ">".to_string(), + ], + self_addresses: &[identity().email.clone()], + }); + + assert!(preview.errors.is_empty()); + assert_eq!(preview.warnings.len(), 1); + assert!( + preview.warnings[0].contains("no authored reply content"), + "unexpected warning: {}", + preview.warnings[0] + ); + assert!( + preview + .lines + .iter() + .any(|line| matches!(line.kind, ReplyPreviewLineKind::QuoteAttribution)) + ); + assert!( + preview + .lines + .iter() + .any(|line| matches!(line.kind, ReplyPreviewLineKind::Quoted)) + ); + } + + #[test] + fn render_reply_preview_marks_authored_reply_lines() { + let preview = render_reply_preview(ReplyPreviewRequest { + from: "CRIEW Test ", + to: "Bob ", + cc: "", + subject: "Re: [PATCH] demo", + in_reply_to: "patch@example.com", + references: &["patch@example.com".to_string()], + body: &[ + "Looks good to me.".to_string(), + String::new(), + "On Fri, 6 Mar 2026 09:30:00 +0000, Alice wrote:".to_string(), + "> quoted line".to_string(), + ], + self_addresses: &[identity().email.clone()], + }); + + assert!(preview.errors.is_empty()); + assert!(preview.warnings.is_empty()); + assert!(preview.lines.iter().any(|line| { + matches!(line.kind, ReplyPreviewLineKind::Authored) && line.text == "Looks good to me." + })); } #[test] diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 9afc431..5da1f5c 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -13,6 +13,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::layout::{Constraint, Direction, Layout, Position, Rect}; +use ratatui::style::{Color, Modifier}; use crate::domain::subscriptions::SubscriptionCategory; use crate::infra::bootstrap::BootstrapState; @@ -113,6 +114,24 @@ fn rendered_row_text(terminal: &Terminal, row: u16) -> String { .collect::() } +fn rendered_cell_style_for_substring( + terminal: &Terminal, + needle: &str, +) -> Option<(Color, Color, Modifier)> { + let buffer = terminal.backend().buffer(); + let width = buffer.area().width as usize; + + for row in 0..buffer.area().height { + let row_text = rendered_row_text(terminal, row); + if let Some(column) = row_text.find(needle) { + let cell = &buffer.content()[row as usize * width + column]; + return Some((cell.fg, cell.bg, cell.modifier)); + } + } + + None +} + fn test_runtime_in(root: PathBuf) -> RuntimeConfig { RuntimeConfig { config_path: root.join("config.toml"), @@ -3899,7 +3918,7 @@ fn reply_send_preview_requires_confirmation_before_send() { } #[test] -fn reply_send_blocked_notice_and_ready_notice_are_rendered_as_overlays() { +fn reply_send_blocked_notice_and_ready_notice_replace_reply_panel_view() { let root = temp_dir("reply-notice-overlay"); let raw = root.join("patch.eml"); fs::write( @@ -3938,6 +3957,8 @@ fn reply_send_blocked_notice_and_ready_notice_are_rendered_as_overlays() { let rendered = format!("{}", terminal.backend()); assert!(rendered.contains("Send Blocked")); assert!(rendered.contains("You must open Send Preview")); + assert!(!rendered.contains("Headers (From/To/Cc/Subject editable)")); + assert!(!rendered.contains("Reply Body")); let _ = handle_key_event( &mut state, @@ -3954,6 +3975,8 @@ fn reply_send_blocked_notice_and_ready_notice_are_rendered_as_overlays() { let rendered = format!("{}", terminal.backend()); assert!(rendered.contains("Ready To Send")); assert!(rendered.contains("Press S to send the reply")); + assert!(!rendered.contains("Headers (From/To/Cc/Subject editable)")); + assert!(!rendered.contains("Reply Body")); let _ = fs::remove_dir_all(root); } @@ -4075,6 +4098,142 @@ fn reply_preview_validation_blocks_confirm_on_missing_recipients() { let _ = fs::remove_dir_all(root); } +#[test] +fn reply_send_preview_warns_but_allows_confirm_without_authored_reply_text() { + let root = temp_dir("reply-preview-empty-authored"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + runtime.clone(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + ); + + let panel = state.reply_panel.as_ref().expect("reply panel"); + assert!(panel.preview_open); + assert!(panel.preview_errors.is_empty()); + assert!( + panel + .preview_warnings + .iter() + .any(|value| value.contains("no authored reply content")) + ); + assert!(state.status.contains("send preview warning")); + + let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw warning preview"); + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("Send Preview [warning]")); + assert!(rendered.contains("draft has no authored reply content")); + assert!(!rendered.contains("Headers (From/To/Cc/Subject editable)")); + assert!(!rendered.contains("Reply Body")); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE), + ); + assert!( + state + .reply_panel + .as_ref() + .is_some_and(|panel| panel.preview_confirmed) + ); + assert_eq!(state.status, "send preview confirmed; ready to send"); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn reply_send_preview_highlights_authored_lines_and_keeps_quotes_bright() { + let root = temp_dir("reply-preview-highlighted-authored"); + let raw = root.join("patch.eml"); + fs::write( + &raw, + b"Message-ID: \r\nSubject: [PATCH] demo\r\nFrom: Alice \r\nTo: Bob \r\nDate: Fri, 6 Mar 2026 09:30:00 +0000\r\n\r\nbody line\r\n", + ) + .expect("write raw reply fixture"); + + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "[PATCH] demo", + "patch@example.com", + 0, + raw.clone(), + )], + runtime.clone(), + ); + state.focus = Pane::Preview; + state.reply_identity_resolver = reply_identity_mock; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE), + ); + if let Some(panel) = state.reply_panel.as_mut() { + panel.body = vec![ + "Looks good to me.".to_string(), + String::new(), + "On Fri, 6 Mar 2026 09:30:00 +0000, Alice wrote:".to_string(), + "> body line".to_string(), + ]; + panel.mark_dirty(); + } + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE), + ); + + let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw highlighted preview"); + let rendered = format!("{}", terminal.backend()); + assert!(rendered.contains("Send Preview [reply highlighted]")); + assert!(rendered.contains("Your authored reply lines are highlighted below.")); + assert!(!rendered.contains("Headers (From/To/Cc/Subject editable)")); + assert!(!rendered.contains("Reply Body")); + + let (authored_fg, authored_bg, authored_modifier) = + rendered_cell_style_for_substring(&terminal, "Looks good to me.") + .expect("authored line style"); + assert_eq!(authored_fg, Color::Black); + assert_eq!(authored_bg, Color::Yellow); + assert!(authored_modifier.contains(Modifier::BOLD)); + + let (quoted_fg, quoted_bg, _) = + rendered_cell_style_for_substring(&terminal, "> body line").expect("quoted line style"); + assert_eq!(quoted_fg, Color::White); + assert_eq!(quoted_bg, Color::Reset); + + let _ = fs::remove_dir_all(root); +} + #[test] fn reply_send_preview_uses_edited_header_values() { let root = temp_dir("reply-preview-edited-headers"); From 1c4209f72e0eb1a15b7ab6f30deec8d688184c98 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Mon, 9 Mar 2026 22:05:16 +0800 Subject: [PATCH 16/29] feat(tui): support vim gg/G and qq shortcuts - add gg/G jumps and qq quit chords across the mail and code-browser panes - scope pending vim chords to the active pane and clear them when modal surfaces take focus - clamp preview and source scrolling so Shift+G stays render-safe, and document/test the workflow Signed-off-by: Chao Liu --- README-zh.md | 1 + README.md | 1 + docs/reference/config.example.toml | 2 +- src/ui/tui.rs | 137 +++++++++- src/ui/tui/config.rs | 2 +- src/ui/tui/input.rs | 61 +++++ src/ui/tui/render.rs | 100 ++++++- src/ui/tui/tests.rs | 419 +++++++++++++++++++++++++++++ 8 files changed, 713 insertions(+), 10 deletions(-) diff --git a/README-zh.md b/README-zh.md index 9bf198b..0fd1f7f 100644 --- a/README-zh.md +++ b/README-zh.md @@ -205,6 +205,7 @@ criew tui - `u` 撤销本次会话中最近一次成功 apply - `r` 或 `e` 打开回信面板 - `Tab` 在 Mail 页面和 Code Browser 页面之间切换 +- 当 `ui.keymap = "vim"` 时,`gg` 跳到当前 pane 行首,`G` 跳到行尾,`qq` 快速退出 #### 命令栏命令 diff --git a/README.md b/README.md index 63b065c..f3edb33 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ Inside the TUI: - `u` undoes the most recent successful apply from the current session - `r` or `e` opens the reply panel - `Tab` switches between the mail page and the code browser +- with `ui.keymap = "vim"`, `gg` jumps to the first line in the active pane, `G` jumps to the last line, and `qq` exits quickly ##### Background sync diff --git a/docs/reference/config.example.toml b/docs/reference/config.example.toml index 7731ed5..b99d7a9 100644 --- a/docs/reference/config.example.toml +++ b/docs/reference/config.example.toml @@ -58,7 +58,7 @@ filter = "info" # Optional. Main-page navigation scheme. # Supported: # "default" (j/l focus, i/k move) -# "vim" (h/l focus, j/k move) +# "vim" (h/l focus, j/k move, gg/G jump, qq quit) # "custom" (header shows custom; navigation currently falls back to default bindings) # Default: "default" # keymap = "default" diff --git a/src/ui/tui.rs b/src/ui/tui.rs index 051eec7..f0ef3fe 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -4,6 +4,7 @@ //! machine stays here so key handling, background work, and side effects remain //! readable in one place. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::env; use std::fs; @@ -230,7 +231,7 @@ const CONFIG_EDITOR_FIELDS: &[ConfigEditorField] = &[ }, ConfigEditorField { key: "ui.keymap", - description: "Main-page navigation scheme. default=j/l+i/k, vim=h/l+j/k, custom=default fallback with custom label.", + description: "Main-page navigation scheme. default=j/l+i/k, vim=h/l+j/k+gg/G+qq, custom=default fallback with custom label.", }, ConfigEditorField { key: "ui.inbox_auto_sync_interval_secs", @@ -338,6 +339,20 @@ type ReplyIdentityResolver = fn() -> std::result::Result; type ReplySendExecutor = fn(&RuntimeConfig, &SendRequest) -> SendOutcome; type MailboxSyncSpawner = fn(RuntimeConfig, Vec) -> Receiver; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingMainPageChord { + VimGoToFirstLine, + VimQuit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PendingMainPageChordState { + chord: PendingMainPageChord, + ui_page: UiPage, + focus: Pane, + code_focus: CodePaneFocus, +} + #[derive(Debug, Clone)] enum StartupSyncEvent { MailboxStarted { @@ -1204,6 +1219,7 @@ struct AppState { kernel_tree_expanded_paths: HashSet, kernel_tree_row_index: usize, code_preview_scroll: u16, + code_preview_scroll_limit: Cell, code_edit_mode: CodeEditMode, code_edit_target: Option, code_edit_buffer: Vec, @@ -1214,6 +1230,7 @@ struct AppState { reply_panel: Option, thread_index: usize, preview_scroll: u16, + preview_scroll_limit: Cell, selected_mail_preview: Option, started_at: Instant, status: String, @@ -1231,6 +1248,7 @@ struct AppState { inbox_auto_sync: Option, manual_sync: Option, subscription_auto_sync: Option, + pending_main_page_chord: Option, } impl AppState { @@ -1317,6 +1335,7 @@ impl AppState { kernel_tree_expanded_paths, kernel_tree_row_index: 0, code_preview_scroll: 0, + code_preview_scroll_limit: Cell::new(u16::MAX), code_edit_mode: CodeEditMode::Browse, code_edit_target: None, code_edit_buffer: Vec::new(), @@ -1327,6 +1346,7 @@ impl AppState { reply_panel: None, thread_index: 0, preview_scroll: 0, + preview_scroll_limit: Cell::new(u16::MAX), selected_mail_preview: None, started_at: Instant::now(), status: String::new(), @@ -1344,6 +1364,7 @@ impl AppState { inbox_auto_sync: None, manual_sync: None, subscription_auto_sync: None, + pending_main_page_chord: None, }; if state.runtime.imap.is_complete() { state.imap_defaults_initialized = true; @@ -3635,13 +3656,19 @@ impl AppState { } } Pane::Preview => { - self.preview_scroll = self.preview_scroll.saturating_sub(1); + self.preview_scroll = self + .preview_scroll + .min(self.preview_scroll_limit.get()) + .saturating_sub(1); } }, UiPage::CodeBrowser => match self.code_focus { CodePaneFocus::Tree => self.move_kernel_tree_up(), CodePaneFocus::Source => { - self.code_preview_scroll = self.code_preview_scroll.saturating_sub(1); + self.code_preview_scroll = self + .code_preview_scroll + .min(self.code_preview_scroll_limit.get()) + .saturating_sub(1); } }, } @@ -3661,13 +3688,23 @@ impl AppState { } } Pane::Preview => { - self.preview_scroll = self.preview_scroll.saturating_add(1); + let preview_scroll_limit = self.preview_scroll_limit.get(); + self.preview_scroll = self + .preview_scroll + .min(preview_scroll_limit) + .saturating_add(1) + .min(preview_scroll_limit); } }, UiPage::CodeBrowser => match self.code_focus { CodePaneFocus::Tree => self.move_kernel_tree_down(), CodePaneFocus::Source => { - self.code_preview_scroll = self.code_preview_scroll.saturating_add(1); + let code_preview_scroll_limit = self.code_preview_scroll_limit.get(); + self.code_preview_scroll = self + .code_preview_scroll + .min(code_preview_scroll_limit) + .saturating_add(1) + .min(code_preview_scroll_limit); } }, } @@ -3679,6 +3716,96 @@ impl AppState { self.status = "search mode".to_string(); } + fn jump_current_pane_to_start(&mut self) { + match self.ui_page { + UiPage::Mail => match self.focus { + Pane::Subscriptions => { + self.subscription_row_index = 0; + self.clamp_subscription_row_selection(); + } + Pane::Threads => { + if !self.filtered_thread_indices.is_empty() { + self.thread_index = 0; + self.preview_scroll = 0; + self.refresh_selected_mail_preview(); + } + } + Pane::Preview => { + self.preview_scroll = 0; + } + }, + UiPage::CodeBrowser => match self.code_focus { + CodePaneFocus::Tree => { + let previous_file = + self.selected_kernel_tree_file_path().map(Path::to_path_buf); + self.kernel_tree_row_index = 0; + let next_file = self.selected_kernel_tree_file_path().map(Path::to_path_buf); + if previous_file != next_file { + self.code_preview_scroll = 0; + } + } + CodePaneFocus::Source => { + self.code_preview_scroll = 0; + } + }, + } + } + + fn jump_current_pane_to_end(&mut self) { + match self.ui_page { + UiPage::Mail => match self.focus { + Pane::Subscriptions => { + let rows = self.subscription_rows(); + if rows.is_empty() { + return; + } + self.subscription_row_index = rows.len().saturating_sub(1); + self.clamp_subscription_row_selection(); + } + Pane::Threads => { + if !self.filtered_thread_indices.is_empty() { + self.thread_index = self.filtered_thread_indices.len().saturating_sub(1); + self.preview_scroll = 0; + self.refresh_selected_mail_preview(); + } + } + Pane::Preview => { + self.preview_scroll = u16::MAX; + } + }, + UiPage::CodeBrowser => match self.code_focus { + CodePaneFocus::Tree => { + if self.kernel_tree_rows.is_empty() { + self.kernel_tree_row_index = 0; + return; + } + let previous_file = + self.selected_kernel_tree_file_path().map(Path::to_path_buf); + self.kernel_tree_row_index = self.kernel_tree_rows.len().saturating_sub(1); + let next_file = self.selected_kernel_tree_file_path().map(Path::to_path_buf); + if previous_file != next_file { + self.code_preview_scroll = 0; + } + } + CodePaneFocus::Source => { + self.code_preview_scroll = self.code_preview_scroll_limit.get(); + } + }, + } + } + + fn pending_main_page_chord_state( + &self, + chord: PendingMainPageChord, + ) -> PendingMainPageChordState { + PendingMainPageChordState { + chord, + ui_page: self.ui_page, + focus: self.focus, + code_focus: self.code_focus, + } + } + fn close_search(&mut self) { self.search.active = false; self.search.input.clear(); diff --git a/src/ui/tui/config.rs b/src/ui/tui/config.rs index cf7aa6b..c6c7e25 100644 --- a/src/ui/tui/config.rs +++ b/src/ui/tui/config.rs @@ -829,7 +829,7 @@ fn config_value_suggestions(state: &AppState, key: Option<&String>) -> Vec [ ("default", "j/l focus, i/k move"), - ("vim", "h/l focus, j/k move"), + ("vim", "h/l focus, j/k move, gg/G jump, qq quit"), ("custom", "custom label with default navigation fallback"), ] .iter() diff --git a/src/ui/tui/input.rs b/src/ui/tui/input.rs index 7ac9a15..0b39b2a 100644 --- a/src/ui/tui/input.rs +++ b/src/ui/tui/input.rs @@ -62,6 +62,58 @@ fn handle_main_page_navigation_key(state: &mut AppState, key: KeyEvent) -> bool } } +fn handle_vim_main_page_chord(state: &mut AppState, key: KeyEvent) -> Option { + if !matches!(state.runtime.ui_keymap, UiKeymap::Vim) { + state.pending_main_page_chord = None; + return None; + } + + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) + { + state.pending_main_page_chord = None; + return None; + } + + if let Some(pending_state) = state.pending_main_page_chord.take() { + let same_scope = pending_state.ui_page == state.ui_page + && pending_state.focus == state.focus + && pending_state.code_focus == state.code_focus; + if same_scope { + match (pending_state.chord, key.code) { + (PendingMainPageChord::VimGoToFirstLine, KeyCode::Char('g')) => { + state.jump_current_pane_to_start(); + return Some(LoopAction::Continue); + } + (PendingMainPageChord::VimQuit, KeyCode::Char('q')) => { + return Some(LoopAction::Exit); + } + _ => {} + } + } + } + + match key.code { + KeyCode::Char('g') => { + state.pending_main_page_chord = + Some(state.pending_main_page_chord_state(PendingMainPageChord::VimGoToFirstLine)); + Some(LoopAction::Continue) + } + KeyCode::Char('G') => { + state.jump_current_pane_to_end(); + Some(LoopAction::Continue) + } + KeyCode::Char('q') => { + state.pending_main_page_chord = + Some(state.pending_main_page_chord_state(PendingMainPageChord::VimQuit)); + state.status = "press qq to quit or use command palette quit/exit".to_string(); + Some(LoopAction::Continue) + } + _ => None, + } +} + pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { tracing::debug!( key = ?key, @@ -78,10 +130,12 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio // Modal UI surfaces take precedence over the base page shortcuts so keys // keep local meaning while a dialog, editor, or search interaction is open. if state.config_editor.open { + state.pending_main_page_chord = None; return handle_config_editor_key_event(state, key); } if state.palette.open { + state.pending_main_page_chord = None; if is_palette_toggle(key) { state.close_palette(); return LoopAction::Continue; @@ -90,17 +144,24 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio } if state.search.active { + state.pending_main_page_chord = None; return handle_search_key_event(state, key); } if state.reply_panel.is_some() { + state.pending_main_page_chord = None; return handle_reply_key_event(state, key); } if state.is_code_edit_active() { + state.pending_main_page_chord = None; return handle_code_edit_key_event(state, key); } + if let Some(action) = handle_vim_main_page_chord(state, key) { + return action; + } + if is_palette_open_shortcut(key) { state.toggle_palette(); return LoopAction::Continue; diff --git a/src/ui/tui/render.rs b/src/ui/tui/render.rs index 6161fe2..aede33b 100644 --- a/src/ui/tui/render.rs +++ b/src/ui/tui/render.rs @@ -22,6 +22,12 @@ use super::*; const REPLY_BODY_GUIDE_COLUMN: usize = 80; const HEADER_BG: Color = Color::Blue; +#[derive(Clone, Copy)] +enum VerticalScrollWrapMode { + Disabled, + Enabled, +} + pub(super) fn draw( frame: &mut Frame<'_>, state: &AppState, @@ -355,8 +361,24 @@ fn draw_code_source_preview(frame: &mut Frame<'_>, area: Rect, state: &AppState) frame.render_widget(block, area); frame.render_widget(Clear, inner_area); - let paragraph = - Paragraph::new(load_code_source_preview(state)).scroll((state.code_preview_scroll, 0)); + let preview = load_code_source_preview(state); + let code_preview_scroll_limit = max_vertical_scroll( + &preview, + inner_area.width, + inner_area.height, + VerticalScrollWrapMode::Disabled, + ); + state + .code_preview_scroll_limit + .set(code_preview_scroll_limit); + let scroll = clamp_vertical_scroll( + &preview, + inner_area.width, + inner_area.height, + state.code_preview_scroll, + VerticalScrollWrapMode::Disabled, + ); + let paragraph = Paragraph::new(preview).scroll((scroll, 0)); frame.render_widget(paragraph, inner_area); if let Some(cursor_position) = code_edit_cursor_position(state, inner_area) { @@ -566,13 +588,85 @@ fn draw_preview(frame: &mut Frame<'_>, area: Rect, state: &AppState, config: &Ru inner_area }; + let preview_scroll_limit = max_vertical_scroll( + preview.as_ref(), + content_area.width, + content_area.height, + VerticalScrollWrapMode::Enabled, + ); + state.preview_scroll_limit.set(preview_scroll_limit); + let scroll = clamp_vertical_scroll( + preview.as_ref(), + content_area.width, + content_area.height, + state.preview_scroll, + VerticalScrollWrapMode::Enabled, + ); let paragraph = Paragraph::new(preview.as_ref()) - .scroll((state.preview_scroll, 0)) + .scroll((scroll, 0)) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, content_area); } +fn clamp_vertical_scroll( + text: &str, + area_width: u16, + area_height: u16, + requested_scroll: u16, + wrap_mode: VerticalScrollWrapMode, +) -> u16 { + requested_scroll.min(max_vertical_scroll( + text, + area_width, + area_height, + wrap_mode, + )) +} + +fn max_vertical_scroll( + text: &str, + area_width: u16, + area_height: u16, + wrap_mode: VerticalScrollWrapMode, +) -> u16 { + if area_height == 0 { + return 0; + } + + let visible_lines = area_height as usize; + let total_lines = visual_line_count(text, area_width, wrap_mode); + total_lines + .saturating_sub(visible_lines) + .min(u16::MAX as usize) as u16 +} + +fn visual_line_count(text: &str, area_width: u16, wrap_mode: VerticalScrollWrapMode) -> usize { + match wrap_mode { + VerticalScrollWrapMode::Disabled => text.split('\n').count().max(1), + VerticalScrollWrapMode::Enabled => { + if area_width == 0 { + return 0; + } + + text.split('\n') + .map(|line| wrapped_visual_line_count(line, area_width)) + .sum::() + .max(1) + } + } +} + +fn wrapped_visual_line_count(line: &str, area_width: u16) -> usize { + if area_width == 0 { + return 0; + } + + let width = area_width as usize; + let display_width = display_column(line, line.chars().count()).max(1); + display_width.saturating_add(width - 1) / width +} + fn load_series_preview(state: &AppState, config: &RuntimeConfig, thread_id: i64) -> Option { let series = state.series_summaries.get(&thread_id)?; let mut lines = vec![ diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 1126c83..3e6e986 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -949,6 +949,8 @@ keymap = "default" let rendered = format!("{}", terminal.backend()); assert!(rendered.contains("h/l focus | j/k move")); assert!(!rendered.contains("j/l focus | i/k move")); + assert!(!rendered.contains("gg/G jump")); + assert!(!rendered.contains("qq quit")); let _ = fs::remove_dir_all(root); } @@ -1033,6 +1035,423 @@ fn loaded_vim_keymap_drives_navigation_keys() { ); } +#[test] +fn vim_keymap_supports_gg_and_capital_g_jumps_on_mail_panes() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + sample_thread("t2", "c@example.com", 2), + ], + runtime, + ); + + let subscription_rows = state.subscription_rows(); + assert!(!subscription_rows.is_empty()); + + let action_bottom = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_bottom, LoopAction::Continue)); + assert_eq!( + state.subscription_row_index, + subscription_rows.len().saturating_sub(1) + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + let action_top = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(action_top, LoopAction::Continue)); + assert_eq!(state.subscription_row_index, 0); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + let action_thread_bottom = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_thread_bottom, LoopAction::Continue)); + assert_eq!(state.thread_index, 2); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + let action_thread_top = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(action_thread_top, LoopAction::Continue)); + assert_eq!(state.thread_index, 0); + + state.focus = Pane::Preview; + state.preview_scroll = 7; + let action_preview_bottom = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_preview_bottom, LoopAction::Continue)); + assert_eq!(state.preview_scroll, u16::MAX); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + let action_preview_top = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(action_preview_top, LoopAction::Continue)); + assert_eq!(state.preview_scroll, 0); +} + +#[test] +fn vim_keymap_supports_gg_and_capital_g_jumps_in_code_browser() { + let tree_root = temp_dir("vim-jump-code-browser"); + let file_path = tree_root.join("demo.c"); + let source = (1..=40) + .map(|line| format!("int line_{line} = {line};")) + .collect::>() + .join("\n"); + fs::write(&file_path, format!("{source}\n")).expect("write file"); + + let mut runtime = test_runtime_with_kernel_tree(tree_root.clone()); + runtime.ui_keymap = UiKeymap::Vim; + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(matches!(state.ui_page, UiPage::CodeBrowser)); + + let action_tree_bottom = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_tree_bottom, LoopAction::Continue)); + assert_eq!( + state.kernel_tree_row_index, + state.kernel_tree_rows.len().saturating_sub(1) + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + let action_tree_top = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(action_tree_top, LoopAction::Continue)); + assert_eq!(state.kernel_tree_row_index, 0); + + state.code_focus = CodePaneFocus::Source; + state.kernel_tree_row_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("file row exists"); + + let mut terminal = Terminal::new(TestBackend::new(140, 18)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw source preview before vim jumps"); + + state.code_preview_scroll = 9; + let action_source_bottom = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_source_bottom, LoopAction::Continue)); + let code_preview_scroll_limit = state.code_preview_scroll_limit.get(); + assert!(code_preview_scroll_limit > 0); + assert_eq!(state.code_preview_scroll, code_preview_scroll_limit); + + let action_source_up = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert!(matches!(action_source_up, LoopAction::Continue)); + assert_eq!( + state.code_preview_scroll, + code_preview_scroll_limit.saturating_sub(1) + ); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + let action_source_top = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(action_source_top, LoopAction::Continue)); + assert_eq!(state.code_preview_scroll, 0); + + let _ = fs::remove_dir_all(tree_root); +} + +#[test] +fn vim_keymap_supports_qq_quit_chord() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new(vec![], runtime); + + let first = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + assert!(matches!(first, LoopAction::Continue)); + assert_eq!( + state.status, + "press qq to quit or use command palette quit/exit" + ); + + let second = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + assert!(matches!(second, LoopAction::Exit)); +} + +#[test] +fn vim_chords_do_not_leak_into_right_preview_pane() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); + + let arm_quit = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), + ); + assert!(matches!(arm_quit, LoopAction::Continue)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Preview)); + + state.preview_scroll = 11; + let first_g = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(first_g, LoopAction::Continue)); + assert_eq!(state.preview_scroll, 11); + + let second_g = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ); + assert!(matches!(second_g, LoopAction::Continue)); + assert_eq!(state.preview_scroll, 0); +} + +#[test] +fn preview_pane_shift_g_keeps_tui_renderable() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread("preview thread", "preview@example.com", 0)], + runtime.clone(), + ); + state.focus = Pane::Preview; + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action, LoopAction::Continue)); + + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw after preview shift-g"); + let rendered = format!("{}", terminal.backend()); + + assert!(rendered.contains("Preview")); +} + +#[test] +fn preview_pane_can_move_up_after_reaching_bottom() { + let root = temp_dir("preview-scroll-bottom"); + let raw_path = root.join("preview.eml"); + let body = (1..=40) + .map(|line| format!("preview line {line:02}")) + .collect::>() + .join("\n"); + fs::write( + &raw_path, + format!( + "Message-ID: \r\nSubject: preview scroll\r\nFrom: preview@example.com\r\n\r\n{body}\n" + ), + ) + .expect("write raw mail"); + + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "preview scroll", + "preview-scroll@example.com", + 0, + raw_path.clone(), + )], + runtime.clone(), + ); + state.focus = Pane::Preview; + + let mut terminal = Terminal::new(TestBackend::new(120, 16)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw initial preview frame"); + + let preview_scroll_limit = state.preview_scroll_limit.get(); + assert!(preview_scroll_limit > 0); + + for _ in 0..(preview_scroll_limit as usize + 10) { + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + } + assert_eq!(state.preview_scroll, preview_scroll_limit); + + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw bottom preview frame"); + let bottom_frame = format!("{}", terminal.backend()); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + assert_eq!(state.preview_scroll, preview_scroll_limit.saturating_sub(1)); + + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw preview frame after moving up"); + let after_up_frame = format!("{}", terminal.backend()); + + assert_ne!(after_up_frame, bottom_frame); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn preview_scroll_limit_accounts_for_wrapped_long_lines() { + let root = temp_dir("preview-wrap-scroll"); + let raw_path = root.join("wrapped-preview.eml"); + let wrapped_body = format!("{} WRAP_TAIL_TOKEN\n", "x".repeat(380)); + fs::write( + &raw_path, + format!( + "Message-ID: \r\nSubject: wrapped preview\r\nFrom: preview@example.com\r\n\r\n{wrapped_body}" + ), + ) + .expect("write wrapped preview mail"); + + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new( + vec![sample_thread_with_raw( + "wrapped preview", + "wrapped-preview@example.com", + 0, + raw_path.clone(), + )], + runtime.clone(), + ); + state.focus = Pane::Preview; + + let mut terminal = Terminal::new(TestBackend::new(40, 10)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw wrapped preview"); + let top_frame = format!("{}", terminal.backend()); + let preview_scroll_limit = state.preview_scroll_limit.get(); + assert!(preview_scroll_limit > 0); + assert!(!top_frame.contains("WRAP_TAIL_TOKEN")); + + for _ in 0..(preview_scroll_limit as usize + 5) { + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + } + + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw wrapped preview after scrolling"); + let bottom_frame = format!("{}", terminal.backend()); + + assert!(bottom_frame.contains("WRAP_TAIL_TOKEN")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn source_pane_shift_g_keeps_tui_renderable() { + let tree_root = temp_dir("source-pane-shift-g"); + let file_path = tree_root.join("demo.c"); + fs::write( + &file_path, + "int line_1;\nint line_2;\nint line_3;\nint line_4;\nint line_5;\n", + ) + .expect("write source file"); + + let mut runtime = test_runtime_with_kernel_tree(tree_root.clone()); + runtime.ui_keymap = UiKeymap::Vim; + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(matches!(state.ui_page, UiPage::CodeBrowser)); + state.code_focus = CodePaneFocus::Source; + state.kernel_tree_row_index = state + .kernel_tree_rows + .iter() + .position(|row| row.path == file_path) + .expect("file row exists"); + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT), + ); + assert!(matches!(action, LoopAction::Continue)); + + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw after source shift-g"); + let rendered = format!("{}", terminal.backend()); + + assert!(rendered.contains("Source Preview")); + + let _ = fs::remove_dir_all(tree_root); +} + #[test] fn config_command_opens_visual_editor() { let mut state = AppState::new(vec![], test_runtime()); From f7e9ca9a027930eb6fa8a26c4dec53f4d31a3bf5 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Mon, 9 Mar 2026 22:06:54 +0800 Subject: [PATCH 17/29] feat(tui): move Enter focus deeper into mail panes - focus the threads pane when Enter opens a mailbox from subscriptions, including cached and syncing paths - focus the preview pane when Enter opens the selected thread so the mail workflow advances forward - add regression coverage for subscription and thread Enter transitions plus non-blocking inbox cases Signed-off-by: Chao Liu --- README-zh.md | 2 +- README.md | 2 +- src/ui/tui.rs | 3 +++ src/ui/tui/input.rs | 1 + src/ui/tui/tests.rs | 43 ++++++++++++++++++++++++++++++++++++++----- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/README-zh.md b/README-zh.md index 0fd1f7f..e7a2918 100644 --- a/README-zh.md +++ b/README-zh.md @@ -199,7 +199,7 @@ criew tui - `:` 打开命令栏 - 顶部状态栏会显示当前 keymap 方案:`default`、`vim` 或 `custom` - `y` / `n` 启用或禁用当前订阅 -- `Enter` 打开当前 mailbox 或 thread +- `Enter` 打开当前 mailbox 或 thread,并自动切到 threads 或 preview pane - `a` apply 当前 patch series - `d` 导出当前 patch series - `u` 撤销本次会话中最近一次成功 apply diff --git a/README.md b/README.md index f3edb33..5a6231a 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ Inside the TUI: - `:` opens the command palette - the header shows the active keymap scheme (`default`, `vim`, or `custom`) - `y` / `n` enable or disable the selected subscription -- `Enter` opens the selected mailbox or thread +- `Enter` opens the selected mailbox or thread and moves focus to threads or preview - `a` applies the current patch series - `d` exports the current patch series - `u` undoes the most recent successful apply from the current session diff --git a/src/ui/tui.rs b/src/ui/tui.rs index f0ef3fe..1795dd6 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -3029,6 +3029,7 @@ impl AppState { format!("showing threads for {}", mailbox), true, ); + self.focus = Pane::Threads; } Ok(_) => { if self.mailbox_sync_pending(&mailbox) { @@ -3038,6 +3039,7 @@ impl AppState { format!("{mailbox} is syncing in background; page stays responsive"), true, ); + self.focus = Pane::Threads; return; } @@ -3053,6 +3055,7 @@ impl AppState { } }; self.show_mailbox_threads(&mailbox, Vec::new(), background_status, true); + self.focus = Pane::Threads; } Err(error) => { tracing::error!( diff --git a/src/ui/tui/input.rs b/src/ui/tui/input.rs index 0b39b2a..0679bf8 100644 --- a/src/ui/tui/input.rs +++ b/src/ui/tui/input.rs @@ -237,6 +237,7 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio Pane::Threads => { if let Some(thread) = state.selected_thread() { state.status = format!("selected {}", thread.message_id); + state.focus = Pane::Preview; } } Pane::Preview => {} diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 3e6e986..58cf702 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -2745,10 +2745,35 @@ fn code_browser_navigation_keys_unchanged_when_not_editing() { } #[test] -fn enter_on_subscription_opens_threads_without_toggling_enabled_state() { - let mut state = AppState::new(vec![], test_runtime()); +fn enter_on_subscription_opens_threads_and_focuses_threads_pane_without_toggling_enabled_state() { + let root = temp_dir("enter-open-subscription"); + let runtime = test_runtime_with_imap_in(root.clone()); + seed_mailbox_thread( + &runtime.database_path, + "io-uring", + 1, + "io-uring@example.com", + "io-uring thread", + ); + + let mut state = AppState::new_with_ui_state( + vec![], + runtime, + Some(UiState { + enabled_mailboxes: vec![IMAP_INBOX_MAILBOX.to_string(), "io-uring".to_string()], + active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() + }), + ); state.focus = Pane::Subscriptions; - let initial = state.subscriptions[0].enabled; + let io_uring_index = state + .subscriptions + .iter() + .position(|item| item.mailbox == "io-uring") + .expect("io-uring subscription exists"); + state.subscription_index = io_uring_index; + state.sync_subscription_row_to_selected_item(); + let initial = state.subscriptions[io_uring_index].enabled; let action = handle_key_event( &mut state, @@ -2756,7 +2781,12 @@ fn enter_on_subscription_opens_threads_without_toggling_enabled_state() { ); assert!(matches!(action, LoopAction::Continue)); - assert_eq!(state.subscriptions[0].enabled, initial); + assert_eq!(state.subscriptions[io_uring_index].enabled, initial); + assert!(matches!(state.focus, Pane::Threads)); + assert_eq!(state.active_thread_mailbox, "io-uring"); + assert_eq!(state.threads.len(), 1); + + let _ = fs::remove_dir_all(root); } #[test] @@ -3091,6 +3121,7 @@ fn opening_empty_inbox_queues_background_sync_and_defers_next_auto_sync_tick() { assert!(state.threads.is_empty()); assert!(state.status.contains("syncing in background")); assert!(state.manual_sync.is_some()); + assert!(matches!(state.focus, Pane::Threads)); assert!( state .inbox_auto_sync @@ -3341,6 +3372,7 @@ fn enter_on_mailbox_pending_startup_sync_stays_non_blocking() { state.open_threads_for_selected_subscription(); assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); + assert!(matches!(state.focus, Pane::Threads)); assert!(state.threads.is_empty()); assert!(state.status.contains("syncing in background")); @@ -3700,7 +3732,7 @@ fn palette_bang_reports_empty_local_command() { } #[test] -fn enter_on_thread_sets_selected_status_message() { +fn enter_on_thread_focuses_preview_and_sets_selected_status_message() { let mut state = AppState::new( vec![sample_thread("normal mail", "plain@example.com", 0)], test_runtime(), @@ -3713,6 +3745,7 @@ fn enter_on_thread_sets_selected_status_message() { ); assert!(matches!(action, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Preview)); assert_eq!(state.status, "selected plain@example.com"); } From b902d1397f8c1a580934fb547a7ac4bdefddf8e5 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Mon, 9 Mar 2026 23:18:17 +0800 Subject: [PATCH 18/29] feat(tui): support counted main-page movement - add scoped numeric prefixes for repeated vertical movement on the main page in default/custom and vim keymaps - clear pending counts when focus changes or modal surfaces take over so prefixes do not leak across interactions - document the count shortcuts and add TUI regression coverage for counted moves and scope resets Signed-off-by: Chao Liu --- README-zh.md | 1 + README.md | 1 + src/ui/tui.rs | 59 +++++++++++++++++++++++- src/ui/tui/input.rs | 72 +++++++++++++++++++++++++---- src/ui/tui/tests.rs | 109 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 11 deletions(-) diff --git a/README-zh.md b/README-zh.md index e7a2918..ee5c36b 100644 --- a/README-zh.md +++ b/README-zh.md @@ -198,6 +198,7 @@ criew tui - `:` 打开命令栏 - 顶部状态栏会显示当前 keymap 方案:`default`、`vim` 或 `custom` +- 数字前缀可重复垂直移动:`default/custom` 使用 `数字 + i/k`,`vim` 使用 `数字 + j/k` - `y` / `n` 启用或禁用当前订阅 - `Enter` 打开当前 mailbox 或 thread,并自动切到 threads 或 preview pane - `a` apply 当前 patch series diff --git a/README.md b/README.md index 5a6231a..b81b4b3 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ Inside the TUI: - `:` opens the command palette - the header shows the active keymap scheme (`default`, `vim`, or `custom`) +- a numeric prefix repeats vertical movement: `count+i/k` for `default`/`custom`, `count+j/k` for `vim` - `y` / `n` enable or disable the selected subscription - `Enter` opens the selected mailbox or thread and moves focus to threads or preview - `a` applies the current patch series diff --git a/src/ui/tui.rs b/src/ui/tui.rs index 1795dd6..e3c3f26 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -231,7 +231,7 @@ const CONFIG_EDITOR_FIELDS: &[ConfigEditorField] = &[ }, ConfigEditorField { key: "ui.keymap", - description: "Main-page navigation scheme. default=j/l+i/k, vim=h/l+j/k+gg/G+qq, custom=default fallback with custom label.", + description: "Main-page navigation scheme. default=j/l+i/k+count, vim=h/l+j/k+count+gg/G+qq, custom=default fallback with custom label.", }, ConfigEditorField { key: "ui.inbox_auto_sync_interval_secs", @@ -353,6 +353,14 @@ struct PendingMainPageChordState { code_focus: CodePaneFocus, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PendingMainPageCountState { + count: u16, + ui_page: UiPage, + focus: Pane, + code_focus: CodePaneFocus, +} + #[derive(Debug, Clone)] enum StartupSyncEvent { MailboxStarted { @@ -1249,6 +1257,7 @@ struct AppState { manual_sync: Option, subscription_auto_sync: Option, pending_main_page_chord: Option, + pending_main_page_count: Option, } impl AppState { @@ -1365,6 +1374,7 @@ impl AppState { manual_sync: None, subscription_auto_sync: None, pending_main_page_chord: None, + pending_main_page_count: None, }; if state.runtime.imap.is_complete() { state.imap_defaults_initialized = true; @@ -3809,6 +3819,53 @@ impl AppState { } } + fn pending_main_page_count_state(&self, count: u16) -> PendingMainPageCountState { + PendingMainPageCountState { + count, + ui_page: self.ui_page, + focus: self.focus, + code_focus: self.code_focus, + } + } + + fn clear_pending_main_page_inputs(&mut self) { + self.pending_main_page_chord = None; + self.pending_main_page_count = None; + } + + fn clear_pending_main_page_count(&mut self) { + self.pending_main_page_count = None; + } + + fn has_pending_main_page_count(&self) -> bool { + self.pending_main_page_count.is_some_and(|state| { + state.ui_page == self.ui_page + && state.focus == self.focus + && state.code_focus == self.code_focus + }) + } + + fn push_pending_main_page_count_digit(&mut self, digit: u16) { + let next_count = self + .pending_main_page_count + .filter(|state| { + state.ui_page == self.ui_page + && state.focus == self.focus + && state.code_focus == self.code_focus + }) + .map(|state| state.count.saturating_mul(10).saturating_add(digit)) + .unwrap_or(digit); + self.pending_main_page_count = Some(self.pending_main_page_count_state(next_count)); + } + + fn take_pending_main_page_count(&mut self) -> Option { + let pending_state = self.pending_main_page_count.take()?; + let same_scope = pending_state.ui_page == self.ui_page + && pending_state.focus == self.focus + && pending_state.code_focus == self.code_focus; + same_scope.then_some(pending_state.count) + } + fn close_search(&mut self) { self.search.active = false; self.search.input.clear(); diff --git a/src/ui/tui/input.rs b/src/ui/tui/input.rs index 0679bf8..37de756 100644 --- a/src/ui/tui/input.rs +++ b/src/ui/tui/input.rs @@ -19,42 +19,58 @@ pub(super) enum LoopAction { Restart, } +fn pending_main_page_move_count(state: &mut AppState) -> u16 { + state.take_pending_main_page_count().unwrap_or(1) +} + fn handle_main_page_navigation_key(state: &mut AppState, key: KeyEvent) -> bool { match state.runtime.ui_keymap { UiKeymap::Default | UiKeymap::Custom => match key.code { KeyCode::Char('j') => { + state.clear_pending_main_page_count(); state.move_focus_previous(); true } KeyCode::Char('l') => { + state.clear_pending_main_page_count(); state.move_focus_next(); true } KeyCode::Char('i') => { - state.move_up(); + for _ in 0..pending_main_page_move_count(state) { + state.move_up(); + } true } KeyCode::Char('k') => { - state.move_down(); + for _ in 0..pending_main_page_move_count(state) { + state.move_down(); + } true } _ => false, }, UiKeymap::Vim => match key.code { KeyCode::Char('h') => { + state.clear_pending_main_page_count(); state.move_focus_previous(); true } KeyCode::Char('l') => { + state.clear_pending_main_page_count(); state.move_focus_next(); true } KeyCode::Char('k') => { - state.move_up(); + for _ in 0..pending_main_page_move_count(state) { + state.move_up(); + } true } KeyCode::Char('j') => { - state.move_down(); + for _ in 0..pending_main_page_move_count(state) { + state.move_down(); + } true } _ => false, @@ -62,6 +78,32 @@ fn handle_main_page_navigation_key(state: &mut AppState, key: KeyEvent) -> bool } } +fn handle_main_page_count_prefix(state: &mut AppState, key: KeyEvent) -> bool { + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) + { + state.clear_pending_main_page_count(); + return false; + } + + let KeyCode::Char(character) = key.code else { + return false; + }; + if !character.is_ascii_digit() { + return false; + } + if character == '0' && !state.has_pending_main_page_count() { + return false; + } + + let digit = character + .to_digit(10) + .expect("ascii digit should convert to decimal") as u16; + state.push_pending_main_page_count_digit(digit); + true +} + fn handle_vim_main_page_chord(state: &mut AppState, key: KeyEvent) -> Option { if !matches!(state.runtime.ui_keymap, UiKeymap::Vim) { state.pending_main_page_chord = None; @@ -72,7 +114,7 @@ fn handle_vim_main_page_chord(state: &mut AppState, key: KeyEvent) -> Option Option { + state.clear_pending_main_page_count(); state.pending_main_page_chord = Some(state.pending_main_page_chord_state(PendingMainPageChord::VimGoToFirstLine)); Some(LoopAction::Continue) } KeyCode::Char('G') => { + state.clear_pending_main_page_count(); state.jump_current_pane_to_end(); Some(LoopAction::Continue) } KeyCode::Char('q') => { + state.clear_pending_main_page_count(); state.pending_main_page_chord = Some(state.pending_main_page_chord_state(PendingMainPageChord::VimQuit)); state.status = "press qq to quit or use command palette quit/exit".to_string(); @@ -130,12 +175,12 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio // Modal UI surfaces take precedence over the base page shortcuts so keys // keep local meaning while a dialog, editor, or search interaction is open. if state.config_editor.open { - state.pending_main_page_chord = None; + state.clear_pending_main_page_inputs(); return handle_config_editor_key_event(state, key); } if state.palette.open { - state.pending_main_page_chord = None; + state.clear_pending_main_page_inputs(); if is_palette_toggle(key) { state.close_palette(); return LoopAction::Continue; @@ -144,17 +189,17 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio } if state.search.active { - state.pending_main_page_chord = None; + state.clear_pending_main_page_inputs(); return handle_search_key_event(state, key); } if state.reply_panel.is_some() { - state.pending_main_page_chord = None; + state.clear_pending_main_page_inputs(); return handle_reply_key_event(state, key); } if state.is_code_edit_active() { - state.pending_main_page_chord = None; + state.clear_pending_main_page_inputs(); return handle_code_edit_key_event(state, key); } @@ -162,7 +207,12 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio return action; } + if handle_main_page_count_prefix(state, key) { + return LoopAction::Continue; + } + if is_palette_open_shortcut(key) { + state.clear_pending_main_page_count(); state.toggle_palette(); return LoopAction::Continue; } @@ -171,6 +221,8 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio return LoopAction::Continue; } + state.clear_pending_main_page_count(); + match key.code { KeyCode::Char('/') => { if matches!(state.ui_page, UiPage::Mail) { diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 58cf702..66d827d 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -82,6 +82,16 @@ fn sample_thread_with_raw( } } +fn sample_threads(count: usize) -> Vec { + (0..count) + .map(|index| { + let subject = format!("t{index}"); + let message_id = format!("{index}@example.com"); + sample_thread(&subject, &message_id, index as u16) + }) + .collect() +} + fn sample_thread_in_thread( thread_id: i64, mail_id: i64, @@ -1035,6 +1045,105 @@ fn loaded_vim_keymap_drives_navigation_keys() { ); } +#[test] +fn default_keymap_supports_counted_ik_navigation() { + let mut state = AppState::new(sample_threads(15), test_runtime()); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE), + ); + let action_down = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert!(matches!(action_down, LoopAction::Continue)); + assert_eq!(state.thread_index, 12); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE), + ); + let action_up = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE), + ); + assert!(matches!(action_up, LoopAction::Continue)); + assert_eq!(state.thread_index, 7); +} + +#[test] +fn vim_keymap_supports_counted_jk_navigation() { + let mut runtime = test_runtime(); + runtime.ui_keymap = UiKeymap::Vim; + let mut state = AppState::new(sample_threads(15), runtime); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE), + ); + let action_down = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(action_down, LoopAction::Continue)); + assert_eq!(state.thread_index, 12); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE), + ); + let action_up = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert!(matches!(action_up, LoopAction::Continue)); + assert_eq!(state.thread_index, 7); +} + +#[test] +fn counted_main_page_navigation_does_not_leak_into_focus_changes() { + let mut state = AppState::new(sample_threads(4), test_runtime()); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE), + ); + let focus_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + assert!(matches!(focus_action, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Threads)); + + let move_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + assert!(matches!(move_action, LoopAction::Continue)); + assert_eq!(state.thread_index, 1); +} + #[test] fn vim_keymap_supports_gg_and_capital_g_jumps_on_mail_panes() { let mut runtime = test_runtime(); From 3fb668d531a92a474a8282634afb9e670ab95298 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Tue, 10 Mar 2026 00:02:55 +0800 Subject: [PATCH 19/29] feat(ui): clarify reply header editing - label editable reply headers and read-only thread metadata in the reply panel - open the reply panel with focus on From and keep docs/tests aligned Signed-off-by: Chen Miao --- README-zh.md | 1 + README.md | 2 +- docs/specs/reply-format-spec.md | 5 +++++ src/ui/tui.rs | 21 +++++++++++++++++---- src/ui/tui/render.rs | 30 +++++++++++++++++++----------- src/ui/tui/tests.rs | 20 +++++++++++++++----- 6 files changed, 58 insertions(+), 21 deletions(-) diff --git a/README-zh.md b/README-zh.md index 8c1e62b..31f2c7d 100644 --- a/README-zh.md +++ b/README-zh.md @@ -29,6 +29,7 @@ English README: [README.md](README.md) - 浏览 thread,识别 `[PATCH vN M/N]` patch series - 通过 `b4` apply 或导出 patch - 在 TUI 中撰写回信,并通过 `git send-email` 发送 +- 回信面板会明确区分可编辑字段(`From/To/Cc/Subject`)和只读线程字段 - `Send Preview` 会在草稿没有实际回复内容时给出警告,并高亮用户自己写的回复正文 - 浏览本地 kernel tree,支持内联 Vim-like 编辑和外部 Vim 编辑 diff --git a/README.md b/README.md index 6e11508..41fda4f 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ refresh your checkout or reinstall the binary and bootstrap a new CRIEW runtime - undo for the most recent successful apply in the current session - kernel tree browser with source preview - inline Vim-like editing and external Vim editing -- reply panel that fills `From`, `To`, `Cc`, `Subject`, `In-Reply-To`, and `References` +- reply panel that fills `From`, `To`, `Cc`, `Subject`, `In-Reply-To`, and `References`, and clearly marks editable draft fields vs read-only thread headers - send preview warnings when the draft has no authored reply text, and authored lines stand out from quoted text - real reply delivery through `git send-email` - visual config editor, command palette completion, and structured operation logs diff --git a/docs/specs/reply-format-spec.md b/docs/specs/reply-format-spec.md index ff1a204..185ed19 100644 --- a/docs/specs/reply-format-spec.md +++ b/docs/specs/reply-format-spec.md @@ -40,6 +40,11 @@ - `In-Reply-To` - `References` +界面必须清楚区分可编辑头部字段与只读线程字段, +避免用户把 `To` / `Cc` 误认为不可修改的固定元数据。 +打开 `Reply Panel` 时,初始焦点应落在 `From`, +方便用户按 Vim-like 方式向下移动并修改头部字段。 + `Reply Panel` 发送区最小交互: - `Send Preview`:生成并展示最终待发送邮件(头部 + 正文) diff --git a/src/ui/tui.rs b/src/ui/tui.rs index e9135ea..d1b21eb 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -895,7 +895,7 @@ impl ReplyPanelState { }, self_addresses, mode: ReplyEditMode::Normal, - section: ReplySection::Body, + section: ReplySection::From, body_row: 0, cursor_col: 0, dirty: false, @@ -3149,7 +3149,10 @@ impl AppState { thread.mail_id, thread.thread_id, )); - self.status = format!("reply panel opened for <{}>", thread.message_id); + self.status = format!( + "reply panel opened for <{}>; edit From/To/Cc/Subject before Send Preview", + thread.message_id + ); } fn close_reply_panel(&mut self, status: impl Into) { @@ -4725,8 +4728,18 @@ fn reply_command_line_logical_row(panel: &ReplyPanelState) -> usize { reply_body_line_logical_row(panel.body.len()) + 1 } -fn reply_field_prefix_width(label: &str) -> usize { - 1 + 1 + label.chars().count() + 2 +fn reply_editable_field_prefix(section: ReplySection) -> &'static str { + match section { + ReplySection::From => "[edit] From: ", + ReplySection::To => "[edit] To: ", + ReplySection::Cc => "[edit] Cc: ", + ReplySection::Subject => "[edit] Subject: ", + ReplySection::Body => "", + } +} + +fn reply_field_prefix_width(section: ReplySection) -> usize { + 1 + 1 + reply_editable_field_prefix(section).chars().count() } fn reply_body_prefix_width(body_row: usize) -> usize { diff --git a/src/ui/tui/render.rs b/src/ui/tui/render.rs index 1d489af..6325bfc 100644 --- a/src/ui/tui/render.rs +++ b/src/ui/tui/render.rs @@ -884,7 +884,7 @@ fn draw_reply_panel(frame: &mut Frame<'_>, state: &AppState) { let body_area = sections[1]; let header_block = Block::default() - .title("Headers (From/To/Cc/Subject editable)") + .title("Headers ([edit] / [read-only])") .borders(Borders::ALL) .border_style(reply_panel_section_style( matches!( @@ -944,28 +944,32 @@ fn draw_reply_panel(frame: &mut Frame<'_>, state: &AppState) { fn render_reply_header_content(panel: &ReplyPanelState) -> String { [ format!( - "{} From: {}", + "{} {}{}", reply_section_marker(panel, ReplySection::From), + reply_editable_field_prefix(ReplySection::From), sanitize_source_preview_text(&panel.from) ), format!( - "{} To: {}", + "{} {}{}", reply_section_marker(panel, ReplySection::To), + reply_editable_field_prefix(ReplySection::To), sanitize_source_preview_text(&panel.to) ), format!( - "{} Cc: {}", + "{} {}{}", reply_section_marker(panel, ReplySection::Cc), + reply_editable_field_prefix(ReplySection::Cc), sanitize_source_preview_text(&panel.cc) ), format!( - "{} Subject: {}", + "{} {}{}", reply_section_marker(panel, ReplySection::Subject), + reply_editable_field_prefix(ReplySection::Subject), sanitize_source_preview_text(&panel.subject) ), - format!(" In-Reply-To: <{}>", panel.in_reply_to), + format!(" [read-only] In-Reply-To: <{}>", panel.in_reply_to), format!( - " References: {}", + " [read-only] References: {}", panel .references .iter() @@ -1073,22 +1077,26 @@ fn reply_panel_cursor_position( ReplySection::From => reply_fixed_cursor_position( header_inner, 0, - reply_field_prefix_width("From") + display_column(&panel.from, panel.cursor_col), + reply_field_prefix_width(ReplySection::From) + + display_column(&panel.from, panel.cursor_col), ), ReplySection::To => reply_fixed_cursor_position( header_inner, 1, - reply_field_prefix_width("To") + display_column(&panel.to, panel.cursor_col), + reply_field_prefix_width(ReplySection::To) + + display_column(&panel.to, panel.cursor_col), ), ReplySection::Cc => reply_fixed_cursor_position( header_inner, 2, - reply_field_prefix_width("Cc") + display_column(&panel.cc, panel.cursor_col), + reply_field_prefix_width(ReplySection::Cc) + + display_column(&panel.cc, panel.cursor_col), ), ReplySection::Subject => reply_fixed_cursor_position( header_inner, 3, - reply_field_prefix_width("Subject") + display_column(&panel.subject, panel.cursor_col), + reply_field_prefix_width(ReplySection::Subject) + + display_column(&panel.subject, panel.cursor_col), ), ReplySection::Body => { let line = panel diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index c9f4e87..c6cefb1 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -4343,6 +4343,7 @@ fn mail_preview_e_opens_reply_panel_with_autofilled_headers() { assert_eq!(panel.subject, "Re: [PATCH] demo"); assert_eq!(panel.in_reply_to, "patch@example.com"); assert_eq!(panel.references, vec!["patch@example.com"]); + assert_eq!(panel.section, ReplySection::From); let mut terminal = Terminal::new(TestBackend::new(140, 40)).expect("create test terminal"); terminal @@ -4350,9 +4351,18 @@ fn mail_preview_e_opens_reply_panel_with_autofilled_headers() { .expect("draw reply panel"); let rendered = format!("{}", terminal.backend()); assert!(rendered.contains("Reply Panel")); - assert!(rendered.contains("Headers (From/To/Cc/Subject editable)")); + assert!(rendered.contains("focus:From")); + assert!(rendered.contains("Headers ([edit] / [read-only])")); assert!(rendered.contains("Reply Body")); + assert!(rendered.contains("[edit] To: Bob ")); + assert!(rendered.contains("[edit] Cc: Alice ")); + assert!(rendered.contains("[read-only] In-Reply-To: ")); assert!(rendered.contains("Subject: Re: [PATCH] demo")); + assert!( + state + .status + .contains("edit From/To/Cc/Subject before Send Preview") + ); let _ = fs::remove_dir_all(root); } @@ -4535,7 +4545,7 @@ fn reply_send_blocked_notice_and_ready_notice_replace_reply_panel_view() { let rendered = format!("{}", terminal.backend()); assert!(rendered.contains("Send Blocked")); assert!(rendered.contains("You must open Send Preview")); - assert!(!rendered.contains("Headers (From/To/Cc/Subject editable)")); + assert!(!rendered.contains("Headers ([edit] / [read-only])")); assert!(!rendered.contains("Reply Body")); let _ = handle_key_event( @@ -4553,7 +4563,7 @@ fn reply_send_blocked_notice_and_ready_notice_replace_reply_panel_view() { let rendered = format!("{}", terminal.backend()); assert!(rendered.contains("Ready To Send")); assert!(rendered.contains("Press S to send the reply")); - assert!(!rendered.contains("Headers (From/To/Cc/Subject editable)")); + assert!(!rendered.contains("Headers ([edit] / [read-only])")); assert!(!rendered.contains("Reply Body")); let _ = fs::remove_dir_all(root); @@ -4727,7 +4737,7 @@ fn reply_send_preview_warns_but_allows_confirm_without_authored_reply_text() { let rendered = format!("{}", terminal.backend()); assert!(rendered.contains("Send Preview [warning]")); assert!(rendered.contains("draft has no authored reply content")); - assert!(!rendered.contains("Headers (From/To/Cc/Subject editable)")); + assert!(!rendered.contains("Headers ([edit] / [read-only])")); assert!(!rendered.contains("Reply Body")); let _ = handle_key_event( @@ -4794,7 +4804,7 @@ fn reply_send_preview_highlights_authored_lines_and_keeps_quotes_bright() { let rendered = format!("{}", terminal.backend()); assert!(rendered.contains("Send Preview [reply highlighted]")); assert!(rendered.contains("Your authored reply lines are highlighted below.")); - assert!(!rendered.contains("Headers (From/To/Cc/Subject editable)")); + assert!(!rendered.contains("Headers ([edit] / [read-only])")); assert!(!rendered.contains("Reply Body")); let (authored_fg, authored_bg, authored_modifier) = From 8ab54a0d59c96d8140a051fbb3587d69badeb245 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Tue, 10 Mar 2026 00:51:13 +0800 Subject: [PATCH 20/29] feat(tui): switch preview mails with = and - Signed-off-by: Chen Miao --- README-zh.md | 1 + README.md | 1 + src/ui/tui.rs | 45 +++++++++++++++++++------------ src/ui/tui/input.rs | 14 +++++++++- src/ui/tui/tests.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 17 deletions(-) diff --git a/README-zh.md b/README-zh.md index 31f2c7d..e52904e 100644 --- a/README-zh.md +++ b/README-zh.md @@ -203,6 +203,7 @@ criew tui - 数字前缀可重复垂直移动:`default/custom` 使用 `数字 + i/k`,`vim` 使用 `数字 + j/k` - `y` / `n` 启用或禁用当前订阅 - `Enter` 打开当前 mailbox 或 thread,并自动切到 threads 或 preview pane +- `-` / `=` 在保持 preview pane 焦点时切换上一封或下一封邮件 - `a` apply 当前 patch series - `d` 导出当前 patch series - `u` 撤销本次会话中最近一次成功 apply diff --git a/README.md b/README.md index 41fda4f..1919122 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ Inside the TUI: - a numeric prefix repeats vertical movement: `count+i/k` for `default`/`custom`, `count+j/k` for `vim` - `y` / `n` enable or disable the selected subscription - `Enter` opens the selected mailbox or thread and moves focus to threads or preview +- `-` / `=` switch to the previous or next mail while preview stays focused - `a` applies the current patch series - `d` exports the current patch series - `u` undoes the most recent successful apply from the current session diff --git a/src/ui/tui.rs b/src/ui/tui.rs index d1b21eb..998354e 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -3674,6 +3674,29 @@ impl AppState { } } + fn select_filtered_thread_at(&mut self, filtered_index: usize) { + debug_assert!(filtered_index < self.filtered_thread_indices.len()); + self.thread_index = filtered_index; + self.preview_scroll = 0; + self.refresh_selected_mail_preview(); + } + + fn select_previous_thread(&mut self) { + if self.thread_index == 0 { + return; + } + + self.select_filtered_thread_at(self.thread_index - 1); + } + + fn select_next_thread(&mut self) { + if self.thread_index + 1 >= self.filtered_thread_indices.len() { + return; + } + + self.select_filtered_thread_at(self.thread_index + 1); + } + fn move_up(&mut self) { match self.ui_page { UiPage::Mail => match self.focus { @@ -3681,11 +3704,7 @@ impl AppState { self.move_subscription_up(); } Pane::Threads => { - if self.thread_index > 0 { - self.thread_index -= 1; - self.preview_scroll = 0; - self.refresh_selected_mail_preview(); - } + self.select_previous_thread(); } Pane::Preview => { self.preview_scroll = self @@ -3713,11 +3732,7 @@ impl AppState { self.move_subscription_down(); } Pane::Threads => { - if self.thread_index + 1 < self.filtered_thread_indices.len() { - self.thread_index += 1; - self.preview_scroll = 0; - self.refresh_selected_mail_preview(); - } + self.select_next_thread(); } Pane::Preview => { let preview_scroll_limit = self.preview_scroll_limit.get(); @@ -3757,9 +3772,7 @@ impl AppState { } Pane::Threads => { if !self.filtered_thread_indices.is_empty() { - self.thread_index = 0; - self.preview_scroll = 0; - self.refresh_selected_mail_preview(); + self.select_filtered_thread_at(0); } } Pane::Preview => { @@ -3796,9 +3809,9 @@ impl AppState { } Pane::Threads => { if !self.filtered_thread_indices.is_empty() { - self.thread_index = self.filtered_thread_indices.len().saturating_sub(1); - self.preview_scroll = 0; - self.refresh_selected_mail_preview(); + self.select_filtered_thread_at( + self.filtered_thread_indices.len().saturating_sub(1), + ); } } Pane::Preview => { diff --git a/src/ui/tui/input.rs b/src/ui/tui/input.rs index 37de756..2cde4c7 100644 --- a/src/ui/tui/input.rs +++ b/src/ui/tui/input.rs @@ -245,6 +245,18 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio { state.set_current_subscription_enabled(false); } + KeyCode::Char('-') + if matches!(state.ui_page, UiPage::Mail) && matches!(state.focus, Pane::Preview) => + { + state.select_previous_thread(); + } + KeyCode::Char(character) + if matches!(state.ui_page, UiPage::Mail) + && matches!(state.focus, Pane::Preview) + && matches!(character, '=' | '+') => + { + state.select_next_thread(); + } KeyCode::Char('e') if matches!(state.ui_page, UiPage::Mail) && matches!(state.focus, Pane::Preview) => { @@ -702,7 +714,7 @@ fn handle_palette_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { "restart" => return LoopAction::Restart, "help" => { state.status = format!( - "commands: quit, exit, restart, help, sync [mailbox], config ..., vim, ! | keys: {} focus, {} move, y/n enable, a apply, d download, u undo apply, e reply/inline edit, r reply, E external vim", + "commands: quit, exit, restart, help, sync [mailbox], config ..., vim, ! | keys: {} focus, {} move, -/= preview switch, y/n enable, a apply, d download, u undo apply, e reply/inline edit, r reply, E external vim", main_page_focus_shortcuts(state.runtime.ui_keymap), main_page_move_shortcuts(state.runtime.ui_keymap) ); diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index c6cefb1..92a2497 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -851,6 +851,7 @@ fn command_palette_help_includes_keyboard_shortcuts() { assert!(state.status.contains("keys:")); assert!(state.status.contains("j/l focus")); assert!(state.status.contains("i/k move")); + assert!(state.status.contains("-/= preview switch")); assert!(state.status.contains("y/n enable")); assert!(state.status.contains("a apply")); assert!(state.status.contains("d download")); @@ -1163,6 +1164,69 @@ fn counted_main_page_navigation_does_not_leak_into_focus_changes() { assert_eq!(state.thread_index, 1); } +#[test] +fn preview_focus_supports_minus_equals_shifted_equals_and_plus_thread_navigation() { + let mut state = AppState::new(sample_threads(4), test_runtime()); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Preview)); + assert_eq!(state.thread_index, 1); + + state.preview_scroll = 7; + let action_previous = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE), + ); + assert!(matches!(action_previous, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Preview)); + assert_eq!(state.thread_index, 0); + assert_eq!(state.preview_scroll, 0); + + state.preview_scroll = 5; + let action_next_with_equals = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('='), KeyModifiers::NONE), + ); + assert!(matches!(action_next_with_equals, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Preview)); + assert_eq!(state.thread_index, 1); + assert_eq!(state.preview_scroll, 0); + + state.preview_scroll = 5; + let action_next_with_shifted_equals = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('='), KeyModifiers::SHIFT), + ); + assert!(matches!( + action_next_with_shifted_equals, + LoopAction::Continue + )); + assert!(matches!(state.focus, Pane::Preview)); + assert_eq!(state.thread_index, 2); + assert_eq!(state.preview_scroll, 0); + + state.preview_scroll = 5; + let action_next_with_plus = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('+'), KeyModifiers::SHIFT), + ); + assert!(matches!(action_next_with_plus, LoopAction::Continue)); + assert!(matches!(state.focus, Pane::Preview)); + assert_eq!(state.thread_index, 3); + assert_eq!(state.preview_scroll, 0); +} + #[test] fn vim_keymap_supports_gg_and_capital_g_jumps_on_mail_panes() { let mut runtime = test_runtime(); From e521463aa81fb55a2a72b5ce0748898414e75d62 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Tue, 10 Mar 2026 01:31:16 +0800 Subject: [PATCH 21/29] feat(ui): persist resizable mail panes - persist subscriptions and preview pane widths in ui-state.toml - support expanding and shrinking mail panes with [ ] and { } - add TUI regression tests and update user-facing shortcut docs Signed-off-by: Chen Miao --- README-zh.md | 1 + README.md | 1 + docs/architecture/design.md | 2 +- src/infra/ui_state.rs | 31 ++++++- src/ui/tui.rs | 142 +++++++++++++++++++++++++++- src/ui/tui/input.rs | 14 ++- src/ui/tui/render.rs | 26 +++--- src/ui/tui/tests.rs | 180 ++++++++++++++++++++++++++++++++++-- 8 files changed, 375 insertions(+), 22 deletions(-) diff --git a/README-zh.md b/README-zh.md index e52904e..f45c3aa 100644 --- a/README-zh.md +++ b/README-zh.md @@ -203,6 +203,7 @@ criew tui - 数字前缀可重复垂直移动:`default/custom` 使用 `数字 + i/k`,`vim` 使用 `数字 + j/k` - `y` / `n` 启用或禁用当前订阅 - `Enter` 打开当前 mailbox 或 thread,并自动切到 threads 或 preview pane +- `[` / `]` 按当前聚焦 pane 向左或向右扩充 mail 三栏宽度;`{` / `}` 做对应方向缩小,并持久化到 `ui-state.toml` - `-` / `=` 在保持 preview pane 焦点时切换上一封或下一封邮件 - `a` apply 当前 patch series - `d` 导出当前 patch series diff --git a/README.md b/README.md index 1919122..1e7ad49 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ Inside the TUI: - a numeric prefix repeats vertical movement: `count+i/k` for `default`/`custom`, `count+j/k` for `vim` - `y` / `n` enable or disable the selected subscription - `Enter` opens the selected mailbox or thread and moves focus to threads or preview +- `[` / `]` expands the focused mail pane left or right; `{` / `}` shrinks it in that direction and persists the widths in `ui-state.toml` - `-` / `=` switch to the previous or next mail while preview stays focused - `a` applies the current patch series - `d` exports the current patch series diff --git a/docs/architecture/design.md b/docs/architecture/design.md index 798f478..5adef55 100644 --- a/docs/architecture/design.md +++ b/docs/architecture/design.md @@ -363,7 +363,7 @@ MVP 范围与阶段目标已迁移至独立文档: - TUI 左栏采用 vger 子系统模板订阅,支持 `y/n` 启停、启用/停用双分组、折叠展开、 `Enter` 打开对应 mailbox Threads。 - 命令栏支持 `sync` / `sync `,并在启动 TUI 时自动同步“已启用订阅”。 -- UI 状态持久化 `enabled_mailboxes`、分组展开状态、active mailbox 到 `ui-state.toml`; +- UI 状态持久化 `enabled_mailboxes`、分组展开状态、active mailbox 和 mail pane 宽度到 `ui-state.toml`; 首次启动默认全部订阅禁用;该基线在 M6 规划中仅对新增 `My Inbox` 订阅例外。 - 右栏预览默认隐藏 RFC 头部,过滤控制字符,切换线程时清屏并重置滚动,避免残留字符。 diff --git a/src/infra/ui_state.rs b/src/infra/ui_state.rs index 7ba6353..72ef9e4 100644 --- a/src/infra/ui_state.rs +++ b/src/infra/ui_state.rs @@ -12,6 +12,9 @@ use serde::{Deserialize, Serialize}; use crate::infra::error::{CriewError, ErrorCode, Result}; +pub const DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH: u16 = 23; +pub const DEFAULT_MAIL_PREVIEW_WIDTH: u16 = 90; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UiState { #[serde(default)] @@ -32,12 +35,24 @@ pub struct UiState { pub imap_defaults_initialized: bool, #[serde(default)] pub active_mailbox: Option, + #[serde(default = "default_mail_subscriptions_width")] + pub mail_subscriptions_width: u16, + #[serde(default = "default_mail_preview_width")] + pub mail_preview_width: u16, } fn default_true() -> bool { true } +fn default_mail_subscriptions_width() -> u16 { + DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH +} + +fn default_mail_preview_width() -> u16 { + DEFAULT_MAIL_PREVIEW_WIDTH +} + impl Default for UiState { fn default() -> Self { Self { @@ -50,6 +65,8 @@ impl Default for UiState { disabled_qemu_subsystem_expanded: true, imap_defaults_initialized: false, active_mailbox: None, + mail_subscriptions_width: DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH, + mail_preview_width: DEFAULT_MAIL_PREVIEW_WIDTH, } } } @@ -138,7 +155,10 @@ mod tests { use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; - use super::{UiState, load, path_for_data_dir, save}; + use super::{ + DEFAULT_MAIL_PREVIEW_WIDTH, DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH, UiState, load, + path_for_data_dir, save, + }; fn temp_dir(label: &str) -> PathBuf { let nonce = SystemTime::now() @@ -164,6 +184,8 @@ mod tests { disabled_qemu_subsystem_expanded: false, imap_defaults_initialized: true, active_mailbox: Some("bpf".to_string()), + mail_subscriptions_width: 28, + mail_preview_width: 84, }; save(&path, &state).expect("save state"); @@ -181,6 +203,8 @@ mod tests { assert!(!loaded.disabled_qemu_subsystem_expanded); assert!(loaded.imap_defaults_initialized); assert_eq!(loaded.active_mailbox.as_deref(), Some("bpf")); + assert_eq!(loaded.mail_subscriptions_width, 28); + assert_eq!(loaded.mail_preview_width, 84); let _ = fs::remove_dir_all(root); } @@ -203,6 +227,11 @@ mod tests { assert!(loaded.enabled_qemu_subsystem_expanded); assert!(loaded.disabled_linux_subsystem_expanded); assert!(loaded.disabled_qemu_subsystem_expanded); + assert_eq!( + loaded.mail_subscriptions_width, + DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH + ); + assert_eq!(loaded.mail_preview_width, DEFAULT_MAIL_PREVIEW_WIDTH); let _ = fs::remove_dir_all(root); } diff --git a/src/ui/tui.rs b/src/ui/tui.rs index 998354e..be10471 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -100,6 +100,49 @@ impl Pane { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct MailPaneLayout { + subscriptions_width: u16, + preview_width: u16, +} + +impl Default for MailPaneLayout { + fn default() -> Self { + Self { + subscriptions_width: ui_state::DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH, + preview_width: ui_state::DEFAULT_MAIL_PREVIEW_WIDTH, + } + } +} + +impl MailPaneLayout { + fn from_persisted(state: Option<&UiState>) -> Self { + let defaults = Self::default(); + Self { + subscriptions_width: state + .map(|persisted| persisted.mail_subscriptions_width) + .unwrap_or(defaults.subscriptions_width) + .max(MIN_MAIL_SUBSCRIPTIONS_WIDTH), + preview_width: state + .map(|persisted| persisted.mail_preview_width) + .unwrap_or(defaults.preview_width) + .max(MIN_MAIL_PREVIEW_WIDTH), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HorizontalResizeDirection { + Left, + Right, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MailPaneResizeMode { + Expand, + Shrink, +} + #[derive(Debug, Clone, Copy)] struct PaletteCommand { name: &'static str, @@ -159,9 +202,11 @@ const PALETTE_COMMANDS: &[PaletteCommand] = &[ ]; const PALETTE_SYNC_RECONNECT_ATTEMPTS: u8 = 3; +const MAIL_PANE_RESIZE_STEP: u16 = 4; +const MIN_MAIL_PREVIEW_WIDTH: u16 = 40; +const MIN_MAIL_SUBSCRIPTIONS_WIDTH: u16 = 12; const PREVIEW_TAB_SPACES: &str = " "; const PREVIEW_RECIPIENT_PREVIEW_LIMIT: usize = 2; -const PREVIEW_PANE_FIXED_WIDTH: u16 = 90; const THREAD_LINE_MAX_CHARS: usize = 120; const KERNEL_TREE_MAX_ROWS: usize = 2048; const CODE_PREVIEW_MAX_BYTES: usize = 256 * 1024; @@ -327,6 +372,17 @@ fn main_page_navigation_shortcuts(keymap: UiKeymap) -> String { ) } +fn shrink_mail_pane_width(width: &mut u16, minimum_width: u16) -> bool { + if *width <= minimum_width { + return false; + } + + *width = width + .saturating_sub(MAIL_PANE_RESIZE_STEP) + .max(minimum_width); + true +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ExternalEditorProcessResult { success: bool, @@ -1214,6 +1270,7 @@ struct AppState { imap_defaults_initialized: bool, ui_page: UiPage, focus: Pane, + mail_pane_layout: MailPaneLayout, code_focus: CodePaneFocus, subscriptions: Vec, enabled_group_expanded: bool, @@ -1275,6 +1332,7 @@ impl AppState { persisted: Option, ) -> Self { let ui_state_path = ui_state::path_for_data_dir(&runtime.data_dir); + let mail_pane_layout = MailPaneLayout::from_persisted(persisted.as_ref()); let persisted_imap_defaults_initialized = persisted .as_ref() .map(|state| state.imap_defaults_initialized) @@ -1313,6 +1371,7 @@ impl AppState { imap_defaults_initialized: persisted_imap_defaults_initialized, ui_page: UiPage::Mail, focus: Pane::Subscriptions, + mail_pane_layout, code_focus: CodePaneFocus::Tree, subscriptions, enabled_group_expanded: persisted @@ -2722,6 +2781,8 @@ impl AppState { disabled_qemu_subsystem_expanded: self.disabled_qemu_subsystem_expanded, imap_defaults_initialized: self.imap_defaults_initialized, active_mailbox: Some(self.active_thread_mailbox.clone()), + mail_subscriptions_width: self.mail_pane_layout.subscriptions_width, + mail_preview_width: self.mail_pane_layout.preview_width, } } @@ -3674,6 +3735,85 @@ impl AppState { } } + fn resize_mail_panes( + &mut self, + direction: HorizontalResizeDirection, + resize_mode: MailPaneResizeMode, + ) { + let did_resize = match (self.focus, direction, resize_mode) { + (Pane::Subscriptions, HorizontalResizeDirection::Left, _) => false, + (Pane::Subscriptions, HorizontalResizeDirection::Right, MailPaneResizeMode::Expand) => { + self.grow_mail_subscriptions_pane() + } + (Pane::Subscriptions, HorizontalResizeDirection::Right, MailPaneResizeMode::Shrink) => { + self.shrink_mail_subscriptions_pane() + } + (Pane::Threads, HorizontalResizeDirection::Left, MailPaneResizeMode::Expand) => { + self.shrink_mail_subscriptions_pane() + } + (Pane::Threads, HorizontalResizeDirection::Left, MailPaneResizeMode::Shrink) => { + self.grow_mail_subscriptions_pane() + } + (Pane::Threads, HorizontalResizeDirection::Right, MailPaneResizeMode::Expand) => { + self.shrink_mail_preview_pane() + } + (Pane::Threads, HorizontalResizeDirection::Right, MailPaneResizeMode::Shrink) => { + self.grow_mail_preview_pane() + } + (Pane::Preview, HorizontalResizeDirection::Left, MailPaneResizeMode::Expand) => { + self.grow_mail_preview_pane() + } + (Pane::Preview, HorizontalResizeDirection::Left, MailPaneResizeMode::Shrink) => { + self.shrink_mail_preview_pane() + } + (Pane::Preview, HorizontalResizeDirection::Right, _) => false, + }; + + if did_resize { + self.persist_ui_state(); + self.status = format!( + "mail panes saved: subscriptions {} cols, preview {} cols", + self.mail_pane_layout.subscriptions_width, self.mail_pane_layout.preview_width + ); + } else { + self.status = match resize_mode { + MailPaneResizeMode::Expand => "mail pane cannot expand in that direction", + MailPaneResizeMode::Shrink => "mail pane cannot shrink in that direction", + } + .to_string(); + } + } + + fn grow_mail_subscriptions_pane(&mut self) -> bool { + self.mail_pane_layout.subscriptions_width = self + .mail_pane_layout + .subscriptions_width + .saturating_add(MAIL_PANE_RESIZE_STEP); + true + } + + fn shrink_mail_subscriptions_pane(&mut self) -> bool { + shrink_mail_pane_width( + &mut self.mail_pane_layout.subscriptions_width, + MIN_MAIL_SUBSCRIPTIONS_WIDTH, + ) + } + + fn grow_mail_preview_pane(&mut self) -> bool { + self.mail_pane_layout.preview_width = self + .mail_pane_layout + .preview_width + .saturating_add(MAIL_PANE_RESIZE_STEP); + true + } + + fn shrink_mail_preview_pane(&mut self) -> bool { + shrink_mail_pane_width( + &mut self.mail_pane_layout.preview_width, + MIN_MAIL_PREVIEW_WIDTH, + ) + } + fn select_filtered_thread_at(&mut self, filtered_index: usize) { debug_assert!(filtered_index < self.filtered_thread_indices.len()); self.thread_index = filtered_index; diff --git a/src/ui/tui/input.rs b/src/ui/tui/input.rs index 2cde4c7..285bd2c 100644 --- a/src/ui/tui/input.rs +++ b/src/ui/tui/input.rs @@ -267,6 +267,18 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio { state.open_reply_panel(false); } + KeyCode::Char('[') if matches!(state.ui_page, UiPage::Mail) => { + state.resize_mail_panes(HorizontalResizeDirection::Left, MailPaneResizeMode::Expand); + } + KeyCode::Char(']') if matches!(state.ui_page, UiPage::Mail) => { + state.resize_mail_panes(HorizontalResizeDirection::Right, MailPaneResizeMode::Expand); + } + KeyCode::Char('{') if matches!(state.ui_page, UiPage::Mail) => { + state.resize_mail_panes(HorizontalResizeDirection::Left, MailPaneResizeMode::Shrink); + } + KeyCode::Char('}') if matches!(state.ui_page, UiPage::Mail) => { + state.resize_mail_panes(HorizontalResizeDirection::Right, MailPaneResizeMode::Shrink); + } KeyCode::Char('e') if matches!(state.ui_page, UiPage::CodeBrowser) => { state.enter_code_edit_mode(); } @@ -714,7 +726,7 @@ fn handle_palette_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { "restart" => return LoopAction::Restart, "help" => { state.status = format!( - "commands: quit, exit, restart, help, sync [mailbox], config ..., vim, ! | keys: {} focus, {} move, -/= preview switch, y/n enable, a apply, d download, u undo apply, e reply/inline edit, r reply, E external vim", + "commands: quit, exit, restart, help, sync [mailbox], config ..., vim, ! | keys: {} focus, {} move, [ ] expand pane, {{ }} shrink pane, -/= preview switch, y/n enable, a apply, d download, u undo apply, e reply/inline edit, r reply, E external vim", main_page_focus_shortcuts(state.runtime.ui_keymap), main_page_move_shortcuts(state.runtime.ui_keymap) ); diff --git a/src/ui/tui/render.rs b/src/ui/tui/render.rs index 6325bfc..b54bc04 100644 --- a/src/ui/tui/render.rs +++ b/src/ui/tui/render.rs @@ -128,7 +128,7 @@ pub(super) fn draw( match state.ui_page { UiPage::Mail => { - let panes = mail_page_panes(areas[1]); + let panes = mail_page_panes(areas[1], state.mail_pane_layout); draw_subscriptions(frame, panes[0], state); draw_threads(frame, panes[1], state); draw_preview(frame, panes[2], state, config); @@ -160,7 +160,7 @@ pub(super) fn draw( "/ search | Tab page | : palette | Enter | e/r reply".to_string() } UiPage::Mail => format!( - "/ search | Tab page | : palette | Enter | e/r reply | {}", + "/ search | Tab page | : palette | Enter | e/r reply | [ ] expand pane | {{ }} shrink pane | {}", main_page_navigation_shortcuts(state.runtime.ui_keymap) ), UiPage::CodeBrowser if state.is_code_edit_active() => { @@ -240,12 +240,12 @@ fn draw_code_browser_page(frame: &mut Frame<'_>, area: Rect, state: &AppState) { draw_code_source_preview(frame, panes[1], state); } -pub(super) fn mail_page_panes(area: Rect) -> [Rect; 3] { +pub(super) fn mail_page_panes(area: Rect, layout: MailPaneLayout) -> [Rect; 3] { if area.width == 0 { return [area, area, area]; } - let preview_width = area.width.min(PREVIEW_PANE_FIXED_WIDTH); + let preview_width = area.width.min(layout.preview_width); let left_width = area.width.saturating_sub(preview_width); let preview = Rect { x: area.x + left_width, @@ -264,18 +264,22 @@ pub(super) fn mail_page_panes(area: Rect) -> [Rect; 3] { return [empty, empty, preview]; } - let left = Rect { + let subscriptions_width = left_width.min(layout.subscriptions_width); + let threads_width = left_width.saturating_sub(subscriptions_width); + let subscriptions = Rect { x: area.x, y: area.y, - width: left_width, + width: subscriptions_width, + height: area.height, + }; + let threads = Rect { + x: area.x + subscriptions_width, + y: area.y, + width: threads_width, height: area.height, }; - let left_panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Ratio(1, 4), Constraint::Ratio(3, 4)]) - .split(left); - [left_panes[0], left_panes[1], preview] + [subscriptions, threads, preview] } fn draw_subscriptions(frame: &mut Frame<'_>, area: Rect, state: &AppState) { diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 92a2497..8d92958 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -24,13 +24,14 @@ use crate::infra::mail_parser; use crate::infra::mail_store::{self, IncomingMail, SyncBatch, ThreadRow}; use crate::infra::reply_store::{self, ReplySendStatus}; use crate::infra::sendmail::{SendOutcome, SendRequest, SendStatus}; -use crate::infra::ui_state::UiState; +use crate::infra::ui_state::{self, UiState}; use super::palette::run_palette_sync; use super::preview::preview_warning_message; use super::reply::ReplyIdentity; use super::{ - AppState, CodeEditMode, CodePaneFocus, ExternalEditorProcessResult, LoopAction, MY_INBOX_LABEL, + AppState, CodeEditMode, CodePaneFocus, ExternalEditorProcessResult, LoopAction, + MIN_MAIL_PREVIEW_WIDTH, MIN_MAIL_SUBSCRIPTIONS_WIDTH, MY_INBOX_LABEL, MailPaneLayout, ManualSyncOrigin, ManualSyncRequestOutcome, ManualSyncState, Pane, ReplyEditMode, ReplySection, StartupSyncEvent, StartupSyncMailboxStatus, StartupSyncState, SubscriptionItem, UiPage, catch_sync_panic, code_edit_cursor_position, draw, extract_mail_body_preview, @@ -729,7 +730,7 @@ fn external_editor_session_restores_terminal_after_editor_exit() { #[test] fn mail_page_layout_keeps_preview_at_fixed_90_columns() { - let panes = mail_page_panes(Rect::new(0, 0, 180, 20)); + let panes = mail_page_panes(Rect::new(0, 0, 180, 20), MailPaneLayout::default()); assert_eq!(panes[2].width, 90); assert_eq!(panes[2].x, 90); @@ -740,13 +741,28 @@ fn mail_page_layout_keeps_preview_at_fixed_90_columns() { #[test] fn mail_page_layout_falls_back_to_available_width_when_terminal_is_narrow() { - let panes = mail_page_panes(Rect::new(0, 0, 60, 20)); + let panes = mail_page_panes(Rect::new(0, 0, 60, 20), MailPaneLayout::default()); assert_eq!(panes[2].width, 60); assert_eq!(panes[0].width, 0); assert_eq!(panes[1].width, 0); } +#[test] +fn mail_page_layout_uses_persisted_fixed_mail_pane_widths() { + let panes = mail_page_panes( + Rect::new(0, 0, 180, 20), + MailPaneLayout { + subscriptions_width: 31, + preview_width: 84, + }, + ); + + assert_eq!(panes[0].width, 31); + assert_eq!(panes[1].width, 65); + assert_eq!(panes[2].width, 84); +} + #[test] fn subscription_line_shows_marker_and_mailbox_name_only() { let enabled = SubscriptionItem { @@ -851,6 +867,8 @@ fn command_palette_help_includes_keyboard_shortcuts() { assert!(state.status.contains("keys:")); assert!(state.status.contains("j/l focus")); assert!(state.status.contains("i/k move")); + assert!(state.status.contains("[ ] expand pane")); + assert!(state.status.contains("{ } shrink pane")); assert!(state.status.contains("-/= preview switch")); assert!(state.status.contains("y/n enable")); assert!(state.status.contains("a apply")); @@ -1227,6 +1245,127 @@ fn preview_focus_supports_minus_equals_shifted_equals_and_plus_thread_navigation assert_eq!(state.preview_scroll, 0); } +#[test] +fn resize_shortcuts_follow_the_focused_mail_pane_and_persist_layout() { + let root = temp_dir("mail-pane-resize"); + let runtime = test_runtime_in(root.clone()); + let mut state = AppState::new(vec![], runtime); + + let expand_subscriptions = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE), + ); + assert!(matches!(expand_subscriptions, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.subscriptions_width, 27); + assert_eq!(state.mail_pane_layout.preview_width, 90); + + let shrink_subscriptions = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('}'), KeyModifiers::SHIFT), + ); + assert!(matches!(shrink_subscriptions, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.subscriptions_width, 23); + + state.focus = Pane::Threads; + let expand_threads_left = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE), + ); + assert!(matches!(expand_threads_left, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.subscriptions_width, 19); + + let shrink_threads_left = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('{'), KeyModifiers::SHIFT), + ); + assert!(matches!(shrink_threads_left, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.subscriptions_width, 23); + + let expand_threads_right = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE), + ); + assert!(matches!(expand_threads_right, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.preview_width, 86); + + let shrink_threads_right = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('}'), KeyModifiers::SHIFT), + ); + assert!(matches!(shrink_threads_right, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.preview_width, 90); + + state.focus = Pane::Preview; + let expand_preview = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE), + ); + assert!(matches!(expand_preview, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.preview_width, 94); + + let shrink_preview = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('{'), KeyModifiers::SHIFT), + ); + assert!(matches!(shrink_preview, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.preview_width, 90); + + let persisted = ui_state::load(&state.ui_state_path) + .expect("load persisted ui state") + .expect("ui state exists"); + assert_eq!(persisted.mail_subscriptions_width, 23); + assert_eq!(persisted.mail_preview_width, 90); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn resize_shortcuts_stop_at_fixed_edges_and_minimum_mail_pane_widths() { + let mut state = AppState::new(vec![], test_runtime()); + + let fixed_edge_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('['), KeyModifiers::NONE), + ); + assert!(matches!(fixed_edge_action, LoopAction::Continue)); + assert_eq!( + state.mail_pane_layout.subscriptions_width, + ui_state::DEFAULT_MAIL_SUBSCRIPTIONS_WIDTH + ); + assert_eq!(state.status, "mail pane cannot expand in that direction"); + + state.mail_pane_layout.subscriptions_width = MIN_MAIL_SUBSCRIPTIONS_WIDTH; + + let min_subscriptions_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('}'), KeyModifiers::SHIFT), + ); + assert!(matches!(min_subscriptions_action, LoopAction::Continue)); + assert_eq!( + state.mail_pane_layout.subscriptions_width, + MIN_MAIL_SUBSCRIPTIONS_WIDTH + ); + assert_eq!(state.status, "mail pane cannot shrink in that direction"); + + state.focus = Pane::Preview; + let preview_fixed_edge_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE), + ); + assert!(matches!(preview_fixed_edge_action, LoopAction::Continue)); + assert_eq!(state.status, "mail pane cannot expand in that direction"); + + state.mail_pane_layout.preview_width = MIN_MAIL_PREVIEW_WIDTH; + + let min_preview_action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('{'), KeyModifiers::SHIFT), + ); + assert!(matches!(min_preview_action, LoopAction::Continue)); + assert_eq!(state.mail_pane_layout.preview_width, MIN_MAIL_PREVIEW_WIDTH); + assert_eq!(state.status, "mail pane cannot shrink in that direction"); +} + #[test] fn vim_keymap_supports_gg_and_capital_g_jumps_on_mail_panes() { let mut runtime = test_runtime(); @@ -2198,7 +2337,7 @@ fn command_palette_renders_local_command_result() { KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); - let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("create test terminal"); + let mut terminal = Terminal::new(TestBackend::new(180, 30)).expect("create test terminal"); terminal .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) .expect("draw frame"); @@ -2859,7 +2998,7 @@ fn code_edit_draw_sets_terminal_cursor_position() { ); assert!(matches!(state.code_edit_mode, CodeEditMode::VimNormal)); - let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("create test terminal"); + let mut terminal = Terminal::new(TestBackend::new(180, 30)).expect("create test terminal"); let mut expected_cursor: Option<(u16, u16)> = None; terminal .draw(|frame| { @@ -3125,6 +3264,26 @@ fn first_open_with_complete_imap_enables_my_inbox() { assert_eq!(state.active_thread_mailbox, IMAP_INBOX_MAILBOX); } +#[test] +fn app_state_restores_and_re_persists_mail_pane_layout_from_ui_state() { + let state = AppState::new_with_ui_state( + vec![], + test_runtime(), + Some(UiState { + mail_subscriptions_width: 29, + mail_preview_width: 82, + ..UiState::default() + }), + ); + + assert_eq!(state.mail_pane_layout.subscriptions_width, 29); + assert_eq!(state.mail_pane_layout.preview_width, 82); + + let persisted = state.to_ui_state(); + assert_eq!(persisted.mail_subscriptions_width, 29); + assert_eq!(persisted.mail_preview_width, 82); +} + #[test] fn legacy_ui_state_with_complete_imap_enables_my_inbox_once() { let state = AppState::new_with_ui_state( @@ -3140,6 +3299,7 @@ fn legacy_ui_state_with_complete_imap_enables_my_inbox_once() { disabled_qemu_subsystem_expanded: true, imap_defaults_initialized: false, active_mailbox: Some("io-uring".to_string()), + ..UiState::default() }), ); @@ -3168,6 +3328,7 @@ fn initialized_ui_state_keeps_my_inbox_disabled_when_user_opted_out() { disabled_qemu_subsystem_expanded: true, imap_defaults_initialized: true, active_mailbox: Some("io-uring".to_string()), + ..UiState::default() }), ); @@ -3216,6 +3377,7 @@ fn empty_active_inbox_recovers_to_cached_enabled_mailbox() { disabled_qemu_subsystem_expanded: true, imap_defaults_initialized: true, active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() }), ); @@ -3252,6 +3414,7 @@ fn startup_sync_failure_for_empty_inbox_falls_back_to_cached_mailbox() { disabled_qemu_subsystem_expanded: true, imap_defaults_initialized: true, active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() }), ); @@ -3553,6 +3716,7 @@ fn enter_on_mailbox_pending_startup_sync_stays_non_blocking() { disabled_qemu_subsystem_expanded: true, imap_defaults_initialized: true, active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() }), ); state.focus = Pane::Subscriptions; @@ -3592,6 +3756,7 @@ fn enter_on_mailbox_pending_manual_sync_stays_non_blocking() { disabled_qemu_subsystem_expanded: true, imap_defaults_initialized: true, active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() }), ); state.focus = Pane::Subscriptions; @@ -3657,6 +3822,7 @@ fn background_success_does_not_steal_focus_from_pending_inbox() { disabled_qemu_subsystem_expanded: true, imap_defaults_initialized: true, active_mailbox: Some(IMAP_INBOX_MAILBOX.to_string()), + ..UiState::default() }), ); state.startup_sync = Some(startup_sync_state(&[ @@ -5844,7 +6010,7 @@ fn threads_panel_renders_thread_group_headers() { ); state.focus = Pane::Threads; - let mut terminal = Terminal::new(TestBackend::new(120, 30)).expect("create test terminal"); + let mut terminal = Terminal::new(TestBackend::new(180, 30)).expect("create test terminal"); terminal .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) .expect("draw frame"); From 1ce1268ffaa73c0272ce64b40ba48d737ab4dfa1 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Wed, 11 Mar 2026 00:45:53 +0800 Subject: [PATCH 22/29] feat(wiki): add local authoring workflow - add the docs/wiki submodule, local MkDocs build scripts, and a GitHub Pages workflow for wiki publishing - add cargo wiki commands through xtask and document the local wiki lint/build flow in the README files - add the CRIEW wiki authoring skill and record the rewritten wiki home page commit in the submodule pointer Signed-off-by: Chen Miao --- .cargo/config.toml | 2 + .codex/skills/criew-wiki-authoring/SKILL.md | 81 +++++ .../criew-wiki-authoring/agents/openai.yaml | 4 + .../references/page-patterns.md | 122 ++++++++ .../references/publish-workflow.md | 39 +++ .../references/style-guide.md | 55 ++++ .github/workflows/wiki-pages.yml | 80 +++++ .gitmodules | 3 + Cargo.lock | 4 + Cargo.toml | 4 + README-zh.md | 2 + README.md | 2 + docs/wiki | 1 + docs/wiki-requirements.txt | 1 + mkdocs.yml | 11 + scripts/prepare-wiki-site.py | 236 +++++++++++++++ scripts/wiki-lint.sh | 108 +++++++ scripts/wiki-site.sh | 63 ++++ xtask/Cargo.toml | 7 + xtask/src/main.rs | 282 ++++++++++++++++++ 20 files changed, 1107 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .codex/skills/criew-wiki-authoring/SKILL.md create mode 100644 .codex/skills/criew-wiki-authoring/agents/openai.yaml create mode 100644 .codex/skills/criew-wiki-authoring/references/page-patterns.md create mode 100644 .codex/skills/criew-wiki-authoring/references/publish-workflow.md create mode 100644 .codex/skills/criew-wiki-authoring/references/style-guide.md create mode 100644 .github/workflows/wiki-pages.yml create mode 160000 docs/wiki create mode 100644 docs/wiki-requirements.txt create mode 100644 mkdocs.yml create mode 100755 scripts/prepare-wiki-site.py create mode 100755 scripts/wiki-lint.sh create mode 100755 scripts/wiki-site.sh create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..bde798d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +wiki = "run --quiet -p xtask --" diff --git a/.codex/skills/criew-wiki-authoring/SKILL.md b/.codex/skills/criew-wiki-authoring/SKILL.md new file mode 100644 index 0000000..c67753b --- /dev/null +++ b/.codex/skills/criew-wiki-authoring/SKILL.md @@ -0,0 +1,81 @@ +--- +name: criew-wiki-authoring +description: Write and revise pages under `docs/wiki` for the CRIEW GitHub wiki and its MkDocs-backed GitHub Pages site. Use when Codex needs to create, expand, reorganize, or review CRIEW wiki pages such as `Home.md`, `_Sidebar.md`, `_Footer.md`, or topic pages, and when the output or workflow must follow GitHub wiki conventions, the local MkDocs pipeline, and a pragmatic, kernel-documentation writing style. +--- + +# Criew Wiki Authoring + +## Overview + +Write CRIEW wiki pages as maintainer documentation. +Treat `docs/wiki/` as the source GitHub wiki repository, and treat the published website as a derived MkDocs build driven from the main `CRIEW` repository. +Keep the source compatible with both GitHub wiki rendering and the local Pages build pipeline, and prefer a direct, technical style over narrative or marketing copy. + +## Follow This Workflow + +1. Confirm the source of truth before writing. +- Read the code, README, spec, config example, or architecture note that defines the behavior. +- Prefer `README.md`, `README-zh.md`, `docs/architecture/design.md`, `docs/specs/`, and `docs/reference/config.example.toml` depending on the topic. +- Do not invent commands, defaults, limitations, or workflows. + +2. Check the wiki context. +- List the existing pages in `docs/wiki/` before creating or renaming a page. +- Treat `docs/wiki/` as a separate Git repository. Use `git -C docs/wiki ...` when checking history or status. +- Read `references/publish-workflow.md` before changing local preview, staging, or deployment behavior. +- Keep `Home.md` as the landing page and update it when a new top-level page changes how readers enter the wiki. +- Read `references/style-guide.md` for page rules. +- Read `references/page-patterns.md` when choosing a page shape. + +3. Choose the smallest page that fits the job. +- Use a short reference page for stable facts and concepts. +- Use a workflow page for operator tasks with prerequisites and ordered steps. +- Use a troubleshooting page for symptoms, likely causes, and recovery actions. +- Split a page once it starts carrying more than one primary purpose. + +4. Draft in GitHub wiki form. +- Use page names and filenames that are stable, literal, and easy to scan. +- Prefer normal Markdown links such as `[Configuration](Configuration.md)` because they work in both GitHub wiki and MkDocs. +- Treat `[[Page Name]]` and `[[Page Name|Link text]]` as compatibility syntax only. The local staging script rewrites them for MkDocs, but new content should not depend on that rewrite when a Markdown link is clear enough. +- Use full GitHub URLs when linking from the wiki back into the main CRIEW repository, because the wiki is a separate repository. +- Add `_Sidebar.md` or `_Footer.md` only when shared navigation or repeated context is materially useful. + +5. Check the publish path before finishing. +- Local copy lint goes through `./scripts/wiki-lint.sh`. +- Local preview and local build go through `./scripts/wiki-site.sh serve` and `./scripts/wiki-site.sh build`. +- The staging step copies `docs/wiki/` into `target/wiki-docs/`, turns `Home.md` into the MkDocs `index.md`, excludes special wiki-only files such as `_Sidebar.md` and `_Footer.md`, and normalizes source-only links. +- The published website is built from `mkdocs.yml` and deployed by `.github/workflows/wiki-pages.yml`. +- The lint script requires `autocorrect`. If it is missing, the script downloads a local copy into `target/wiki-venv/bin/` and then reruns the check. +- Treat a clean `./scripts/wiki-lint.sh` result as part of done for every wiki page this skill creates or edits. +- Because `docs/wiki` is a submodule, the main repository deploys the pinned wiki commit, not the remote wiki repository's latest HEAD. A new wiki commit reaches GitHub Pages only after the CRIEW repository updates the `docs/wiki` submodule pointer. + +6. Draft in kernel-documentation style. +- Lead with scope and purpose. +- Prefer active voice, short sentences, and concrete nouns. +- State prerequisites, constraints, and side effects before optional detail. +- Prefer commands, paths, config keys, and observable outcomes over abstract explanation. +- Avoid filler, advocacy, roadmap prose, and vague adjectives such as `simple`, `easy`, `powerful`, or `seamless`. +- Name conditions directly when behavior depends on mode, configuration, or state. + +7. Review before finishing. +- Verify commands, file paths, environment variables, config keys, and feature names against the repository. +- Keep language consistent within the page. Match the page's existing language unless the user requests a change. +- Check that headings are informative, examples are minimal, and links resolve to the intended wiki page or repository URL. +- Run `./scripts/wiki-lint.sh` for every text change and revise the page until it passes. If the environment prevents the check, report that explicitly instead of assuming the page is clean. +- When page structure or links changed, run `./scripts/wiki-site.sh build` if the environment allows it. + +## Apply These Page Rules + +- Start each page with a short paragraph that states what the page covers and when to use it. +- Prefer headings such as `Prerequisites`, `Workflow`, `Configuration`, `Troubleshooting`, `Limitations`, and `See also` when they fit the content. +- Keep heading depth shallow unless the page genuinely needs more structure. +- Keep lists parallel and action-oriented. +- Use fenced code blocks for commands, config fragments, and sample output. +- Keep examples minimal but real. +- Write copy that passes `autocorrect`; treat lint failures as defects that need wording changes before the page is considered complete. +- Preserve CRIEW naming: use `CRIEW` for the project and `criew` for the binary, crate, config file, runtime directory, and environment variables. + +## Use The References + +- `references/style-guide.md`: GitHub wiki constraints, linking rules, and writing expectations. +- `references/page-patterns.md`: Reusable page skeletons for `Home.md`, workflow pages, reference pages, and troubleshooting pages. +- `references/publish-workflow.md`: The `docs/wiki -> MkDocs -> GitHub Pages` pipeline, local preview commands, and submodule publication constraints. diff --git a/.codex/skills/criew-wiki-authoring/agents/openai.yaml b/.codex/skills/criew-wiki-authoring/agents/openai.yaml new file mode 100644 index 0000000..ce5b7ca --- /dev/null +++ b/.codex/skills/criew-wiki-authoring/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CRIEW Wiki" + short_description: "CRIEW GitHub wiki authoring guide" + default_prompt: "Use $criew-wiki-authoring to draft or revise pages under docs/wiki in the CRIEW GitHub wiki style." diff --git a/.codex/skills/criew-wiki-authoring/references/page-patterns.md b/.codex/skills/criew-wiki-authoring/references/page-patterns.md new file mode 100644 index 0000000..c0a9533 --- /dev/null +++ b/.codex/skills/criew-wiki-authoring/references/page-patterns.md @@ -0,0 +1,122 @@ +# CRIEW Wiki Page Patterns + +## Home Page + +Use this when editing `Home.md`. + +```md +# CRIEW Wiki + +Briefly state what the wiki covers and who should read it. + +## Start here + +- [Install and setup](Install-and-Setup.md) +- [Configuration](Configuration.md) +- [Patch workflow](Patch-Workflow.md) +- [Troubleshooting](Troubleshooting.md) + +## Topic map + +### User workflows + +- [Sync mail](Sync-Mail.md) +- [Review and reply](Review-and-Reply.md) +- [Apply patches](Apply-Patches.md) + +### Reference + +- [Configuration reference](Configuration-Reference.md) +- [Key concepts](Key-Concepts.md) + +### Development + +- [CRIEW repository](https://github.com/ChenMiaoi/CRIEW) +- [Contributor notes](Contributor-Notes.md) +``` + +## Workflow Page + +Use this for task-oriented pages. + +```md +# Page Title + +State the task, operator, and expected outcome. + +## Prerequisites + +- Required environment, files, or state. + +## Workflow + +1. Run the first command or enter the first screen. +2. Describe the expected result. +3. Continue with the next observable step. + +## Verify the result + +- State what success looks like. + +## Troubleshooting + +- Common failure mode and the next action. + +## See also + +- [Related page](Related-Page.md) +``` + +## Reference Page + +Use this for concepts, settings, or stable behavior. + +```md +# Page Title + +State the scope and the object being described. + +## Overview + +Brief definition or behavioral summary. + +## Fields or options + +- `key_name`: State meaning, accepted values, and important default behavior. + +## Constraints + +- State limits, invariants, or caveats. + +## Related behavior + +- Explain how this topic affects adjacent workflows. +``` + +## Troubleshooting Page + +Use this for failure-driven guidance. + +```md +# Page Title + +State the symptom or failure class this page covers. + +## Symptoms + +- Observable error, UI state, or command output. + +## Likely causes + +- Most common cause first. + +## Recovery steps + +1. Verify the suspected cause. +2. Apply the fix. +3. Confirm recovery. + +## Escalation + +- State when the user should stop and inspect code, logs, or configuration in more detail. +``` diff --git a/.codex/skills/criew-wiki-authoring/references/publish-workflow.md b/.codex/skills/criew-wiki-authoring/references/publish-workflow.md new file mode 100644 index 0000000..9285b8b --- /dev/null +++ b/.codex/skills/criew-wiki-authoring/references/publish-workflow.md @@ -0,0 +1,39 @@ +# CRIEW Wiki Publish Workflow + +## Pipeline + +The publication path is: + +`docs/wiki` source repo -> `scripts/prepare-wiki-site.py` staging -> `mkdocs.yml` build -> `.github/workflows/wiki-pages.yml` deploy -> GitHub Pages + +This means the source of truth stays in the GitHub wiki repository, while the published website is generated from the main `CRIEW` repository. + +## Local Commands + +- `./scripts/wiki-lint.sh`: Check wiki copy with `autocorrect`. If the command is missing, the script downloads a local copy into `target/wiki-venv/bin/`. +- `./scripts/wiki-site.sh prepare`: Stage the wiki into `target/wiki-docs` without installing MkDocs. +- `./scripts/wiki-site.sh serve`: Stage the wiki, install MkDocs into `target/wiki-venv`, and start a local preview server on `0.0.0.0:8000` by default. Override the bind address with `CRIEW_WIKI_DEV_ADDR`. +- `./scripts/wiki-site.sh build`: Stage the wiki, install MkDocs into `target/wiki-venv`, and build `target/wiki-site`. + +Treat `./scripts/wiki-lint.sh` as mandatory for wiki content changes. +Do not consider a new or edited wiki page complete until the copy passes the lint check, unless the environment prevents running the command and that limitation is reported. + +## What The Staging Step Does + +- Copy published wiki content from `docs/wiki/` into `target/wiki-docs/`. +- Rewrite `Home.md` into the MkDocs landing page `index.md`. +- Skip GitHub wiki helper files such as `_Sidebar.md` and `_Footer.md`. +- Rewrite legacy `[[Page]]` wiki links into Markdown links that MkDocs can resolve. + +## CI And Deployment + +- `.github/workflows/wiki-pages.yml` runs `./scripts/wiki-lint.sh` before it builds the site on pull requests that touch the wiki publish pipeline. +- The same workflow deploys to GitHub Pages on pushes to `develop`. +- The workflow uploads the rendered site from `target/wiki-site`. +- This CI step is the enforcement point for the same copy rules that the skill expects locally. Write pages to pass the lint check before handing the change off. + +## Submodule Constraint + +`docs/wiki` is a Git submodule in the main repository. +That means the GitHub Pages deployment uses the wiki commit pinned by the main `CRIEW` repository. +If the standalone wiki repository advances but the main repository does not update the `docs/wiki` gitlink, GitHub Pages will keep publishing the older pinned wiki snapshot. diff --git a/.codex/skills/criew-wiki-authoring/references/style-guide.md b/.codex/skills/criew-wiki-authoring/references/style-guide.md new file mode 100644 index 0000000..e410a57 --- /dev/null +++ b/.codex/skills/criew-wiki-authoring/references/style-guide.md @@ -0,0 +1,55 @@ +# CRIEW Wiki Style Guide + +## GitHub Wiki Constraints + +- Treat `docs/wiki/` as the source wiki repository, not as a normal docs folder inside the main repo. +- Treat the published website as a derived MkDocs build from the main `CRIEW` repository. +- Keep `Home.md` as the landing page. +- Use `_Sidebar.md` for shared navigation only when the page set is large enough to justify it. +- Use `_Footer.md` only for short, repeated context such as related links or maintenance notes. +- Prefer Markdown pages unless the existing page already uses another supported markup. +- Avoid syntax that GitHub wikis do not support well, such as definition lists, table-of-contents directives, transclusion, or heavy indentation tricks. + +## Page Naming + +- Use stable, descriptive page names. +- Keep filenames portable. Avoid characters that are awkward in URLs or file systems. +- Match the filename to the page topic instead of using vague names such as `Notes` or `Misc`. +- Rename pages only when the old name is clearly wrong or blocks navigation. + +## Linking Rules + +- Prefer standard Markdown links for internal page links, for example `[Configuration](Configuration.md)`. +- Keep `[[Page Name]]` or `[[Page Name|Link text]]` only as compatibility syntax for older pages. `scripts/prepare-wiki-site.py` rewrites them during MkDocs staging. +- Use normal Markdown links for external URLs. +- Use full GitHub URLs when linking from the wiki to files, directories, issues, or pull requests in the main `CRIEW` repository. +- Do not rely on repository-relative paths from the wiki back to the main repo. The wiki is a separate repository, so those links are easy to break. +- Keep link text explicit. Prefer the destination's role over generic text such as `here` or `more`. + +## Writing Style + +- State what the page is for in the opening paragraph. +- Prefer direct statements over explanation-first prose. +- Put prerequisites before steps. +- Put limitations and side effects near the action that triggers them. +- Prefer one fact per sentence when the topic is operational. +- Use active voice and imperative steps for procedures. +- Avoid marketing tone, vision statements, and rhetorical fillers. +- Avoid unexplained claims such as `fast`, `simple`, `robust`, or `advanced`. +- Keep wording compatible with `autocorrect`. If lint flags a phrase, rewrite the sentence instead of suppressing the check. + +## Evidence And Accuracy + +- Verify commands, config keys, and paths against the current repository state. +- Cite the real source in prose when the behavior comes from a spec, config example, or code path. +- Call out assumptions explicitly if the repository does not prove them. +- Prefer a small verified page over a broad speculative page. +- Run `./scripts/wiki-lint.sh` after every copy edit and treat a clean result as required for completion. If the environment prevents the check, report that gap explicitly. + +## Page Maintenance + +- Update `Home.md` when a new page changes the top-level information architecture. +- Merge or split pages when navigation starts to hide the main task flow. +- Keep the lint path working: `./scripts/wiki-lint.sh` installs `autocorrect` on demand when the binary is missing. +- Run `./scripts/wiki-site.sh build` after link or structure changes when the environment allows it. +- Remove stale TODO text before finishing. diff --git a/.github/workflows/wiki-pages.yml b/.github/workflows/wiki-pages.yml new file mode 100644 index 0000000..406a114 --- /dev/null +++ b/.github/workflows/wiki-pages.yml @@ -0,0 +1,80 @@ +name: Wiki Pages + +on: + pull_request: + paths: + - .github/workflows/wiki-pages.yml + - .gitmodules + - docs/wiki + - docs/wiki-requirements.txt + - mkdocs.yml + - scripts/prepare-wiki-site.py + - scripts/wiki-lint.sh + - scripts/wiki-site.sh + push: + branches: + - develop + paths: + - .github/workflows/wiki-pages.yml + - .gitmodules + - docs/wiki + - docs/wiki-requirements.txt + - mkdocs.yml + - scripts/prepare-wiki-site.py + - scripts/wiki-lint.sh + - scripts/wiki-site.sh + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: wiki-pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Configure GitHub Pages + uses: actions/configure-pages@v5 + + - name: Lint wiki copy + run: ./scripts/wiki-lint.sh + + - name: Build wiki site + run: ./scripts/wiki-site.sh build + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: target/wiki-site + + deploy: + if: github.event_name == 'push' && github.ref == 'refs/heads/develop' + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitmodules b/.gitmodules index 57ee896..8f91d5c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "vendor/b4"] path = vendor/b4 url = https://github.com/mricon/b4.git +[submodule "docs/wiki"] + path = docs/wiki + url = https://github.com/ChenMiaoi/CRIEW.wiki.git diff --git a/Cargo.lock b/Cargo.lock index eea8554..a8f2a51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2601,6 +2601,10 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xtask" +version = "0.1.0" + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 758ec74..8e44228 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,10 @@ include = [ "/vendor/b4/patatt/patatt/*.py", ] +[workspace] +members = ["xtask"] +resolver = "3" + [dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } clap = { version = "4.5", features = ["derive"] } diff --git a/README-zh.md b/README-zh.md index f45c3aa..c3ac59e 100644 --- a/README-zh.md +++ b/README-zh.md @@ -248,6 +248,8 @@ CRIEW 的 Reply Panel 会自动填充: ### 相关文档 - [README.md](README.md): 英文项目说明 +- `docs/wiki`:GitHub wiki 源子模块;可通过 `cargo wiki serve` 本地预览 MkDocs 站点(默认监听 `0.0.0.0:8000`,可用 `CRIEW_WIKI_DEV_ADDR` 覆盖) +- `cargo wiki check`:本地执行 wiki 文案检查并构建 MkDocs 站点;若缺少 `autocorrect`,首次运行 `cargo wiki lint` 或 `cargo wiki check` 时会把它缓存到 `target/wiki-venv/bin/` - [docs/reference/config.example.toml](docs/reference/config.example.toml): 配置示例 - [docs/architecture/design.md](docs/architecture/design.md): 设计文档 - [docs/specs/reply-format-spec.md](docs/specs/reply-format-spec.md): 回信格式规范 diff --git a/README.md b/README.md index 1e7ad49..6aeabfa 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,8 @@ Enabled mailing-list subscriptions also keep doing periodic background sync whil ### Documentation - [README-zh.md](README-zh.md): Chinese usage guide +- `docs/wiki`: GitHub wiki source submodule; preview the MkDocs site with `cargo wiki serve` (defaults to `0.0.0.0:8000`, override with `CRIEW_WIKI_DEV_ADDR`) +- `cargo wiki check`: lint wiki copy and build the local MkDocs site; if `autocorrect` is missing, the first `cargo wiki lint` or `cargo wiki check` download caches it under `target/wiki-venv/bin/` - [docs/reference/config.example.toml](docs/reference/config.example.toml): configuration example - [docs/architecture/design.md](docs/architecture/design.md): design notes - [docs/specs/reply-format-spec.md](docs/specs/reply-format-spec.md): reply panel and sending format diff --git a/docs/wiki b/docs/wiki new file mode 160000 index 0000000..14d5f32 --- /dev/null +++ b/docs/wiki @@ -0,0 +1 @@ +Subproject commit 14d5f323f2dd07a334b8d4d348f13fc14a09c699 diff --git a/docs/wiki-requirements.txt b/docs/wiki-requirements.txt new file mode 100644 index 0000000..3bd21f5 --- /dev/null +++ b/docs/wiki-requirements.txt @@ -0,0 +1 @@ +mkdocs>=1.6,<2.0 diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..269df7c --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,11 @@ +site_name: CRIEW Wiki +site_description: CRIEW GitHub wiki rendered as a MkDocs site. +repo_name: ChenMiaoi/CRIEW +repo_url: https://github.com/ChenMiaoi/CRIEW +edit_uri: "" +docs_dir: target/wiki-docs +site_dir: target/wiki-site +strict: true + +theme: + name: mkdocs diff --git a/scripts/prepare-wiki-site.py b/scripts/prepare-wiki-site.py new file mode 100755 index 0000000..d491930 --- /dev/null +++ b/scripts/prepare-wiki-site.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import posixpath +import re +import shutil +import sys +from pathlib import Path, PurePosixPath + +MARKDOWN_SUFFIXES = {".md", ".markdown"} +SPECIAL_WIKI_FILES = {".git", "_Sidebar.md", "_Footer.md"} +WIKI_LINK_RE = re.compile(r"\[\[([^\]|]+?)(?:\|([^\]]+))?\]\]") +MARKDOWN_LINK_RE = re.compile(r"(? argparse.Namespace: + parser = argparse.ArgumentParser( + description="Stage docs/wiki for MkDocs and normalize wiki-only links." + ) + parser.add_argument( + "--source", + default="docs/wiki", + help="GitHub wiki source directory.", + ) + parser.add_argument( + "--output", + default="target/wiki-docs", + help="Prepared MkDocs docs directory.", + ) + return parser.parse_args() + + +def normalize_page_key(name: str) -> str: + page_name = name.strip().removesuffix(".md").removesuffix(".markdown") + page_name = page_name.split("#", 1)[0] + page_name = page_name.rstrip("/") + return re.sub(r"[\s_-]+", " ", page_name).strip().lower() + + +def is_markdown(path: Path) -> bool: + return path.suffix.lower() in MARKDOWN_SUFFIXES + + +def should_skip(rel_path: Path) -> bool: + return rel_path.name in SPECIAL_WIKI_FILES + + +def output_rel_for(source_rel: Path) -> Path: + if source_rel == Path("Home.md"): + return Path("index.md") + return source_rel + + +def build_page_lookup(source_dir: Path) -> tuple[dict[str, PurePosixPath], dict[str, PurePosixPath]]: + page_lookup: dict[str, PurePosixPath] = {} + source_to_output: dict[str, PurePosixPath] = {} + + for source_path in sorted(source_dir.rglob("*")): + if not source_path.is_file(): + continue + + source_rel = source_path.relative_to(source_dir) + if should_skip(source_rel) or not is_markdown(source_rel): + continue + + output_rel = PurePosixPath(output_rel_for(source_rel).as_posix()) + source_key = source_rel.as_posix() + source_to_output[source_key] = output_rel + + page_key = normalize_page_key(source_rel.stem) + existing = page_lookup.get(page_key) + if existing is not None and existing != output_rel: + raise ValueError( + f"duplicate wiki page key '{page_key}' for {existing} and {output_rel}" + ) + page_lookup[page_key] = output_rel + + home_page = page_lookup.get("home") + if home_page is not None: + page_lookup.setdefault("index", home_page) + + return page_lookup, source_to_output + + +def rel_link_to(output_target: PurePosixPath, current_output: PurePosixPath) -> str: + current_parent = current_output.parent + relative = posixpath.relpath(output_target.as_posix(), current_parent.as_posix() or ".") + return relative + + +def rewrite_wiki_links( + text: str, + source_rel: Path, + current_output: PurePosixPath, + page_lookup: dict[str, PurePosixPath], +) -> str: + errors: list[str] = [] + + def replace(match: re.Match[str]) -> str: + target_name = match.group(1).strip() + link_text = match.group(2).strip() if match.group(2) else target_name + target_output = page_lookup.get(normalize_page_key(target_name)) + if target_output is None: + errors.append( + f"{source_rel.as_posix()}: unresolved wiki link [[{target_name}]]" + ) + return match.group(0) + + return f"[{link_text}]({rel_link_to(target_output, current_output)})" + + rewritten = WIKI_LINK_RE.sub(replace, text) + if errors: + raise ValueError("\n".join(errors)) + return rewritten + + +def normalize_local_markdown_target( + target: str, + source_rel: Path, + current_output: PurePosixPath, + source_to_output: dict[str, PurePosixPath], +) -> str | None: + if not target or "://" in target or target.startswith("mailto:"): + return None + + if target.startswith("#") or target.startswith("/"): + return None + + fragment = "" + target_body = target + if "#" in target: + target_body, fragment = target.split("#", 1) + fragment = f"#{fragment}" + + target_body = target_body.strip() + if not target_body: + return None + + current_source = PurePosixPath(source_rel.as_posix()) + candidate_paths = [PurePosixPath(target_body)] + if not PurePosixPath(target_body).suffix: + candidate_paths.append(PurePosixPath(f"{target_body}.md")) + + for candidate in candidate_paths: + joined = current_source.parent.joinpath(candidate) + normalized = PurePosixPath(posixpath.normpath(joined.as_posix())) + output_target = source_to_output.get(normalized.as_posix()) + if output_target is None: + continue + return f"{rel_link_to(output_target, current_output)}{fragment}" + + if target_body == "Home.md": + return f"{rel_link_to(PurePosixPath('index.md'), current_output)}{fragment}" + + return None + + +def rewrite_markdown_links( + text: str, + source_rel: Path, + current_output: PurePosixPath, + source_to_output: dict[str, PurePosixPath], +) -> str: + def replace(match: re.Match[str]) -> str: + label = match.group(1) + target = match.group(2) + normalized = normalize_local_markdown_target( + target, + source_rel, + current_output, + source_to_output, + ) + if normalized is None: + return match.group(0) + return f"[{label}]({normalized})" + + return MARKDOWN_LINK_RE.sub(replace, text) + + +def stage_wiki(source_dir: Path, output_dir: Path) -> None: + if not source_dir.is_dir(): + raise FileNotFoundError(f"wiki source directory not found: {source_dir}") + + if not (source_dir / "Home.md").is_file(): + raise FileNotFoundError( + f"expected {source_dir / 'Home.md'} so MkDocs can build an index page" + ) + + if output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + page_lookup, source_to_output = build_page_lookup(source_dir) + + for source_path in sorted(source_dir.rglob("*")): + if not source_path.is_file(): + continue + + source_rel = source_path.relative_to(source_dir) + if should_skip(source_rel): + continue + + output_rel = output_rel_for(source_rel) + destination = output_dir / output_rel + destination.parent.mkdir(parents=True, exist_ok=True) + + if not is_markdown(source_rel): + shutil.copy2(source_path, destination) + continue + + text = source_path.read_text(encoding="utf-8") + current_output = PurePosixPath(output_rel.as_posix()) + text = rewrite_wiki_links(text, source_rel, current_output, page_lookup) + text = rewrite_markdown_links(text, source_rel, current_output, source_to_output) + destination.write_text(text, encoding="utf-8") + + +def main() -> int: + args = parse_args() + source_dir = Path(args.source) + output_dir = Path(args.output) + + try: + stage_wiki(source_dir, output_dir) + except Exception as exc: # pragma: no cover - CLI error path + print(exc, file=sys.stderr) + return 1 + + print(f"prepared MkDocs source at {output_dir} from {source_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/wiki-lint.sh b/scripts/wiki-lint.sh new file mode 100755 index 0000000..5e71f35 --- /dev/null +++ b/scripts/wiki-lint.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +set -euo pipefail + +autocorrect_repo="huacnlee/autocorrect" + +latest_autocorrect_release() { + curl --silent "https://api.github.com/repos/${autocorrect_repo}/releases/latest" | + grep '"tag_name":' | + sed -E 's/.*"([^"]+)".*/\1/' +} + +autocorrect_platform() { + uname | tr '[:upper:]' '[:lower:]' +} + +autocorrect_arch() { + uname -m | sed 's/x86_64/amd64/' +} + +autocorrect_libc_suffix() { + if ldd --version 2>&1 | grep -q 'musl'; then + printf '%s\n' '-musl' + return 0 + fi + + printf '%s\n' '' +} + +resolve_autocorrect_bin() { + if command -v autocorrect >/dev/null 2>&1; then + command -v autocorrect + return 0 + fi + + local candidate + for candidate in \ + "${repo_root}/target/wiki-venv/bin/autocorrect" \ + "/opt/homebrew/bin/autocorrect" \ + "/usr/local/bin/autocorrect" \ + "${HOME}/.autocorrect/bin/autocorrect" \ + "${HOME}/.cargo/bin/autocorrect" \ + "${HOME}/bin/autocorrect" \ + "${HOME}/.local/bin/autocorrect" + do + if [[ -x "${candidate}" ]]; then + printf '%s\n' "${candidate}" + return 0 + fi + done + + return 1 +} + +install_autocorrect_into_target() { + local install_dir="${repo_root}/target/wiki-venv/bin" + local install_bin="${install_dir}/autocorrect" + local version platform arch libc_suffix archive_url temp_dir + + mkdir -p "${install_dir}" + + version="$(latest_autocorrect_release)" + platform="$(autocorrect_platform)" + arch="$(autocorrect_arch)" + libc_suffix="$(autocorrect_libc_suffix)" + archive_url="https://github.com/${autocorrect_repo}/releases/download/${version}/autocorrect-${platform}${libc_suffix}-${arch}.tar.gz" + temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/autocorrect-install.XXXXXX")" + + printf '%s\n' "autocorrect is not installed; downloading ${archive_url} into ${install_dir}" >&2 + curl -fsSL -o "${temp_dir}/autocorrect.tar.gz" "${archive_url}" + tar -xzf "${temp_dir}/autocorrect.tar.gz" -C "${temp_dir}" + + if [[ ! -f "${temp_dir}/autocorrect" ]]; then + printf '%s\n' "autocorrect archive from ${archive_url} did not contain an autocorrect binary" >&2 + rm -rf "${temp_dir}" + exit 1 + fi + + mv "${temp_dir}/autocorrect" "${install_bin}" + chmod 0755 "${install_bin}" + rm -rf "${temp_dir}" + + printf '%s\n' "${install_bin}" +} + +ensure_autocorrect() { + local autocorrect_bin + if autocorrect_bin="$(resolve_autocorrect_bin)"; then + printf '%s\n' "${autocorrect_bin}" + return 0 + fi + + autocorrect_bin="$(install_autocorrect_into_target)" + + if [[ -x "${autocorrect_bin}" ]]; then + printf '%s\n' "${autocorrect_bin}" + return 0 + fi + + printf '%s\n' "autocorrect installation completed but no executable was found in ${repo_root}/target/wiki-venv/bin" >&2 + exit 1 +} + +repo_root="$(git rev-parse --show-toplevel)" +cd "${repo_root}" + +autocorrect_bin="$(ensure_autocorrect)" +exec "${autocorrect_bin}" --lint docs/wiki "$@" diff --git a/scripts/wiki-site.sh b/scripts/wiki-site.sh new file mode 100755 index 0000000..61977dd --- /dev/null +++ b/scripts/wiki-site.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: ./scripts/wiki-site.sh [mkdocs args...] + +prepare Stage docs/wiki into target/wiki-docs without installing MkDocs. +serve Prepare the wiki, install MkDocs into target/wiki-venv, and start a local server. +build Prepare the wiki, install MkDocs into target/wiki-venv, and build target/wiki-site. +EOF +} + +repo_root="$(git rev-parse --show-toplevel)" +requirements_file="${repo_root}/docs/wiki-requirements.txt" +venv_dir="${repo_root}/target/wiki-venv" +python_bin="${venv_dir}/bin/python3" +wiki_dev_addr="${CRIEW_WIKI_DEV_ADDR:-0.0.0.0:8000}" + +prepare_wiki() { + python3 "${repo_root}/scripts/prepare-wiki-site.py" +} + +ensure_mkdocs() { + if [[ ! -x "${python_bin}" ]]; then + python3 -m venv "${venv_dir}" + "${python_bin}" -m pip install --upgrade pip + fi + + "${python_bin}" -m pip install -r "${requirements_file}" +} + +command_name="${1:-}" +if [[ -z "${command_name}" ]]; then + usage >&2 + exit 1 +fi + +shift || true + +case "${command_name}" in + prepare) + prepare_wiki + ;; + serve) + prepare_wiki + ensure_mkdocs + exec "${python_bin}" -m mkdocs serve \ + -f "${repo_root}/mkdocs.yml" \ + --dev-addr "${wiki_dev_addr}" \ + "$@" + ;; + build) + prepare_wiki + ensure_mkdocs + exec "${python_bin}" -m mkdocs build -f "${repo_root}/mkdocs.yml" --clean "$@" + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..6622e3b --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..e429b84 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,282 @@ +use std::env; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +fn main() -> ExitCode { + match run(env::args_os().skip(1).collect()) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} + +fn run(args: Vec) -> Result<(), XtaskError> { + match ParseOutcome::parse(args)? { + ParseOutcome::Help => { + println!("{}", WikiCommand::help()); + Ok(()) + } + ParseOutcome::Invocation(invocation) => invocation.run(), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WikiCommand { + Build, + Check, + Lint, + Prepare, + Serve, +} + +impl WikiCommand { + fn parse(value: &OsStr) -> Option { + match value.to_str()? { + "build" => Some(Self::Build), + "check" => Some(Self::Check), + "lint" => Some(Self::Lint), + "prepare" => Some(Self::Prepare), + "serve" => Some(Self::Serve), + _ => None, + } + } + + fn help() -> &'static str { + "\ +Usage: cargo wiki [args...] + +build Run ./scripts/wiki-site.sh build [mkdocs args...] +check Run wiki lint and then wiki build +lint Run ./scripts/wiki-lint.sh [autocorrect args...] +prepare Run ./scripts/wiki-site.sh prepare +serve Run ./scripts/wiki-site.sh serve [mkdocs args...]" + } +} + +#[derive(Debug, Eq, PartialEq)] +struct WikiInvocation { + command: WikiCommand, + extra_args: Vec, +} + +#[derive(Debug, Eq, PartialEq)] +enum ParseOutcome { + Help, + Invocation(WikiInvocation), +} + +impl ParseOutcome { + fn parse(args: Vec) -> Result { + let mut args = args.into_iter(); + let Some(command_name) = args.next() else { + return Err(UsageError::new("missing wiki command")); + }; + + if command_name == "help" || command_name == "--help" || command_name == "-h" { + return Ok(Self::Help); + } + + let Some(command) = WikiCommand::parse(&command_name) else { + let invalid_name = command_name.to_string_lossy().into_owned(); + return Err(UsageError::new(format!( + "unknown wiki command '{invalid_name}'" + ))); + }; + + Ok(Self::Invocation(WikiInvocation { + command, + extra_args: args.collect(), + })) + } +} + +impl WikiInvocation { + fn run(self) -> Result<(), XtaskError> { + let repo_root = repo_root()?; + + match self.command { + WikiCommand::Lint => run_script( + repo_root.join("scripts/wiki-lint.sh"), + self.extra_args, + &repo_root, + ), + WikiCommand::Prepare => run_script_with_prefix( + "scripts/wiki-site.sh", + ["prepare"], + self.extra_args, + &repo_root, + ), + WikiCommand::Build => run_script_with_prefix( + "scripts/wiki-site.sh", + ["build"], + self.extra_args, + &repo_root, + ), + WikiCommand::Serve => run_script_with_prefix( + "scripts/wiki-site.sh", + ["serve"], + self.extra_args, + &repo_root, + ), + WikiCommand::Check => { + if !self.extra_args.is_empty() { + return Err(XtaskError::Usage(UsageError::new( + "cargo wiki check does not accept extra arguments", + ))); + } + + run_script(repo_root.join("scripts/wiki-lint.sh"), [], &repo_root)?; + run_script_with_prefix("scripts/wiki-site.sh", ["build"], [], &repo_root) + } + } + } +} + +fn run_script_with_prefix( + script_relative_path: &str, + prefix_args: [&str; N], + extra_args: impl IntoIterator, + repo_root: &Path, +) -> Result<(), XtaskError> { + let args = prefix_args + .into_iter() + .map(OsString::from) + .chain(extra_args) + .collect::>(); + run_script(repo_root.join(script_relative_path), args, repo_root) +} + +fn run_script( + script_path: PathBuf, + args: impl IntoIterator, + repo_root: &Path, +) -> Result<(), XtaskError> { + let status = Command::new(&script_path) + .args(args) + .current_dir(repo_root) + .status() + .map_err(|source| XtaskError::CommandSpawn { + command: script_path.display().to_string(), + source, + })?; + + if status.success() { + return Ok(()); + } + + Err(XtaskError::CommandFailed { + command: script_path.display().to_string(), + status, + }) +} + +fn repo_root() -> Result { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest_dir.parent().map(Path::to_path_buf).ok_or_else(|| { + XtaskError::RepoRoot(format!( + "xtask manifest directory '{}' does not have a repository root parent", + manifest_dir.display() + )) + }) +} + +#[derive(Debug)] +enum XtaskError { + CommandFailed { + command: String, + status: std::process::ExitStatus, + }, + CommandSpawn { + command: String, + source: std::io::Error, + }, + RepoRoot(String), + Usage(UsageError), +} + +impl fmt::Display for XtaskError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CommandFailed { command, status } => { + write!(f, "command '{command}' exited with status {status}") + } + Self::CommandSpawn { command, source } => { + write!(f, "failed to run '{command}': {source}") + } + Self::RepoRoot(message) => f.write_str(message), + Self::Usage(error) => write!(f, "{error}"), + } + } +} + +impl From for XtaskError { + fn from(value: UsageError) -> Self { + Self::Usage(value) + } +} + +#[derive(Debug, Eq, PartialEq)] +struct UsageError { + message: String, +} + +impl UsageError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for UsageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}\n\n{}", self.message, WikiCommand::help()) + } +} + +#[cfg(test)] +mod tests { + use super::{ParseOutcome, UsageError, WikiCommand}; + use std::ffi::OsString; + + #[test] + fn parses_build_command_with_extra_args() { + let outcome = ParseOutcome::parse(vec![ + OsString::from("build"), + OsString::from("--strict"), + OsString::from("--dirtyreload"), + ]) + .unwrap(); + let ParseOutcome::Invocation(invocation) = outcome else { + panic!("expected invocation parse outcome"); + }; + + assert_eq!(invocation.command, WikiCommand::Build); + assert_eq!( + invocation.extra_args, + vec![OsString::from("--strict"), OsString::from("--dirtyreload")] + ); + } + + #[test] + fn rejects_missing_command() { + let error = ParseOutcome::parse(Vec::new()).unwrap_err(); + assert_eq!(error, UsageError::new("missing wiki command")); + } + + #[test] + fn rejects_unknown_command() { + let error = ParseOutcome::parse(vec![OsString::from("deploy")]).unwrap_err(); + assert_eq!(error, UsageError::new("unknown wiki command 'deploy'")); + } + + #[test] + fn parses_help_without_error() { + let outcome = ParseOutcome::parse(vec![OsString::from("--help")]).unwrap(); + assert_eq!(outcome, ParseOutcome::Help); + } +} From 71964183bc739000b06f0ca34e61beecf48ae8b6 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Wed, 11 Mar 2026 02:29:35 +0800 Subject: [PATCH 23/29] docs: update wiki submodule pointer - advance docs/wiki to include the expanded configuration and keybinding reference - include the remaining install, patch/reply, development, and contribution wiki pages Signed-off-by: Chen Miao --- docs/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/wiki b/docs/wiki index 14d5f32..35d3162 160000 --- a/docs/wiki +++ b/docs/wiki @@ -1 +1 @@ -Subproject commit 14d5f323f2dd07a334b8d4d348f13fc14a09c699 +Subproject commit 35d31628b1747f3009ae1c56ffa4428524dbecd6 From 8205ab0fe5c26d1227d9c19a46bfc8f891027cd4 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Wed, 11 Mar 2026 02:30:43 +0800 Subject: [PATCH 24/29] docs: switch repository guidance to the wiki - shorten the README files into wiki-first project entry points and update issue and PR templates - add the missing config example notes and the wiki submodule commit reminder in the authoring skill - switch the local wiki site requirements and MkDocs theme to mkdocs-material Signed-off-by: Chen Miao --- .codex/skills/criew-wiki-authoring/SKILL.md | 1 + .../references/publish-workflow.md | 4 + .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/config.yml | 4 +- .github/ISSUE_TEMPLATE/documentation.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 1 + README-zh.md | 282 +++-------------- README.md | 286 +++--------------- docs/reference/config.example.toml | 5 + docs/wiki-requirements.txt | 1 + mkdocs.yml | 2 +- 12 files changed, 93 insertions(+), 499 deletions(-) diff --git a/.codex/skills/criew-wiki-authoring/SKILL.md b/.codex/skills/criew-wiki-authoring/SKILL.md index c67753b..56ab747 100644 --- a/.codex/skills/criew-wiki-authoring/SKILL.md +++ b/.codex/skills/criew-wiki-authoring/SKILL.md @@ -47,6 +47,7 @@ Keep the source compatible with both GitHub wiki rendering and the local Pages b - The lint script requires `autocorrect`. If it is missing, the script downloads a local copy into `target/wiki-venv/bin/` and then reruns the check. - Treat a clean `./scripts/wiki-lint.sh` result as part of done for every wiki page this skill creates or edits. - Because `docs/wiki` is a submodule, the main repository deploys the pinned wiki commit, not the remote wiki repository's latest HEAD. A new wiki commit reaches GitHub Pages only after the CRIEW repository updates the `docs/wiki` submodule pointer. +- If you commit inside `docs/wiki`, explicitly ask the user whether the main `CRIEW` repository should also commit the updated `docs/wiki` submodule pointer. Do not assume they want both commits automatically. 6. Draft in kernel-documentation style. - Lead with scope and purpose. diff --git a/.codex/skills/criew-wiki-authoring/references/publish-workflow.md b/.codex/skills/criew-wiki-authoring/references/publish-workflow.md index 9285b8b..a20fff5 100644 --- a/.codex/skills/criew-wiki-authoring/references/publish-workflow.md +++ b/.codex/skills/criew-wiki-authoring/references/publish-workflow.md @@ -37,3 +37,7 @@ Do not consider a new or edited wiki page complete until the copy passes the lin `docs/wiki` is a Git submodule in the main repository. That means the GitHub Pages deployment uses the wiki commit pinned by the main `CRIEW` repository. If the standalone wiki repository advances but the main repository does not update the `docs/wiki` gitlink, GitHub Pages will keep publishing the older pinned wiki snapshot. + +When a wiki-only commit is created, +the agent should not automatically create the matching main-repository commit. +It should ask the user whether the current `CRIEW` repository also needs a commit that updates the pinned `docs/wiki` submodule pointer. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c6318cb..55adaed 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -17,7 +17,7 @@ body: options: - label: I searched the existing issues required: true - - label: I checked the current README or README-zh + - label: I checked the current wiki, README, or README-zh required: true - type: textarea id: summary diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 126bbb1..0f9fbfe 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: true contact_links: - name: Usage guide - url: https://github.com/ChenMiaoi/CRIEW/blob/develop/README.md + url: https://github.com/ChenMiaoi/CRIEW/wiki about: Start here for installation, configuration, and common workflows. - name: 中文使用说明 url: https://github.com/ChenMiaoi/CRIEW/blob/develop/README-zh.md - about: 中文安装、配置与使用说明。 + about: 中文项目入口与文档导航。 diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml index 313637f..3951528 100644 --- a/.github/ISSUE_TEMPLATE/documentation.yml +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -22,7 +22,7 @@ body: attributes: label: Affected document description: Which file, page, or section is involved? - placeholder: README.md, README-zh.md, docs/config.example.toml, ... + placeholder: wiki/Contribution, README.md, README-zh.md, docs/reference/config.example.toml, ... validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 1e852b1..b3e0f93 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -17,7 +17,7 @@ body: options: - label: I searched the existing issues required: true - - label: I checked whether the current README or docs already cover this workflow + - label: I checked whether the current wiki or README docs already cover this workflow required: true - type: textarea id: summary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5255790..6850386 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -36,6 +36,7 @@ List what you ran locally. - [ ] Tests were added or updated when needed - [ ] User-visible behavior was documented when needed - [ ] I checked for regressions in related workflows +- [ ] If this branch conflicted with `develop`, I used `rebase` to resolve it ## Notes For Reviewers diff --git a/README-zh.md b/README-zh.md index c3ac59e..a544614 100644 --- a/README-zh.md +++ b/README-zh.md @@ -5,268 +5,60 @@ [![docs](https://docs.rs/criew/badge.svg)](https://docs.rs/criew/) [![codecov](https://codecov.io/github/ChenMiaoi/CRIEW/graph/badge.svg?token=AH99YLKKPD)](https://codecov.io/github/ChenMiaoi/CRIEW) -CRIEW 是一个面向 Linux kernel patch 邮件工作流的 Rust TUI 工具,用来把“订阅 -> 同步 -> 阅读 -> 应用 patch -> 回复邮件”放进同一条终端内、本地优先的工作流中。 -`CRIEW` 的含义是 `Code Review in Efficient Workflow`。 -仓库名保持大写 `CRIEW`,crate 和 CLI 命令使用小写 `criew`。 +CRIEW 是一个面向 Linux kernel patch 邮件工作流的 Rust TUI 工具, +把“订阅 -> 同步 -> 阅读 -> 应用 patch -> 回复邮件” +放进同一条终端内、本地优先的工作流中。 +仓库名保持大写 `CRIEW`, +crate 和 CLI 命令使用小写 `criew`。 + +完整文档现在以 wiki 为主: +[CRIEW wiki](https://github.com/ChenMiaoi/CRIEW/wiki) ![CRIEW TUI demo](docs/media/criew-tui-demo.gif) English README: [README.md](README.md) -## 导航 - -- [项目概览](#项目概览) -- [安装与配置](#安装与配置) -- [使用](#使用) -- [延伸阅读](#延伸阅读) - -## 项目概览 - -### 当前能力 - -- 同步 `lore.kernel.org` 邮件列表,并按 checkpoint 做增量更新 -- 同步真实 IMAP `INBOX`,内置 `My Inbox` 订阅 -- 浏览 thread,识别 `[PATCH vN M/N]` patch series -- 通过 `b4` apply 或导出 patch -- 在 TUI 中撰写回信,并通过 `git send-email` 发送 -- 回信面板会明确区分可编辑字段(`From/To/Cc/Subject`)和只读线程字段 -- `Send Preview` 会在草稿没有实际回复内容时给出警告,并高亮用户自己写的回复正文 -- 浏览本地 kernel tree,支持内联 Vim-like 编辑和外部 Vim 编辑 - -### 发布基线 - -`v0.0.1` 是 CRIEW 第一版对外支持的发布基线。 -从 `v0.0.1` 开始,项目只使用 CRIEW 这一套命名: -`criew`、`~/.criew/`、`criew-config.toml`、`criew.db`、`CRIEW_B4_PATH`、`CRIEW_IMAP_PROXY`。 - -更早的 Courier 命名不再视为受支持的升级路径。 -如果你之前测试过 rename 落定前的预发布快照,或者更早打出的 `v0.0.1` tag, -请重新拉取代码或重新安装,并按当前命名重新初始化 CRIEW 运行目录。 - -### 依赖 - -- Rust stable -- Git -- Python 3 - - 当你使用仓库内 `vendor/b4/b4.sh`,或内置的 runtime fallback 时需要 -- `b4` - - CRIEW 的查找顺序是:`[b4].path` -> `CRIEW_B4_PATH` -> `./vendor/b4/b4.sh` -> `~/.criew/vendor/b4/b4.sh` 内置展开副本 -> `b4` in `PATH` -- `git send-email` - - 仅在发送回信时需要 - -建议先运行: - -```bash -criew doctor -``` - -它会检查 `b4`、`git send-email`、git 邮件身份和 IMAP 连接状态。 - -## 安装与配置 - -### 安装 - -#### 从 crates.io 安装 +## 快速开始 ```bash cargo install criew -``` - -这个安装包会把一个最小可运行的 vendored `b4` fallback 内嵌进二进制。 -当 `[b4].path`、`CRIEW_B4_PATH` 和 `./vendor/b4/b4.sh` 都不可用时, -CRIEW 会在首次需要时把它展开到 `~/.criew/vendor/b4/`。 -这个 fallback 仍然需要系统可用的 Python 3。 - -#### 从源码仓库安装 - -如果你希望直接使用工作区里的 `./vendor/b4/b4.sh`,建议递归拉取子模块: - -```bash -git clone --recurse-submodules https://github.com/ChenMiaoi/CRIEW.git -cd CRIEW -cargo install --path . --locked -``` - -如果仓库已经 clone 下来但没有初始化子模块: - -```bash -git submodule update --init --recursive -``` - -#### 直接从 GitHub 安装 - -```bash -cargo install --git https://github.com/ChenMiaoi/CRIEW.git --locked criew -``` - -这种方式下,建议你自行通过 `b4.path`、`CRIEW_B4_PATH` 或系统 `PATH` 提供 `b4`。 -如果 checkout 里也带着 `vendor/b4`,CRIEW 也能像源码模式一样直接使用它。 - -#### 开发时直接运行 - -```bash -cargo run -- doctor -cargo run -- tui -``` - -### 配置 - -#### 默认路径 - -默认配置文件路径: - -```text -~/.criew/criew-config.toml -``` - -默认运行数据目录: - -```text -~/.criew/ -``` - -#### 常见配置 - -首次运行时,CRIEW 会自动生成一个最小配置文件。完整示例见 [docs/reference/config.example.toml](docs/reference/config.example.toml)。 - -一个常见配置如下: - -```toml -[source] -mailbox = "io-uring" - -[imap] -email = "you@example.com" -user = "you@example.com" -pass = "app-password" -server = "imap.example.com" -serverport = 993 -encryption = "ssl" - -[kernel] -tree = "/path/to/linux" -``` - -#### 配置说明 - -配置说明: - -- 相对路径会相对于配置文件所在目录解析 -- 只使用 lore 同步时,可以完全不配置 `[imap]` -- IMAP 配置完整后,左栏会出现默认启用的 `My Inbox` -- `imap.proxy` 支持 `http://`、`socks5://`、`socks5h://` -- 回信身份优先读取 `git config sendemail.from`,否则回退到 `git config user.name` / `git config user.email` -- `ui.startup_sync` 默认是 `true` -- `ui.inbox_auto_sync_interval_secs` 默认是 `30` -- `~/.courier`、`courier-config.toml`、`courier.db`、`COURIER_B4_PATH`、`COURIER_IMAP_PROXY` 从 `v0.0.1` 起都不再受支持 - -## 使用 - -### 基本使用 - -#### 1. 环境自检 - -```bash criew doctor -``` - -#### 2. 同步 lore 邮箱 - -```bash criew sync --mailbox io-uring -``` - -#### 3. 同步真实 IMAP 收件箱 - -```bash -criew sync --mailbox INBOX -``` - -#### 4. 使用本地 `.eml` fixture 调试 - -```bash -criew sync --mailbox test --fixture-dir ./fixtures -``` - -#### 5. 启动 TUI - -```bash criew tui ``` -### TUI 常用操作 - -#### 键位 - -- `:` 打开命令栏 -- 顶部状态栏会显示当前 keymap 方案:`default`、`vim` 或 `custom` -- 数字前缀可重复垂直移动:`default/custom` 使用 `数字 + i/k`,`vim` 使用 `数字 + j/k` -- `y` / `n` 启用或禁用当前订阅 -- `Enter` 打开当前 mailbox 或 thread,并自动切到 threads 或 preview pane -- `[` / `]` 按当前聚焦 pane 向左或向右扩充 mail 三栏宽度;`{` / `}` 做对应方向缩小,并持久化到 `ui-state.toml` -- `-` / `=` 在保持 preview pane 焦点时切换上一封或下一封邮件 -- `a` apply 当前 patch series -- `d` 导出当前 patch series -- `u` 撤销本次会话中最近一次成功 apply -- `r` 或 `e` 打开回信面板 -- `Tab` 在 Mail 页面和 Code Browser 页面之间切换 -- 当 `ui.keymap = "vim"` 时,`gg` 跳到当前 pane 行首,`G` 跳到行尾,`qq` 快速退出 - -#### 命令栏命令 - -命令栏常见命令: - -- `sync` -- `sync ` -- `config` -- `vim` -- `restart` -- `quit` -- `!` +启用 IMAP、 +patch apply、 +或回信发送前, +请先阅读 wiki 中对应的页面。 -#### 后台同步 +## 文档 -如果 IMAP 配置完整,`My Inbox` 会参与启动自动同步,并在 TUI 保持打开时按配置周期持续做后台增量同步。 -启用的邮件列表订阅也会在 TUI 保持打开时按同一周期做后台增量同步,以持续拉取 Linux lore 和 QEMU GNU archive 上的新邮件。 +- [CRIEW wiki](https://github.com/ChenMiaoi/CRIEW/wiki) +- [安装与初始化](https://github.com/ChenMiaoi/CRIEW/wiki/Install-and-Setup) +- [配置说明](https://github.com/ChenMiaoi/CRIEW/wiki/Configuration) +- [同步与 TUI](https://github.com/ChenMiaoi/CRIEW/wiki/Sync-and-TUI) +- [Patch 与回信](https://github.com/ChenMiaoi/CRIEW/wiki/Patch-and-Reply) +- [开发与本地 wiki 构建](https://github.com/ChenMiaoi/CRIEW/wiki/Development) +- [贡献流程](https://github.com/ChenMiaoi/CRIEW/wiki/Contribution) +- [docs.rs API 文档](https://docs.rs/criew/) -### 回复邮件 +## 发布基线 -CRIEW 的 Reply Panel 会自动填充: - -- `From` -- `To` -- `Cc` -- `Subject` -- `In-Reply-To` -- `References` - -同时会生成符合 kernel 邮件习惯的引用正文模板。发送时,底层走 `git send-email`。 -`Send Preview` 会把用户自己写的未引用正文高亮出来;如果预览里只有引用内容和生成的回复骨架,也会明确给出警告。 - -## 延伸阅读 - -### 相关文档 - -- [README.md](README.md): 英文项目说明 -- `docs/wiki`:GitHub wiki 源子模块;可通过 `cargo wiki serve` 本地预览 MkDocs 站点(默认监听 `0.0.0.0:8000`,可用 `CRIEW_WIKI_DEV_ADDR` 覆盖) -- `cargo wiki check`:本地执行 wiki 文案检查并构建 MkDocs 站点;若缺少 `autocorrect`,首次运行 `cargo wiki lint` 或 `cargo wiki check` 时会把它缓存到 `target/wiki-venv/bin/` -- [docs/reference/config.example.toml](docs/reference/config.example.toml): 配置示例 -- [docs/architecture/design.md](docs/architecture/design.md): 设计文档 -- [docs/specs/reply-format-spec.md](docs/specs/reply-format-spec.md): 回信格式规范 -- [docs/milestones/mvp-milestones.md](docs/milestones/mvp-milestones.md): 历史里程碑 -- [docs/milestones/reply-mvp-milestones.md](docs/milestones/reply-mvp-milestones.md): 回信功能演进记录 - -### 开发 - -```bash -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --all-targets --all-features -./scripts/check-coverage.sh -``` - -### License +`v0.0.1` 是 CRIEW 第一版对外支持的发布基线。 +从 `v0.0.1` 开始, +项目只支持 CRIEW 这一套命名: +`criew`、 +`~/.criew/`, +`criew-config.toml`, +`criew.db`, +`CRIEW_B4_PATH`, +和 `CRIEW_IMAP_PROXY`。 +Courier 时代的命名不再受支持。 + +## License CRIEW 自身的 Rust 代码使用 [LGPL-2.1](LICENSE) 许可证发布。 -打包进来的 vendored 组件保留各自上游许可证,包括 `vendor/b4`(GPL-2.0) +打包进来的 vendored 组件保留各自上游许可证, +包括 `vendor/b4`(GPL-2.0) 和 `vendor/b4/patatt`(MIT-0)。 diff --git a/README.md b/README.md index 6aeabfa..3b123e9 100644 --- a/README.md +++ b/README.md @@ -6,271 +6,61 @@ [![codecov](https://codecov.io/github/ChenMiaoi/CRIEW/graph/badge.svg?token=AH99YLKKPD)](https://codecov.io/github/ChenMiaoi/CRIEW) CRIEW is a Rust TUI for Linux kernel patch mail workflows. -`CRIEW` stands for `Code Review in Efficient Workflow`. -The repository name stays uppercase as `CRIEW`, while the crate and CLI use lowercase `criew`. +It keeps subscription, +sync, +review, +patch application, +and reply in one terminal-first local workflow. +`CRIEW` is the repository name, +while the crate and CLI use lowercase `criew`. -It is built for developers who work on mailing-list-driven review, especially in Linux kernel style flows, and want a terminal-first tool that keeps subscription, sync, review, patch application, and reply in one local workflow. +Full documentation lives in the +[CRIEW wiki](https://github.com/ChenMiaoi/CRIEW/wiki). ![CRIEW TUI demo](docs/media/criew-tui-demo.gif) -Chinese usage guide: [README-zh.md](README-zh.md) +Chinese quick start: [README-zh.md](README-zh.md) -## Guide Map - -- [Project Overview](#project-overview) -- [Setup](#setup) -- [Usage](#usage) -- [Reference](#reference) - -## Project Overview - -### Status - -CRIEW is under active development. The current `develop` branch already covers the core workflow: - -- sync mail from `lore.kernel.org` -- sync a real IMAP `INBOX` through the built-in `My Inbox` subscription -- browse threads and detect patch series -- apply or export patches through `b4` -- compose and send replies from the TUI through `git send-email` - -### Release Baseline - -`v0.0.1` is the first supported public baseline for CRIEW. -Starting from `v0.0.1`, the project uses only the CRIEW naming set: -`criew`, `~/.criew/`, `criew-config.toml`, `criew.db`, `CRIEW_B4_PATH`, and `CRIEW_IMAP_PROXY`. - -Earlier Courier-era names are not treated as a supported upgrade path. -If you tested an older pre-release snapshot or an earlier `v0.0.1` tag before this rename settled, -refresh your checkout or reinstall the binary and bootstrap a new CRIEW runtime directory. - -### Features - -- Rust CLI with `criew tui`, `criew sync`, `criew doctor`, and `criew version` -- local SQLite storage with automatic runtime bootstrap -- incremental lore sync with checkpoint-based updates -- real IMAP `INBOX` sync with patch-oriented filtering -- background startup sync for enabled subscriptions -- periodic auto-sync for `My Inbox` -- patch series detection for subjects like `[PATCH vN M/N]` -- patch apply/export workflow powered by `b4` -- undo for the most recent successful apply in the current session -- kernel tree browser with source preview -- inline Vim-like editing and external Vim editing -- reply panel that fills `From`, `To`, `Cc`, `Subject`, `In-Reply-To`, and `References`, and clearly marks editable draft fields vs read-only thread headers -- send preview warnings when the draft has no authored reply text, and authored lines stand out from quoted text -- real reply delivery through `git send-email` -- visual config editor, command palette completion, and structured operation logs - -## Setup - -### Requirements - -- Rust stable -- Git -- Python 3 - - needed when using the repo-local `vendor/b4/b4.sh` or the embedded runtime fallback -- `b4` - - CRIEW resolves it in this order: `[b4].path` -> `CRIEW_B4_PATH` -> `./vendor/b4/b4.sh` -> embedded runtime vendor under `~/.criew/vendor/b4/b4.sh` -> `b4` in `PATH` -- `git send-email` - - only required if you want to send replies - -`criew doctor` checks `b4`, `git send-email`, git mail identity, and IMAP connectivity. - -### Installation - -`crates.io` installation is the recommended path. - -#### Install from crates.io +## Quick Start ```bash cargo install criew -``` - -This build keeps a minimal vendored `b4` runtime embedded in the binary. -If `[b4].path`, `CRIEW_B4_PATH`, and `./vendor/b4/b4.sh` are all unavailable, -CRIEW can materialize that fallback under `~/.criew/vendor/b4/` on first use. -Python 3 is still required for that fallback. - -#### Install from a clone - -If you want to use the repo-local `./vendor/b4/b4.sh` fallback from a checkout, -clone the repository with submodules: - -```bash -git clone --recurse-submodules https://github.com/ChenMiaoi/CRIEW.git -cd CRIEW -cargo install --path . --locked -``` - -If you already cloned the repository without submodules: - -```bash -git submodule update --init --recursive -``` - -#### Install directly from GitHub - -```bash -cargo install --git https://github.com/ChenMiaoi/CRIEW.git --locked criew -``` - -In this mode, you should provide `b4` through `b4.path`, `CRIEW_B4_PATH`, or your system `PATH`. -If the checkout also includes `vendor/b4`, CRIEW can use it the same way as a source clone. - -#### Run from source - -```bash -cargo run -- doctor -cargo run -- tui -``` - -## Usage - -### Quick Start - -#### 1. Check your environment - -```bash criew doctor -``` - -#### 2. Prepare configuration - -##### Default locations - -The default config file is `~/.criew/criew-config.toml`, and the default runtime directory is `~/.criew/`. CRIEW creates a minimal config file automatically on first run. - -##### Typical configuration - -See [docs/reference/config.example.toml](docs/reference/config.example.toml) for a complete example. - -Typical configuration: - -```toml -[source] -mailbox = "io-uring" - -[imap] -email = "you@example.com" -user = "you@example.com" -pass = "app-password" -server = "imap.example.com" -serverport = 993 -encryption = "ssl" - -[kernel] -tree = "/path/to/linux" -``` - -Notes: - -- relative paths are resolved from the config file directory -- `[imap]` is optional if you only use lore sync -- when IMAP config is complete, `My Inbox` is enabled by default on first use -- `imap.proxy` supports `http://`, `socks5://`, and `socks5h://` -- reply identity prefers `git config sendemail.from`, then falls back to `git config user.name` and `git config user.email` -- `ui.startup_sync` defaults to `true` -- `ui.inbox_auto_sync_interval_secs` defaults to `30` -- Courier-era names such as `~/.courier`, `courier-config.toml`, `courier.db`, `COURIER_B4_PATH`, and `COURIER_IMAP_PROXY` are intentionally unsupported from `v0.0.1` onward - -#### 3. Sync mail - -##### Sync a lore mailbox - -Sync a lore mailbox: - -```bash criew sync --mailbox io-uring -``` - -##### Sync a real IMAP inbox - -Sync a real IMAP inbox: - -```bash -criew sync --mailbox INBOX -``` - -##### Use local `.eml` fixtures for debugging - -Use local `.eml` fixtures for debugging: - -```bash -criew sync --mailbox test --fixture-dir ./fixtures -``` - -#### 4. Start the TUI - -##### Inside the TUI - -```bash criew tui ``` -Inside the TUI: +Use the wiki before enabling IMAP, +patch application, +or reply sending. -- `:` opens the command palette -- the header shows the active keymap scheme (`default`, `vim`, or `custom`) -- a numeric prefix repeats vertical movement: `count+i/k` for `default`/`custom`, `count+j/k` for `vim` -- `y` / `n` enable or disable the selected subscription -- `Enter` opens the selected mailbox or thread and moves focus to threads or preview -- `[` / `]` expands the focused mail pane left or right; `{` / `}` shrinks it in that direction and persists the widths in `ui-state.toml` -- `-` / `=` switch to the previous or next mail while preview stays focused -- `a` applies the current patch series -- `d` exports the current patch series -- `u` undoes the most recent successful apply from the current session -- `r` or `e` opens the reply panel -- `Tab` switches between the mail page and the code browser -- with `ui.keymap = "vim"`, `gg` jumps to the first line in the active pane, `G` jumps to the last line, and `qq` exits quickly +## Documentation -##### Background sync +- [CRIEW wiki](https://github.com/ChenMiaoi/CRIEW/wiki) +- [Install and Setup](https://github.com/ChenMiaoi/CRIEW/wiki/Install-and-Setup) +- [Configuration](https://github.com/ChenMiaoi/CRIEW/wiki/Configuration) +- [Sync and TUI](https://github.com/ChenMiaoi/CRIEW/wiki/Sync-and-TUI) +- [Patch and Reply](https://github.com/ChenMiaoi/CRIEW/wiki/Patch-and-Reply) +- [Development](https://github.com/ChenMiaoi/CRIEW/wiki/Development) +- [Contribution](https://github.com/ChenMiaoi/CRIEW/wiki/Contribution) +- [API docs on docs.rs](https://docs.rs/criew/) -When IMAP is configured, `My Inbox` joins startup sync and continues periodic background sync while the TUI remains open. -Enabled mailing-list subscriptions also keep doing periodic background sync while the TUI remains open so Linux lore and QEMU archive mailboxes keep pulling new mail. +## Release Baseline -## Reference - -### Documentation - -- [README-zh.md](README-zh.md): Chinese usage guide -- `docs/wiki`: GitHub wiki source submodule; preview the MkDocs site with `cargo wiki serve` (defaults to `0.0.0.0:8000`, override with `CRIEW_WIKI_DEV_ADDR`) -- `cargo wiki check`: lint wiki copy and build the local MkDocs site; if `autocorrect` is missing, the first `cargo wiki lint` or `cargo wiki check` download caches it under `target/wiki-venv/bin/` -- [docs/reference/config.example.toml](docs/reference/config.example.toml): configuration example -- [docs/architecture/design.md](docs/architecture/design.md): design notes -- [docs/specs/reply-format-spec.md](docs/specs/reply-format-spec.md): reply panel and sending format -- [docs/milestones/mvp-milestones.md](docs/milestones/mvp-milestones.md): historical milestone record -- [docs/milestones/reply-mvp-milestones.md](docs/milestones/reply-mvp-milestones.md): reply workflow evolution - -### Development - -Common development commands: - -```bash -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --all-targets --all-features -./scripts/check-coverage.sh -``` - -The repository includes GitHub Actions CI for `push` and `pull_request` with the same formatting, lint, and test checks. - -### Contributing - -Issues and pull requests are welcome. - -Before sending changes, run: - -```bash -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --all-targets --all-features -``` - -If you change user-visible behavior, commands, config keys, or workflows, update the relevant documentation in the same change. - -### License +`v0.0.1` is the first supported public baseline for CRIEW. +From `v0.0.1` onward, +CRIEW supports only the CRIEW naming set: +`criew`, +`~/.criew/`, +`criew-config.toml`, +`criew.db`, +`CRIEW_B4_PATH`, +and `CRIEW_IMAP_PROXY`. +Courier-era names are unsupported. + +## License CRIEW's Rust code is licensed under [LGPL-2.1](LICENSE). -Bundled vendored components keep their upstream licenses, including `vendor/b4` (GPL-2.0) +Bundled vendored components keep their upstream licenses, +including `vendor/b4` (GPL-2.0) and `vendor/b4/patatt` (MIT-0). diff --git a/docs/reference/config.example.toml b/docs/reference/config.example.toml index b99d7a9..3d2b512 100644 --- a/docs/reference/config.example.toml +++ b/docs/reference/config.example.toml @@ -12,6 +12,9 @@ # Required to customize DB location. Default: ~/.criew/db/criew.db database = "./.criew/db/criew.db" +# Required to customize raw mail cache location. Default: ~/.criew/mail/raw +# raw_mail_dir = "./.criew/mail/raw" + # Required to customize patch file location. Default: ~/.criew/patches patch_dir = "./.criew/patches" @@ -28,6 +31,8 @@ filter = "info" [source] # Optional default lore mailbox used by CLI `sync` when no mailbox is passed. # Default: linux-kernel +# Historical compatibility alias: [imap].mailbox still maps to the same value, +# but new config should prefer [source].mailbox. # mailbox = "linux-kernel" # Optional lore base URL. diff --git a/docs/wiki-requirements.txt b/docs/wiki-requirements.txt index 3bd21f5..04e4782 100644 --- a/docs/wiki-requirements.txt +++ b/docs/wiki-requirements.txt @@ -1 +1,2 @@ mkdocs>=1.6,<2.0 +mkdocs-material>=9,<10 diff --git a/mkdocs.yml b/mkdocs.yml index 269df7c..daed8ff 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,4 +8,4 @@ site_dir: target/wiki-site strict: true theme: - name: mkdocs + name: material From 6a6f32e3cedff9ebe0bb1964fbf993787ca8806e Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Wed, 11 Mar 2026 02:52:59 +0800 Subject: [PATCH 25/29] fix: support docs.rs builds Signed-off-by: Chen Miao --- .github/workflows/ci.yml | 5 +++++ Cargo.toml | 6 ++++++ src/app/cli.rs | 4 ++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecb90fb..a4b8198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,11 @@ jobs: - name: Clippy run: cargo clippy --all-targets --all-features -- -D warnings + - name: Rustdoc + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --no-deps + - name: Test run: cargo test --all-targets --all-features diff --git a/Cargo.toml b/Cargo.toml index 8e44228..8f1cdff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,12 @@ include = [ members = ["xtask"] resolver = "3" +[package.metadata.docs.rs] +# CRIEW is Linux-first, so keep docs.rs on the supported target instead of +# asking it to fan out across every tier-one platform. +default-target = "x86_64-unknown-linux-gnu" +targets = [] + [dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } clap = { version = "4.5", features = ["derive"] } diff --git a/src/app/cli.rs b/src/app/cli.rs index ba1b803..54cc0bf 100644 --- a/src/app/cli.rs +++ b/src/app/cli.rs @@ -29,8 +29,8 @@ pub enum Command { Tui, /// Execute mailbox sync worker. Sync { - /// Mailbox name to sync (defaults to [source].mailbox or linux-kernel). - /// Use INBOX to trigger real IMAP sync when [imap] config is complete. + /// Mailbox name to sync (defaults to `source.mailbox` or `linux-kernel`). + /// Use `INBOX` to trigger real IMAP sync when the IMAP config is complete. #[arg(long)] mailbox: Option, /// Local fixture directory for offline/local test (.eml files). From 76d4ea0843724c6e435eac31af54b909ea0a48f4 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Thu, 12 Mar 2026 23:59:19 +0800 Subject: [PATCH 26/29] docs: require wiki sync review for code changes Signed-off-by: Chen Miao --- .codex/skills/criew-development/SKILL.md | 5 +++-- .codex/skills/criew-development/agents/openai.yaml | 4 ++-- .codex/skills/criew-wiki-authoring/SKILL.md | 4 +++- .codex/skills/criew-wiki-authoring/agents/openai.yaml | 4 ++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.codex/skills/criew-development/SKILL.md b/.codex/skills/criew-development/SKILL.md index f62948a..946cc0e 100644 --- a/.codex/skills/criew-development/SKILL.md +++ b/.codex/skills/criew-development/SKILL.md @@ -1,6 +1,6 @@ --- name: criew-development -description: Repository-specific workflow and coding rules for the CRIEW codebase. Use when modifying or reviewing CRIEW Rust code, TUI behavior, sync/IMAP/reply/patch workflows, migrations, tests, docs, or config, and whenever the task must follow `docs/development/code-guildline.md` or `docs/development/code-guildline-cn.md`. +description: Repository-specific workflow and coding rules for the CRIEW codebase. Use when modifying or reviewing CRIEW Rust code, TUI behavior, sync/IMAP/reply/patch workflows, migrations, tests, docs, or config, when code or workflow changes may require syncing `docs/wiki/`, and whenever the task must follow `docs/development/code-guildline.md` or `docs/development/code-guildline-cn.md`. --- # Criew Development @@ -64,9 +64,10 @@ Follow this order unless the task is trivial. 2. Confirm the user-visible behavior from the README or a spec doc when the change affects sync, reply, patch apply/export, config, or startup flow. 3. Keep the change focused on one logical topic. 4. Add or update regression tests when fixing a bug or changing observable behavior. -5. Update docs when behavior, commands, config, or workflow changed. +5. Update docs when behavior, commands, config, or workflow changed, and explicitly decide whether `README.md`, `README-zh.md`, or `docs/wiki/` need updates. Prefer updating both `README.md` and `README-zh.md` when the change affects user-facing usage or setup. +When operator workflow, troubleshooting guidance, configuration, or other stable user-facing behavior changed, review the relevant `docs/wiki/` pages and update them in the same task unless the user explicitly scopes wiki work out. Use `criew-wiki-authoring` for the wiki edit itself. Keep PR-sized changes focused and commit subjects aligned with the repository's Conventional Commit prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`. When asked to create a commit in this repository, use `git commit -s`. diff --git a/.codex/skills/criew-development/agents/openai.yaml b/.codex/skills/criew-development/agents/openai.yaml index d3064f0..8dc90d9 100644 --- a/.codex/skills/criew-development/agents/openai.yaml +++ b/.codex/skills/criew-development/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "CRIEW Development" - short_description: "CRIEW repo workflow and coding rules" - default_prompt: "Use $criew-development to make a focused change in the CRIEW repository." + short_description: "CRIEW repo workflow, coding rules, and doc sync checks" + default_prompt: "Use $criew-development to make a focused CRIEW code or config change and assess required README/wiki updates." diff --git a/.codex/skills/criew-wiki-authoring/SKILL.md b/.codex/skills/criew-wiki-authoring/SKILL.md index 56ab747..69aad76 100644 --- a/.codex/skills/criew-wiki-authoring/SKILL.md +++ b/.codex/skills/criew-wiki-authoring/SKILL.md @@ -1,6 +1,6 @@ --- name: criew-wiki-authoring -description: Write and revise pages under `docs/wiki` for the CRIEW GitHub wiki and its MkDocs-backed GitHub Pages site. Use when Codex needs to create, expand, reorganize, or review CRIEW wiki pages such as `Home.md`, `_Sidebar.md`, `_Footer.md`, or topic pages, and when the output or workflow must follow GitHub wiki conventions, the local MkDocs pipeline, and a pragmatic, kernel-documentation writing style. +description: Write and revise pages under `docs/wiki` for the CRIEW GitHub wiki and its MkDocs-backed GitHub Pages site. Use when Codex needs to create, expand, reorganize, or review CRIEW wiki pages such as `Home.md`, `_Sidebar.md`, `_Footer.md`, or topic pages, when code or workflow changes require a wiki sync pass, and when the output or workflow must follow GitHub wiki conventions, the local MkDocs pipeline, and a pragmatic, kernel-documentation writing style. --- # Criew Wiki Authoring @@ -10,6 +10,7 @@ description: Write and revise pages under `docs/wiki` for the CRIEW GitHub wiki Write CRIEW wiki pages as maintainer documentation. Treat `docs/wiki/` as the source GitHub wiki repository, and treat the published website as a derived MkDocs build driven from the main `CRIEW` repository. Keep the source compatible with both GitHub wiki rendering and the local Pages build pipeline, and prefer a direct, technical style over narrative or marketing copy. +Use this skill not only for standalone wiki work, but also when a code or config change means the existing wiki may now be stale. ## Follow This Workflow @@ -17,6 +18,7 @@ Keep the source compatible with both GitHub wiki rendering and the local Pages b - Read the code, README, spec, config example, or architecture note that defines the behavior. - Prefer `README.md`, `README-zh.md`, `docs/architecture/design.md`, `docs/specs/`, and `docs/reference/config.example.toml` depending on the topic. - Do not invent commands, defaults, limitations, or workflows. +- When paired with a code change, identify which existing wiki pages describe the changed behavior and either update them or explicitly confirm that no wiki page is affected. 2. Check the wiki context. - List the existing pages in `docs/wiki/` before creating or renaming a page. diff --git a/.codex/skills/criew-wiki-authoring/agents/openai.yaml b/.codex/skills/criew-wiki-authoring/agents/openai.yaml index ce5b7ca..bb586d0 100644 --- a/.codex/skills/criew-wiki-authoring/agents/openai.yaml +++ b/.codex/skills/criew-wiki-authoring/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "CRIEW Wiki" - short_description: "CRIEW GitHub wiki authoring guide" - default_prompt: "Use $criew-wiki-authoring to draft or revise pages under docs/wiki in the CRIEW GitHub wiki style." + short_description: "CRIEW wiki authoring and sync guide" + default_prompt: "Use $criew-wiki-authoring to draft, revise, or sync pages under docs/wiki after CRIEW behavior or workflow changes." From d2a6e603603f5f97d54ac4bd47a1694fc3220c1d Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Fri, 13 Mar 2026 00:50:50 +0800 Subject: [PATCH 27/29] feat(tui): add configurable keymap editor - add a :keymap editor for main-page navigation presets and custom bindings - persist ui.keymap_base and ui.custom_keymap settings with runtime validation - refresh TUI docs and regression tests for scheme isolation and active binding display Signed-off-by: Chen Miao --- docs/reference/config.example.toml | 18 +- docs/wiki | 2 +- src/app/mod.rs | 2 + src/app/patch.rs | 4 + src/app/sync.rs | 12 + src/infra/bootstrap.rs | 2 + src/infra/config.rs | 378 ++++++++++- src/infra/sendmail.rs | 2 + src/ui/tui.rs | 75 +- src/ui/tui/config.rs | 26 +- src/ui/tui/input.rs | 143 +--- src/ui/tui/keymap.rs | 1017 ++++++++++++++++++++++++++++ src/ui/tui/render.rs | 7 +- src/ui/tui/tests.rs | 251 ++++++- 14 files changed, 1745 insertions(+), 194 deletions(-) create mode 100644 src/ui/tui/keymap.rs diff --git a/docs/reference/config.example.toml b/docs/reference/config.example.toml index 3d2b512..dc95a15 100644 --- a/docs/reference/config.example.toml +++ b/docs/reference/config.example.toml @@ -64,10 +64,26 @@ filter = "info" # Supported: # "default" (j/l focus, i/k move) # "vim" (h/l focus, j/k move, gg/G jump, qq quit) -# "custom" (header shows custom; navigation currently falls back to default bindings) +# "custom" (ui.keymap_base plus ui.custom_keymap overrides) # Default: "default" # keymap = "default" +# Optional. Base preset used when keymap = "custom". +# Supported: "default", "vim" +# Default: inferred from ui.keymap when omitted +# keymap_base = "default" + +# Optional. Custom main-page bindings. +# Each value is an array of 1-2 printable keys. +# [ui.custom_keymap] +# focus_prev = ["j"] +# focus_next = ["l"] +# move_up = ["i"] +# move_down = ["k"] +# jump_top = ["g", "g"] +# jump_bottom = ["G"] +# quick_quit = ["q", "q"] + # Optional. Seconds between `My Inbox` background auto-sync runs while TUI stays open. # Must be greater than 0. Default: 30 # inbox_auto_sync_interval_secs = 30 diff --git a/docs/wiki b/docs/wiki index 35d3162..aa5b0b8 160000 --- a/docs/wiki +++ b/docs/wiki @@ -1 +1 @@ -Subproject commit 35d31628b1747f3009ae1c56ffa4428524dbecd6 +Subproject commit aa5b0b8d2b401e37759585e642d69925825cd71c diff --git a/src/app/mod.rs b/src/app/mod.rs index dc0bc5e..00ad410 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -429,6 +429,8 @@ mod tests { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), } diff --git a/src/app/patch.rs b/src/app/patch.rs index 6c84c64..8a9b101 100644 --- a/src/app/patch.rs +++ b/src/app/patch.rs @@ -1181,6 +1181,8 @@ mod tests { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees, @@ -1202,6 +1204,8 @@ mod tests { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees, diff --git a/src/app/sync.rs b/src/app/sync.rs index 55b8001..b348ba4 100644 --- a/src/app/sync.rs +++ b/src/app/sync.rs @@ -606,6 +606,8 @@ mod tests { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -733,6 +735,8 @@ mod tests { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -796,6 +800,8 @@ mod tests { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -857,6 +863,8 @@ mod tests { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -900,6 +908,8 @@ mod tests { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), @@ -943,6 +953,8 @@ mod tests { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), diff --git a/src/infra/bootstrap.rs b/src/infra/bootstrap.rs index be912dc..4103925 100644 --- a/src/infra/bootstrap.rs +++ b/src/infra/bootstrap.rs @@ -144,6 +144,8 @@ mod tests { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), } diff --git a/src/infra/config.rs b/src/infra/config.rs index cb48bae..b61d99d 100644 --- a/src/infra/config.rs +++ b/src/infra/config.rs @@ -32,6 +32,15 @@ mailbox = "linux-kernel" [ui] startup_sync = true # keymap = "default" # Supported: default, vim, custom +# keymap_base = "default" # Base scheme used when keymap = "custom" +# [ui.custom_keymap] +# focus_prev = ["j"] +# focus_next = ["l"] +# move_up = ["i"] +# move_down = ["k"] +# jump_top = ["g", "g"] +# jump_bottom = ["G"] +# quick_quit = ["q", "q"] # inbox_auto_sync_interval_secs = 30 [logging] @@ -74,6 +83,41 @@ impl UiKeymap { Self::Custom => "custom", } } + + pub fn default_base(self) -> UiKeymapBase { + match self { + Self::Vim => UiKeymapBase::Vim, + Self::Default | Self::Custom => UiKeymapBase::Default, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UiKeymapBase { + #[default] + Default, + Vim, +} + +impl UiKeymapBase { + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Vim => "vim", + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UiCustomKeymapConfig { + pub focus_prev: Option>, + pub focus_next: Option>, + pub move_up: Option>, + pub move_down: Option>, + pub jump_top: Option>, + pub jump_bottom: Option>, + pub quick_quit: Option>, } #[derive(Debug, Clone, Default)] @@ -154,6 +198,8 @@ pub struct RuntimeConfig { pub lore_base_url: String, pub startup_sync: bool, pub ui_keymap: UiKeymap, + pub ui_keymap_base: UiKeymapBase, + pub ui_custom_keymap: UiCustomKeymapConfig, pub inbox_auto_sync_interval_secs: u64, pub kernel_trees: Vec, } @@ -219,9 +265,23 @@ struct SourceConfig { struct UiConfig { startup_sync: Option, keymap: Option, + keymap_base: Option, + #[serde(default)] + custom_keymap: UiCustomKeymapFileConfig, inbox_auto_sync_interval_secs: Option, } +#[derive(Debug, Default, Deserialize)] +struct UiCustomKeymapFileConfig { + focus_prev: Option>, + focus_next: Option>, + move_up: Option>, + move_down: Option>, + jump_top: Option>, + jump_bottom: Option>, + quick_quit: Option>, +} + #[derive(Debug, Default, Deserialize)] struct ImapFileConfig { mailbox: Option, @@ -369,6 +429,12 @@ fn build_runtime_config( .unwrap_or_else(|| DEFAULT_LORE_BASE_URL.to_string()); let startup_sync = file_config.ui.startup_sync.unwrap_or(true); let ui_keymap = file_config.ui.keymap.unwrap_or_default(); + let ui_keymap_base = file_config + .ui + .keymap_base + .unwrap_or_else(|| ui_keymap.default_base()); + let ui_custom_keymap = build_ui_custom_keymap_config(&file_config.ui.custom_keymap)?; + validate_ui_custom_keymap_config(ui_keymap_base, &ui_custom_keymap)?; let inbox_auto_sync_interval_secs = file_config .ui .inbox_auto_sync_interval_secs @@ -407,6 +473,8 @@ fn build_runtime_config( lore_base_url, startup_sync, ui_keymap, + ui_keymap_base, + ui_custom_keymap, inbox_auto_sync_interval_secs, kernel_trees, }) @@ -484,6 +552,227 @@ fn build_imap_config(file_config: &ImapFileConfig, config_path: &Path) -> Result build_imap_config_with_env(file_config, config_path, |key| std::env::var(key).ok()) } +fn build_ui_custom_keymap_config( + file_config: &UiCustomKeymapFileConfig, +) -> Result { + Ok(UiCustomKeymapConfig { + focus_prev: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.focus_prev", + file_config.focus_prev.clone(), + 1, + 1, + )?, + focus_next: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.focus_next", + file_config.focus_next.clone(), + 1, + 1, + )?, + move_up: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.move_up", + file_config.move_up.clone(), + 1, + 1, + )?, + move_down: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.move_down", + file_config.move_down.clone(), + 1, + 1, + )?, + jump_top: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.jump_top", + file_config.jump_top.clone(), + 1, + 2, + )?, + jump_bottom: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.jump_bottom", + file_config.jump_bottom.clone(), + 1, + 2, + )?, + quick_quit: normalize_ui_custom_keymap_binding( + "ui.custom_keymap.quick_quit", + file_config.quick_quit.clone(), + 1, + 2, + )?, + }) +} + +fn normalize_ui_custom_keymap_binding( + key: &str, + tokens: Option>, + min_len: usize, + max_len: usize, +) -> Result>> { + let Some(tokens) = tokens else { + return Ok(None); + }; + if tokens.len() < min_len || tokens.len() > max_len { + return Err(CriewError::new( + ErrorCode::ConfigParse, + format!("{key} must contain between {min_len} and {max_len} keys"), + )); + } + + let mut normalized = Vec::with_capacity(tokens.len()); + for token in tokens { + let Some(character) = normalize_ui_custom_keymap_token(&token) else { + return Err(CriewError::new( + ErrorCode::ConfigParse, + format!("{key} contains an unsupported key token: {token}"), + )); + }; + if is_reserved_main_page_keymap_character(character) { + return Err(CriewError::new( + ErrorCode::ConfigParse, + format!("{key} uses reserved key {character}"), + )); + } + normalized.push(character.to_string()); + } + + Ok(Some(normalized)) +} + +fn normalize_ui_custom_keymap_token(token: &str) -> Option { + let trimmed = token.trim(); + let mut characters = trimmed.chars(); + let character = characters.next()?; + if characters.next().is_some() + || character.is_ascii_control() + || character.is_ascii_whitespace() + || character.is_ascii_digit() + { + return None; + } + + Some(character) +} + +fn is_reserved_main_page_keymap_character(character: char) -> bool { + matches!( + character, + ':' | '/' + | 'e' + | 'r' + | 'a' + | 'd' + | 'u' + | 'y' + | 'n' + | '[' + | ']' + | '{' + | '}' + | 'E' + | '-' + | '=' + | '+' + ) +} + +fn validate_ui_custom_keymap_config( + base: UiKeymapBase, + custom: &UiCustomKeymapConfig, +) -> Result<()> { + let mut bindings = vec![ + ( + "focus_prev", + resolved_ui_keymap_binding(base, custom.focus_prev.as_ref(), "focus_prev") + .expect("focus_prev should always resolve"), + ), + ( + "focus_next", + resolved_ui_keymap_binding(base, custom.focus_next.as_ref(), "focus_next") + .expect("focus_next should always resolve"), + ), + ( + "move_up", + resolved_ui_keymap_binding(base, custom.move_up.as_ref(), "move_up") + .expect("move_up should always resolve"), + ), + ( + "move_down", + resolved_ui_keymap_binding(base, custom.move_down.as_ref(), "move_down") + .expect("move_down should always resolve"), + ), + ]; + + if let Some(binding) = resolved_ui_keymap_binding(base, custom.jump_top.as_ref(), "jump_top") { + bindings.push(("jump_top", binding)); + } + if let Some(binding) = + resolved_ui_keymap_binding(base, custom.jump_bottom.as_ref(), "jump_bottom") + { + bindings.push(("jump_bottom", binding)); + } + if let Some(binding) = + resolved_ui_keymap_binding(base, custom.quick_quit.as_ref(), "quick_quit") + { + bindings.push(("quick_quit", binding)); + } + + for (index, (left_name, left_binding)) in bindings.iter().enumerate() { + for (right_name, right_binding) in bindings.iter().skip(index + 1) { + if left_binding == right_binding { + return Err(CriewError::new( + ErrorCode::ConfigParse, + format!("ui.custom_keymap.{left_name} conflicts with {right_name}"), + )); + } + if binding_is_prefix(left_binding, right_binding) + || binding_is_prefix(right_binding, left_binding) + { + return Err(CriewError::new( + ErrorCode::ConfigParse, + format!("ui.custom_keymap.{left_name} has a prefix conflict with {right_name}"), + )); + } + } + } + + Ok(()) +} + +fn resolved_ui_keymap_binding<'a>( + base: UiKeymapBase, + custom: Option<&'a Vec>, + action: &str, +) -> Option> { + if let Some(binding) = custom { + return Some(binding.iter().map(String::as_str).collect()); + } + + preset_ui_keymap_binding(base, action).map(|binding| binding.to_vec()) +} + +fn preset_ui_keymap_binding(base: UiKeymapBase, action: &str) -> Option<&'static [&'static str]> { + match (base, action) { + (UiKeymapBase::Default, "focus_prev") => Some(&["j"]), + (UiKeymapBase::Default, "focus_next") => Some(&["l"]), + (UiKeymapBase::Default, "move_up") => Some(&["i"]), + (UiKeymapBase::Default, "move_down") => Some(&["k"]), + (UiKeymapBase::Default, "jump_top") => None, + (UiKeymapBase::Default, "jump_bottom") => None, + (UiKeymapBase::Default, "quick_quit") => None, + (UiKeymapBase::Vim, "focus_prev") => Some(&["h"]), + (UiKeymapBase::Vim, "focus_next") => Some(&["l"]), + (UiKeymapBase::Vim, "move_up") => Some(&["k"]), + (UiKeymapBase::Vim, "move_down") => Some(&["j"]), + (UiKeymapBase::Vim, "jump_top") => Some(&["g", "g"]), + (UiKeymapBase::Vim, "jump_bottom") => Some(&["G"]), + (UiKeymapBase::Vim, "quick_quit") => Some(&["q", "q"]), + _ => unreachable!("unexpected keymap action"), + } +} + +fn binding_is_prefix(left: &[&str], right: &[&str]) -> bool { + left.len() < right.len() && right.starts_with(left) +} + fn build_imap_config_with_env( file_config: &ImapFileConfig, config_path: &Path, @@ -634,7 +923,7 @@ mod tests { use super::{ DEFAULT_CONFIG_FILE_NAME, DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, IMAP_INBOX_MAILBOX, - ImapFileConfig, LEGACY_CONFIG_FILE_NAME, SelfEmailSource, UiKeymap, + ImapFileConfig, LEGACY_CONFIG_FILE_NAME, SelfEmailSource, UiKeymap, UiKeymapBase, build_imap_config_with_env, load, load_with_home, resolve_self_email_with, }; @@ -758,6 +1047,93 @@ keymap = "custom" let _ = fs::remove_dir_all(base); } + #[test] + fn loads_custom_ui_keymap_overrides_and_base_from_config() { + let base = temp_dir("config-custom-keymap-overrides"); + let config_path = base.join("config.toml"); + + fs::write( + &config_path, + r#" +[ui] +keymap = "custom" +keymap_base = "vim" + +[ui.custom_keymap] +focus_prev = ["x"] +quick_quit = ["z", "z"] +"#, + ) + .expect("write config"); + + let loaded = load(Some(&config_path)).expect("load config"); + + assert_eq!(loaded.ui_keymap, UiKeymap::Custom); + assert_eq!(loaded.ui_keymap_base, UiKeymapBase::Vim); + assert_eq!( + loaded.ui_custom_keymap.focus_prev, + Some(vec!["x".to_string()]) + ); + assert_eq!( + loaded.ui_custom_keymap.quick_quit, + Some(vec!["z".to_string(), "z".to_string()]) + ); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn infers_custom_keymap_base_from_vim_scheme_when_omitted() { + let base = temp_dir("config-vim-infers-custom-base"); + let config_path = base.join("config.toml"); + + fs::write( + &config_path, + r#" +[ui] +keymap = "vim" +"#, + ) + .expect("write config"); + + let loaded = load(Some(&config_path)).expect("load config"); + + assert_eq!(loaded.ui_keymap, UiKeymap::Vim); + assert_eq!(loaded.ui_keymap_base, UiKeymapBase::Vim); + + let _ = fs::remove_dir_all(base); + } + + #[test] + fn rejects_custom_keymap_prefix_conflicts_against_base() { + let base = temp_dir("config-custom-keymap-conflict"); + let config_path = base.join("config.toml"); + + fs::write( + &config_path, + r#" +[ui] +keymap = "custom" +keymap_base = "vim" + +[ui.custom_keymap] +focus_prev = ["g"] +"#, + ) + .expect("write config"); + + let error = match load(Some(&config_path)) { + Ok(_) => panic!("conflicting config should fail"), + Err(error) => error, + }; + assert!( + error.to_string().contains("prefix conflict"), + "unexpected error: {error}" + ); + + let _ = fs::remove_dir_all(base); + } + #[test] fn falls_back_to_config_alias_filename_when_present() { let home = temp_dir("config-legacy-home"); diff --git a/src/infra/sendmail.rs b/src/infra/sendmail.rs index a1cffad..0fa83ef 100644 --- a/src/infra/sendmail.rs +++ b/src/infra/sendmail.rs @@ -887,6 +887,8 @@ mod tests { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: crate::infra::config::UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), diff --git a/src/ui/tui.rs b/src/ui/tui.rs index be10471..9da1cad 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -22,7 +22,7 @@ use crate::domain::subscriptions::{ DEFAULT_SUBSCRIPTIONS, SubscriptionCategory, category_for_mailbox, }; use crate::infra::bootstrap::BootstrapState; -use crate::infra::config::{IMAP_INBOX_MAILBOX, RuntimeConfig, UiKeymap}; +use crate::infra::config::{IMAP_INBOX_MAILBOX, RuntimeConfig}; use crate::infra::error::{CriewError, ErrorCode, Result}; use crate::infra::mail_store::{self, ThreadRow}; use crate::infra::reply_store::{self, ReplySendRecordRequest, ReplySendStatus}; @@ -41,6 +41,7 @@ use ratatui::widgets::{Block, Borders}; mod config; mod input; +mod keymap; mod palette; mod preview; mod render; @@ -49,6 +50,11 @@ mod reply; mod tests; use input::{LoopAction, handle_key_event}; +use keymap::{ + KeymapEditorState, PendingMainPageSequenceState, ResolvedMainPageKeymap, draw_keymap_editor, + handle_keymap_editor_key_event, handle_main_page_key_event, main_page_focus_shortcuts, + main_page_move_shortcuts, main_page_navigation_shortcuts, resolve_active_main_page_keymap, +}; use palette::short_commit_id; #[cfg(test)] use palette::{is_palette_open_shortcut, is_palette_toggle, resolve_palette_local_workdir}; @@ -195,6 +201,10 @@ const PALETTE_COMMANDS: &[PaletteCommand] = &[ name: "config", description: "Open visual config editor or update runtime config", }, + PaletteCommand { + name: "keymap", + description: "Open visual keymap editor for main-page navigation", + }, PaletteCommand { name: "vim", description: "Open selected source file in external vim", @@ -233,6 +243,7 @@ const CONFIG_GET_KEYS: &[&str] = &[ "source.lore_base_url", "ui.startup_sync", "ui.keymap", + "ui.keymap_base", "ui.inbox_auto_sync_interval_secs", "kernel.tree", "kernel.trees", @@ -257,6 +268,7 @@ const CONFIG_SET_KEYS: &[&str] = &[ "source.lore_base_url", "ui.startup_sync", "ui.keymap", + "ui.keymap_base", "ui.inbox_auto_sync_interval_secs", "kernel.tree", "kernel.trees", @@ -276,7 +288,7 @@ const CONFIG_EDITOR_FIELDS: &[ConfigEditorField] = &[ }, ConfigEditorField { key: "ui.keymap", - description: "Main-page navigation scheme. default=j/l+i/k+count, vim=h/l+j/k+count+gg/G+qq, custom=default fallback with custom label.", + description: "Main-page navigation scheme. default=j/l+i/k+count, vim=h/l+j/k+count+gg/G+qq, custom=ui.keymap_base plus ui.custom_keymap overrides.", }, ConfigEditorField { key: "ui.inbox_auto_sync_interval_secs", @@ -350,28 +362,6 @@ const CONFIG_EDITOR_FIELDS: &[ConfigEditorField] = &[ const CODE_EDIT_ENTRY_HINT: &str = "select a source file in Source pane, then press e"; const EXTERNAL_EDITOR_ENTRY_HINT: &str = "select a source file in Source pane, then press E"; -fn main_page_focus_shortcuts(keymap: UiKeymap) -> &'static str { - match keymap { - UiKeymap::Default | UiKeymap::Custom => "j/l", - UiKeymap::Vim => "h/l", - } -} - -fn main_page_move_shortcuts(keymap: UiKeymap) -> &'static str { - match keymap { - UiKeymap::Default | UiKeymap::Custom => "i/k", - UiKeymap::Vim => "j/k", - } -} - -fn main_page_navigation_shortcuts(keymap: UiKeymap) -> String { - format!( - "{} focus | {} move", - main_page_focus_shortcuts(keymap), - main_page_move_shortcuts(keymap) - ) -} - fn shrink_mail_pane_width(width: &mut u16, minimum_width: u16) -> bool { if *width <= minimum_width { return false; @@ -395,20 +385,6 @@ type ReplyIdentityResolver = fn() -> std::result::Result; type ReplySendExecutor = fn(&RuntimeConfig, &SendRequest) -> SendOutcome; type MailboxSyncSpawner = fn(RuntimeConfig, Vec) -> Receiver; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PendingMainPageChord { - VimGoToFirstLine, - VimQuit, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct PendingMainPageChordState { - chord: PendingMainPageChord, - ui_page: UiPage, - focus: Pane, - code_focus: CodePaneFocus, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct PendingMainPageCountState { count: u16, @@ -1307,6 +1283,7 @@ struct AppState { palette: CommandPaletteState, search: SearchState, config_editor: ConfigEditorState, + keymap_editor: KeymapEditorState, external_editor_runner: ExternalEditorRunner, reply_identity_resolver: ReplyIdentityResolver, reply_send_executor: ReplySendExecutor, @@ -1317,7 +1294,8 @@ struct AppState { inbox_auto_sync: Option, manual_sync: Option, subscription_auto_sync: Option, - pending_main_page_chord: Option, + main_page_keymap: ResolvedMainPageKeymap, + pending_main_page_sequence: Option, pending_main_page_count: Option, } @@ -1364,6 +1342,7 @@ impl AppState { let kernel_tree_expanded_paths = default_kernel_tree_expanded_paths(&runtime.kernel_trees); let kernel_tree_rows = build_kernel_tree_rows(&runtime.kernel_trees, &kernel_tree_expanded_paths); + let main_page_keymap = resolve_active_main_page_keymap(&runtime); let mut state = Self { active_thread_mailbox, runtime, @@ -1426,6 +1405,7 @@ impl AppState { palette: CommandPaletteState::default(), search: SearchState::default(), config_editor: ConfigEditorState::default(), + keymap_editor: KeymapEditorState::default(), external_editor_runner: run_external_editor_session, reply_identity_resolver: resolve_git_reply_identity, reply_send_executor: send_reply_message, @@ -1436,7 +1416,8 @@ impl AppState { inbox_auto_sync: None, manual_sync: None, subscription_auto_sync: None, - pending_main_page_chord: None, + main_page_keymap, + pending_main_page_sequence: None, pending_main_page_count: None, }; if state.runtime.imap.is_complete() { @@ -3979,18 +3960,6 @@ impl AppState { } } - fn pending_main_page_chord_state( - &self, - chord: PendingMainPageChord, - ) -> PendingMainPageChordState { - PendingMainPageChordState { - chord, - ui_page: self.ui_page, - focus: self.focus, - code_focus: self.code_focus, - } - } - fn pending_main_page_count_state(&self, count: u16) -> PendingMainPageCountState { PendingMainPageCountState { count, @@ -4001,7 +3970,7 @@ impl AppState { } fn clear_pending_main_page_inputs(&mut self) { - self.pending_main_page_chord = None; + self.pending_main_page_sequence = None; self.pending_main_page_count = None; } diff --git a/src/ui/tui/config.rs b/src/ui/tui/config.rs index c6c7e25..b28c619 100644 --- a/src/ui/tui/config.rs +++ b/src/ui/tui/config.rs @@ -15,9 +15,9 @@ use super::palette::PaletteCompletionContext; use super::render::{centered_rect, truncate_with_ellipsis}; use super::*; -struct ConfigFileUpdate { - rendered_value: String, - runtime: RuntimeConfig, +pub(super) struct ConfigFileUpdate { + pub(super) rendered_value: String, + pub(super) runtime: RuntimeConfig, } impl AppState { @@ -330,7 +330,7 @@ fn read_config_key_from_file( Ok(lookup_config_key(&table, key).cloned()) } -fn update_config_key_in_file( +pub(super) fn update_config_key_in_file( config_path: &Path, key: &str, value_literal: &str, @@ -354,7 +354,7 @@ fn update_config_key_in_file( }) } -fn remove_config_key_from_file( +pub(super) fn remove_config_key_from_file( config_path: &Path, key: &str, ) -> std::result::Result, String> { @@ -532,7 +532,7 @@ fn config_editor_field_index(key: &str) -> Option { .position(|field| field.key.eq_ignore_ascii_case(key)) } -fn apply_runtime_update(state: &mut AppState, runtime: RuntimeConfig) { +pub(super) fn apply_runtime_update(state: &mut AppState, runtime: RuntimeConfig) { let old_inbox_auto_sync_interval_secs = state.runtime.inbox_auto_sync_interval_secs; let selected_path_hint = state.selected_kernel_tree_path(); let enabled_mailboxes: HashSet = state.enabled_mailboxes().into_iter().collect(); @@ -540,6 +540,7 @@ fn apply_runtime_update(state: &mut AppState, runtime: RuntimeConfig) { // Preserve current UI intent across config reloads so editing one setting // does not unexpectedly wipe mailbox enablement or the active tree focus. state.runtime = runtime; + state.rebuild_main_page_keymap(); state.ui_state_path = ui_state::path_for_data_dir(&state.runtime.data_dir); state.subscriptions = default_subscriptions( &state.runtime, @@ -611,6 +612,7 @@ fn effective_config_value(state: &AppState, key: &str) -> Option { "source.lore_base_url" => Some(state.runtime.lore_base_url.clone()), "ui.startup_sync" => Some(state.runtime.startup_sync.to_string()), "ui.keymap" => Some(state.runtime.ui_keymap.as_str().to_string()), + "ui.keymap_base" => Some(state.runtime.ui_keymap_base.as_str().to_string()), "ui.inbox_auto_sync_interval_secs" => { Some(state.runtime.inbox_auto_sync_interval_secs.to_string()) } @@ -830,7 +832,17 @@ fn config_value_suggestions(state: &AppState, key: Option<&String>) -> Vec [ ("default", "j/l focus, i/k move"), ("vim", "h/l focus, j/k move, gg/G jump, qq quit"), - ("custom", "custom label with default navigation fallback"), + ("custom", "ui.keymap_base plus ui.custom_keymap overrides"), + ] + .iter() + .map(|(value, description)| PaletteSuggestion { + value: (*value).to_string(), + description: Some((*description).to_string()), + }) + .collect(), + "ui.keymap_base" => [ + ("default", "j/l focus, i/k move"), + ("vim", "h/l focus, j/k move, gg/G jump, qq quit"), ] .iter() .map(|(value, description)| PaletteSuggestion { diff --git a/src/ui/tui/input.rs b/src/ui/tui/input.rs index 285bd2c..50b6ab9 100644 --- a/src/ui/tui/input.rs +++ b/src/ui/tui/input.rs @@ -19,65 +19,10 @@ pub(super) enum LoopAction { Restart, } -fn pending_main_page_move_count(state: &mut AppState) -> u16 { +pub(super) fn pending_main_page_move_count(state: &mut AppState) -> u16 { state.take_pending_main_page_count().unwrap_or(1) } -fn handle_main_page_navigation_key(state: &mut AppState, key: KeyEvent) -> bool { - match state.runtime.ui_keymap { - UiKeymap::Default | UiKeymap::Custom => match key.code { - KeyCode::Char('j') => { - state.clear_pending_main_page_count(); - state.move_focus_previous(); - true - } - KeyCode::Char('l') => { - state.clear_pending_main_page_count(); - state.move_focus_next(); - true - } - KeyCode::Char('i') => { - for _ in 0..pending_main_page_move_count(state) { - state.move_up(); - } - true - } - KeyCode::Char('k') => { - for _ in 0..pending_main_page_move_count(state) { - state.move_down(); - } - true - } - _ => false, - }, - UiKeymap::Vim => match key.code { - KeyCode::Char('h') => { - state.clear_pending_main_page_count(); - state.move_focus_previous(); - true - } - KeyCode::Char('l') => { - state.clear_pending_main_page_count(); - state.move_focus_next(); - true - } - KeyCode::Char('k') => { - for _ in 0..pending_main_page_move_count(state) { - state.move_up(); - } - true - } - KeyCode::Char('j') => { - for _ in 0..pending_main_page_move_count(state) { - state.move_down(); - } - true - } - _ => false, - }, - } -} - fn handle_main_page_count_prefix(state: &mut AppState, key: KeyEvent) -> bool { if key .modifiers @@ -104,61 +49,6 @@ fn handle_main_page_count_prefix(state: &mut AppState, key: KeyEvent) -> bool { true } -fn handle_vim_main_page_chord(state: &mut AppState, key: KeyEvent) -> Option { - if !matches!(state.runtime.ui_keymap, UiKeymap::Vim) { - state.pending_main_page_chord = None; - return None; - } - - if key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) - { - state.clear_pending_main_page_inputs(); - return None; - } - - if let Some(pending_state) = state.pending_main_page_chord.take() { - let same_scope = pending_state.ui_page == state.ui_page - && pending_state.focus == state.focus - && pending_state.code_focus == state.code_focus; - if same_scope { - match (pending_state.chord, key.code) { - (PendingMainPageChord::VimGoToFirstLine, KeyCode::Char('g')) => { - state.jump_current_pane_to_start(); - return Some(LoopAction::Continue); - } - (PendingMainPageChord::VimQuit, KeyCode::Char('q')) => { - return Some(LoopAction::Exit); - } - _ => {} - } - } - } - - match key.code { - KeyCode::Char('g') => { - state.clear_pending_main_page_count(); - state.pending_main_page_chord = - Some(state.pending_main_page_chord_state(PendingMainPageChord::VimGoToFirstLine)); - Some(LoopAction::Continue) - } - KeyCode::Char('G') => { - state.clear_pending_main_page_count(); - state.jump_current_pane_to_end(); - Some(LoopAction::Continue) - } - KeyCode::Char('q') => { - state.clear_pending_main_page_count(); - state.pending_main_page_chord = - Some(state.pending_main_page_chord_state(PendingMainPageChord::VimQuit)); - state.status = "press qq to quit or use command palette quit/exit".to_string(); - Some(LoopAction::Continue) - } - _ => None, - } -} - pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { tracing::debug!( key = ?key, @@ -167,6 +57,7 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio code_focus = ?state.code_focus, code_edit_mode = ?state.code_edit_mode, config_editor_open = state.config_editor.open, + keymap_editor_open = state.keymap_editor.open, palette_open = state.palette.open, search_active = state.search.active, "user key event" @@ -179,6 +70,11 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio return handle_config_editor_key_event(state, key); } + if state.keymap_editor.open { + state.clear_pending_main_page_inputs(); + return handle_keymap_editor_key_event(state, key); + } + if state.palette.open { state.clear_pending_main_page_inputs(); if is_palette_toggle(key) { @@ -203,21 +99,17 @@ pub(super) fn handle_key_event(state: &mut AppState, key: KeyEvent) -> LoopActio return handle_code_edit_key_event(state, key); } - if let Some(action) = handle_vim_main_page_chord(state, key) { - return action; - } - - if handle_main_page_count_prefix(state, key) { - return LoopAction::Continue; - } - if is_palette_open_shortcut(key) { - state.clear_pending_main_page_count(); + state.clear_pending_main_page_inputs(); state.toggle_palette(); return LoopAction::Continue; } - if handle_main_page_navigation_key(state, key) { + if let Some(action) = handle_main_page_key_event(state, key) { + return action; + } + + if handle_main_page_count_prefix(state, key) { return LoopAction::Continue; } @@ -726,9 +618,9 @@ fn handle_palette_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { "restart" => return LoopAction::Restart, "help" => { state.status = format!( - "commands: quit, exit, restart, help, sync [mailbox], config ..., vim, ! | keys: {} focus, {} move, [ ] expand pane, {{ }} shrink pane, -/= preview switch, y/n enable, a apply, d download, u undo apply, e reply/inline edit, r reply, E external vim", - main_page_focus_shortcuts(state.runtime.ui_keymap), - main_page_move_shortcuts(state.runtime.ui_keymap) + "commands: quit, exit, restart, help, sync [mailbox], config ..., keymap, vim, ! | keys: {} focus, {} move, [ ] expand pane, {{ }} shrink pane, -/= preview switch, y/n enable, a apply, d download, u undo apply, e reply/inline edit, r reply, E external vim", + main_page_focus_shortcuts(&state.main_page_keymap), + main_page_move_shortcuts(&state.main_page_keymap) ); } value if value.split_whitespace().next() == Some("sync") => { @@ -738,6 +630,9 @@ fn handle_palette_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { value if value.split_whitespace().next() == Some("config") => { run_palette_config(state, value); } + "keymap" => { + state.open_keymap_editor(); + } "vim" => { state.open_external_editor(); } diff --git a/src/ui/tui/keymap.rs b/src/ui/tui/keymap.rs new file mode 100644 index 0000000..870d200 --- /dev/null +++ b/src/ui/tui/keymap.rs @@ -0,0 +1,1017 @@ +//! Main-page keymap resolution, matching, and editor UI. +//! +//! CRIEW keeps top-level navigation configurable while preserving the +//! hard-coded rescue keys that operators rely on to recover from a bad custom +//! layout. This module resolves the active scheme from runtime config, matches +//! multi-key chords, and exposes the `:keymap` editor modal. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; + +use crate::infra::config::{RuntimeConfig, UiCustomKeymapConfig, UiKeymap, UiKeymapBase}; + +use super::config::{apply_runtime_update, remove_config_key_from_file, update_config_key_in_file}; +use super::input::LoopAction; +use super::input::pending_main_page_move_count; +use super::render::{centered_rect, truncate_with_ellipsis}; +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum MainPageAction { + FocusPrevious, + FocusNext, + MoveUp, + MoveDown, + JumpTop, + JumpBottom, + QuickQuit, +} + +impl MainPageAction { + const ALL: [Self; 7] = [ + Self::FocusPrevious, + Self::FocusNext, + Self::MoveUp, + Self::MoveDown, + Self::JumpTop, + Self::JumpBottom, + Self::QuickQuit, + ]; + + fn key(self) -> &'static str { + match self { + Self::FocusPrevious => "focus_prev", + Self::FocusNext => "focus_next", + Self::MoveUp => "move_up", + Self::MoveDown => "move_down", + Self::JumpTop => "jump_top", + Self::JumpBottom => "jump_bottom", + Self::QuickQuit => "quick_quit", + } + } + + fn label(self) -> &'static str { + match self { + Self::FocusPrevious => "Focus Previous", + Self::FocusNext => "Focus Next", + Self::MoveUp => "Move Up", + Self::MoveDown => "Move Down", + Self::JumpTop => "Jump Top", + Self::JumpBottom => "Jump Bottom", + Self::QuickQuit => "Quick Quit", + } + } + + fn description(self) -> &'static str { + match self { + Self::FocusPrevious => "Move focus to the previous top-level pane.", + Self::FocusNext => "Move focus to the next top-level pane.", + Self::MoveUp => "Move up inside the currently focused pane.", + Self::MoveDown => "Move down inside the currently focused pane.", + Self::JumpTop => "Jump to the top of the active pane or preview.", + Self::JumpBottom => "Jump to the bottom of the active pane or preview.", + Self::QuickQuit => "Exit CRIEW without opening the command palette.", + } + } + + fn max_tokens(self) -> usize { + match self { + Self::FocusPrevious | Self::FocusNext | Self::MoveUp | Self::MoveDown => 1, + Self::JumpTop | Self::JumpBottom | Self::QuickQuit => 2, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct KeySequence { + tokens: Vec, +} + +impl KeySequence { + fn from_chars(tokens: Vec) -> Self { + Self { tokens } + } + + fn from_config_tokens(tokens: &[String]) -> Self { + Self { + tokens: tokens + .iter() + .filter_map(|token| token.chars().next()) + .collect(), + } + } + + fn display(&self) -> String { + self.tokens.iter().collect() + } + + fn as_chars(&self) -> &[char] { + &self.tokens + } + + fn starts_with(&self, input: &[char]) -> bool { + self.tokens.starts_with(input) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ResolvedMainPageKeymap { + focus_previous: KeySequence, + focus_next: KeySequence, + move_up: KeySequence, + move_down: KeySequence, + move_shortcut_order: MoveShortcutOrder, + jump_top: Option, + jump_bottom: Option, + quick_quit: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MoveShortcutOrder { + UpThenDown, + DownThenUp, +} + +impl ResolvedMainPageKeymap { + fn binding(&self, action: MainPageAction) -> Option<&KeySequence> { + match action { + MainPageAction::FocusPrevious => Some(&self.focus_previous), + MainPageAction::FocusNext => Some(&self.focus_next), + MainPageAction::MoveUp => Some(&self.move_up), + MainPageAction::MoveDown => Some(&self.move_down), + MainPageAction::JumpTop => self.jump_top.as_ref(), + MainPageAction::JumpBottom => self.jump_bottom.as_ref(), + MainPageAction::QuickQuit => self.quick_quit.as_ref(), + } + } + + fn action_for_sequence(&self, input: &[char]) -> Option { + MainPageAction::ALL.into_iter().find(|action| { + self.binding(*action) + .is_some_and(|binding| binding.as_chars() == input) + }) + } + + fn has_pending_prefix(&self, input: &[char]) -> bool { + MainPageAction::ALL.into_iter().any(|action| { + self.binding(action) + .is_some_and(|binding| binding.as_chars() != input && binding.starts_with(input)) + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(super) enum KeymapEditorMode { + #[default] + Browse, + Capture, +} + +#[derive(Debug, Default)] +pub(super) struct KeymapEditorState { + pub(super) open: bool, + pub(super) selected_field: usize, + pub(super) mode: KeymapEditorMode, + pub(super) capture_tokens: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeymapEditorField { + ActiveScheme, + CustomBase, + Action(MainPageAction), +} + +impl KeymapEditorField { + fn label(self) -> &'static str { + match self { + Self::ActiveScheme => "Active Scheme", + Self::CustomBase => "Custom Base", + Self::Action(action) => action.label(), + } + } +} + +const KEYMAP_EDITOR_FIELDS: &[KeymapEditorField] = &[ + KeymapEditorField::ActiveScheme, + KeymapEditorField::CustomBase, + KeymapEditorField::Action(MainPageAction::FocusPrevious), + KeymapEditorField::Action(MainPageAction::FocusNext), + KeymapEditorField::Action(MainPageAction::MoveUp), + KeymapEditorField::Action(MainPageAction::MoveDown), + KeymapEditorField::Action(MainPageAction::JumpTop), + KeymapEditorField::Action(MainPageAction::JumpBottom), + KeymapEditorField::Action(MainPageAction::QuickQuit), +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct PendingMainPageSequenceState { + tokens: Vec, + ui_page: UiPage, + focus: Pane, + code_focus: CodePaneFocus, +} + +impl AppState { + pub(super) fn open_keymap_editor(&mut self) { + self.palette.open = false; + self.palette.input.clear(); + self.palette.clear_completion(); + self.palette.clear_local_result(); + self.keymap_editor.open = true; + self.keymap_editor.mode = KeymapEditorMode::Browse; + self.keymap_editor.capture_tokens.clear(); + self.status = "keymap editor opened".to_string(); + } + + pub(super) fn close_keymap_editor(&mut self) { + self.keymap_editor.open = false; + self.keymap_editor.mode = KeymapEditorMode::Browse; + self.keymap_editor.capture_tokens.clear(); + self.status = "keymap editor closed".to_string(); + } + + fn selected_keymap_editor_field(&self) -> KeymapEditorField { + let index = self + .keymap_editor + .selected_field + .min(KEYMAP_EDITOR_FIELDS.len().saturating_sub(1)); + KEYMAP_EDITOR_FIELDS[index] + } + + fn move_keymap_editor_up(&mut self) { + if self.keymap_editor.selected_field > 0 { + self.keymap_editor.selected_field -= 1; + } + } + + fn move_keymap_editor_down(&mut self) { + if self.keymap_editor.selected_field + 1 < KEYMAP_EDITOR_FIELDS.len() { + self.keymap_editor.selected_field += 1; + } + } + + fn pending_main_page_sequence_state(&self, tokens: Vec) -> PendingMainPageSequenceState { + PendingMainPageSequenceState { + tokens, + ui_page: self.ui_page, + focus: self.focus, + code_focus: self.code_focus, + } + } + + fn take_pending_main_page_sequence(&mut self) -> Option> { + let pending = self.pending_main_page_sequence.take()?; + let same_scope = pending.ui_page == self.ui_page + && pending.focus == self.focus + && pending.code_focus == self.code_focus; + same_scope.then_some(pending.tokens) + } + + pub(super) fn rebuild_main_page_keymap(&mut self) { + self.main_page_keymap = resolve_active_main_page_keymap(&self.runtime); + self.pending_main_page_sequence = None; + } + + fn start_keymap_binding_capture(&mut self) { + let KeymapEditorField::Action(action) = self.selected_keymap_editor_field() else { + self.status = "select a key binding row to record".to_string(); + return; + }; + + self.keymap_editor.mode = KeymapEditorMode::Capture; + self.keymap_editor.capture_tokens.clear(); + self.status = format!("recording {}", action.key()); + } + + fn cancel_keymap_binding_capture(&mut self) { + self.keymap_editor.mode = KeymapEditorMode::Browse; + self.keymap_editor.capture_tokens.clear(); + self.status = "key binding capture cancelled".to_string(); + } + + fn capture_keymap_binding_token(&mut self, action: MainPageAction, key: KeyEvent) { + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) + { + self.status = "modifiers are not supported in main-page keymap bindings".to_string(); + return; + } + + let KeyCode::Char(character) = key.code else { + self.status = "only printable character keys are supported".to_string(); + return; + }; + if !is_bindable_keymap_character(character) { + self.status = format!("unsupported binding key: {character}"); + return; + } + if is_reserved_keymap_character(character) { + self.status = format!("reserved binding key: {character}"); + return; + } + if self.keymap_editor.capture_tokens.len() >= action.max_tokens() { + self.status = format!( + "{} accepts at most {} key(s)", + action.key(), + action.max_tokens() + ); + return; + } + + self.keymap_editor.capture_tokens.push(character); + self.status = format!( + "recording {} = {}", + action.key(), + self.keymap_editor.capture_tokens.iter().collect::() + ); + } + + fn save_keymap_binding_capture(&mut self) { + let KeymapEditorField::Action(action) = self.selected_keymap_editor_field() else { + self.cancel_keymap_binding_capture(); + return; + }; + if self.keymap_editor.capture_tokens.is_empty() { + self.status = format!( + "empty binding for {}; press x to reset instead", + action.key() + ); + return; + } + + let literal = format_toml_key_sequence_literal(&self.keymap_editor.capture_tokens); + match update_config_key_in_file( + &self.runtime.config_path, + &format!("ui.custom_keymap.{}", action.key()), + &literal, + ) { + Ok(update) => { + apply_runtime_update(self, update.runtime); + self.keymap_editor.mode = KeymapEditorMode::Browse; + self.keymap_editor.capture_tokens.clear(); + self.status = format!( + "custom binding updated: {} = {}", + action.key(), + update.rendered_value + ); + } + Err(error) => { + self.status = format!("failed to set custom binding {}: {error}", action.key()); + } + } + } + + fn cycle_keymap_editor_value(&mut self, forward: bool) { + match self.selected_keymap_editor_field() { + KeymapEditorField::ActiveScheme => { + let next = cycle_keymap_scheme(self.runtime.ui_keymap, forward); + match update_config_key_in_file( + &self.runtime.config_path, + "ui.keymap", + next.as_str(), + ) { + Ok(update) => { + apply_runtime_update(self, update.runtime); + self.status = format!("active keymap set to {}", next.as_str()); + } + Err(error) => { + self.status = format!("failed to set ui.keymap: {error}"); + } + } + } + KeymapEditorField::CustomBase => { + let next = cycle_keymap_base(self.runtime.ui_keymap_base, forward); + match update_config_key_in_file( + &self.runtime.config_path, + "ui.keymap_base", + next.as_str(), + ) { + Ok(update) => { + apply_runtime_update(self, update.runtime); + self.status = format!("custom keymap base set to {}", next.as_str()); + } + Err(error) => { + self.status = format!("failed to set ui.keymap_base: {error}"); + } + } + } + KeymapEditorField::Action(_) => { + self.status = "press Enter or e to record a custom binding".to_string(); + } + } + } + + fn reset_selected_keymap_field(&mut self) { + match self.selected_keymap_editor_field() { + KeymapEditorField::ActiveScheme => { + match remove_config_key_from_file(&self.runtime.config_path, "ui.keymap") { + Ok(Some(runtime)) => { + apply_runtime_update(self, runtime); + self.status = "ui.keymap reset to default".to_string(); + } + Ok(None) => { + self.status = "ui.keymap already uses the default".to_string(); + } + Err(error) => { + self.status = format!("failed to reset ui.keymap: {error}"); + } + } + } + KeymapEditorField::CustomBase => { + match remove_config_key_from_file(&self.runtime.config_path, "ui.keymap_base") { + Ok(Some(runtime)) => { + apply_runtime_update(self, runtime); + self.status = "ui.keymap_base reset to inferred default".to_string(); + } + Ok(None) => { + self.status = + "ui.keymap_base already uses the inferred default".to_string(); + } + Err(error) => { + self.status = format!("failed to reset ui.keymap_base: {error}"); + } + } + } + KeymapEditorField::Action(action) => { + match remove_config_key_from_file( + &self.runtime.config_path, + &format!("ui.custom_keymap.{}", action.key()), + ) { + Ok(Some(runtime)) => { + apply_runtime_update(self, runtime); + self.status = format!("custom binding reset: {}", action.key()); + } + Ok(None) => { + self.status = format!("custom binding already inherited: {}", action.key()); + } + Err(error) => { + self.status = + format!("failed to reset custom binding {}: {error}", action.key()); + } + } + } + } + } + + fn reset_all_custom_key_bindings(&mut self) { + match remove_config_key_from_file(&self.runtime.config_path, "ui.custom_keymap") { + Ok(Some(runtime)) => { + apply_runtime_update(self, runtime); + self.status = "all custom key bindings reset".to_string(); + } + Ok(None) => { + self.status = "no custom key bindings to reset".to_string(); + } + Err(error) => { + self.status = format!("failed to reset custom key bindings: {error}"); + } + } + } +} + +pub(super) fn resolve_active_main_page_keymap(runtime: &RuntimeConfig) -> ResolvedMainPageKeymap { + match runtime.ui_keymap { + UiKeymap::Default => preset_main_page_keymap(UiKeymapBase::Default), + UiKeymap::Vim => preset_main_page_keymap(UiKeymapBase::Vim), + UiKeymap::Custom => resolve_custom_main_page_keymap(runtime), + } +} + +pub(super) fn resolve_custom_main_page_keymap(runtime: &RuntimeConfig) -> ResolvedMainPageKeymap { + let base = preset_main_page_keymap(runtime.ui_keymap_base); + overlay_custom_keymap(base, &runtime.ui_custom_keymap) +} + +pub(super) fn main_page_focus_shortcuts(keymap: &ResolvedMainPageKeymap) -> String { + format!( + "{}/{}", + keymap.focus_previous.display(), + keymap.focus_next.display() + ) +} + +pub(super) fn main_page_move_shortcuts(keymap: &ResolvedMainPageKeymap) -> String { + match keymap.move_shortcut_order { + MoveShortcutOrder::UpThenDown => { + format!( + "{}/{}", + keymap.move_up.display(), + keymap.move_down.display() + ) + } + MoveShortcutOrder::DownThenUp => { + format!( + "{}/{}", + keymap.move_down.display(), + keymap.move_up.display() + ) + } + } +} + +pub(super) fn main_page_navigation_shortcuts(keymap: &ResolvedMainPageKeymap) -> String { + format!( + "{} focus | {} move", + main_page_focus_shortcuts(keymap), + main_page_move_shortcuts(keymap) + ) +} + +pub(super) fn handle_main_page_key_event( + state: &mut AppState, + key: KeyEvent, +) -> Option { + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) + { + state.pending_main_page_sequence = None; + return None; + } + + let KeyCode::Char(character) = key.code else { + state.pending_main_page_sequence = None; + return None; + }; + + let pending_prefix = state.take_pending_main_page_sequence().unwrap_or_default(); + let combined = extend_sequence(&pending_prefix, character); + if let Some(action) = state.main_page_keymap.action_for_sequence(&combined) { + state.pending_main_page_sequence = None; + return Some(execute_main_page_action(state, action)); + } + if state.main_page_keymap.has_pending_prefix(&combined) { + state.clear_pending_main_page_count(); + set_pending_main_page_status(state, &combined); + state.pending_main_page_sequence = Some(state.pending_main_page_sequence_state(combined)); + return Some(LoopAction::Continue); + } + + let single = vec![character]; + if let Some(action) = state.main_page_keymap.action_for_sequence(&single) { + state.pending_main_page_sequence = None; + return Some(execute_main_page_action(state, action)); + } + if state.main_page_keymap.has_pending_prefix(&single) { + state.clear_pending_main_page_count(); + set_pending_main_page_status(state, &single); + state.pending_main_page_sequence = Some(state.pending_main_page_sequence_state(single)); + return Some(LoopAction::Continue); + } + + state.pending_main_page_sequence = None; + None +} + +pub(super) fn handle_keymap_editor_key_event(state: &mut AppState, key: KeyEvent) -> LoopAction { + match state.keymap_editor.mode { + KeymapEditorMode::Browse => match key.code { + KeyCode::Esc => state.close_keymap_editor(), + KeyCode::Up | KeyCode::Char('i') => state.move_keymap_editor_up(), + KeyCode::Down | KeyCode::Char('k') => state.move_keymap_editor_down(), + KeyCode::Left => state.cycle_keymap_editor_value(false), + KeyCode::Right | KeyCode::Tab => state.cycle_keymap_editor_value(true), + KeyCode::Enter | KeyCode::Char('e') => match state.selected_keymap_editor_field() { + KeymapEditorField::Action(_) => state.start_keymap_binding_capture(), + _ => state.cycle_keymap_editor_value(true), + }, + KeyCode::Char('x') => state.reset_selected_keymap_field(), + KeyCode::Char('R') => state.reset_all_custom_key_bindings(), + _ => {} + }, + KeymapEditorMode::Capture => match key.code { + KeyCode::Esc => state.cancel_keymap_binding_capture(), + KeyCode::Enter => state.save_keymap_binding_capture(), + KeyCode::Backspace => { + state.keymap_editor.capture_tokens.pop(); + } + _ => { + let KeymapEditorField::Action(action) = state.selected_keymap_editor_field() else { + return LoopAction::Continue; + }; + state.capture_keymap_binding_token(action, key); + } + }, + } + + LoopAction::Continue +} + +pub(super) fn draw_keymap_editor(frame: &mut Frame<'_>, state: &AppState) { + let custom_keymap = resolve_custom_main_page_keymap(&state.runtime); + let area = centered_rect(88, 78, frame.area()); + frame.render_widget(Clear, area); + + let block = Block::default() + .title("Keymap Editor") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + let inner = block.inner(area); + frame.render_widget(block, area); + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Min(10), + Constraint::Length(4), + ]) + .split(inner); + + let header = format!( + "active={} | custom base={} | command=:keymap", + state.runtime.ui_keymap.as_str(), + state.runtime.ui_keymap_base.as_str() + ); + frame.render_widget( + Paragraph::new(header).wrap(Wrap { trim: false }), + sections[0], + ); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(42), Constraint::Percentage(58)]) + .split(sections[1]); + + let selected_index = state + .keymap_editor + .selected_field + .min(KEYMAP_EDITOR_FIELDS.len().saturating_sub(1)); + let mut list_state = ListState::default(); + list_state.select(Some(selected_index)); + + let items: Vec = KEYMAP_EDITOR_FIELDS + .iter() + .map(|field| { + let value = keymap_editor_field_value(*field, state, &custom_keymap); + let text = truncate_with_ellipsis( + &format!("{:<14} {}", field.label(), value), + body[0].width.saturating_sub(3) as usize, + ); + ListItem::new(text) + }) + .collect(); + let list = List::new(items) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "); + frame.render_stateful_widget(list, body[0], &mut list_state); + + let details = + Paragraph::new(keymap_editor_details(state, &custom_keymap)).wrap(Wrap { trim: false }); + frame.render_widget(details, body[1]); + + let footer = match state.keymap_editor.mode { + KeymapEditorMode::Browse => { + "Up/Down move | Left/Right/Tab cycle | Enter/e record | x reset | R reset custom | Esc close" + } + KeymapEditorMode::Capture => { + "Type 1-2 printable keys | Backspace delete | Enter save | Esc cancel" + } + }; + frame.render_widget( + Paragraph::new(footer).wrap(Wrap { trim: false }), + sections[2], + ); +} + +fn preset_main_page_keymap(base: UiKeymapBase) -> ResolvedMainPageKeymap { + match base { + UiKeymapBase::Default => ResolvedMainPageKeymap { + focus_previous: KeySequence::from_chars(vec!['j']), + focus_next: KeySequence::from_chars(vec!['l']), + move_up: KeySequence::from_chars(vec!['i']), + move_down: KeySequence::from_chars(vec!['k']), + move_shortcut_order: MoveShortcutOrder::UpThenDown, + jump_top: None, + jump_bottom: None, + quick_quit: None, + }, + UiKeymapBase::Vim => ResolvedMainPageKeymap { + focus_previous: KeySequence::from_chars(vec!['h']), + focus_next: KeySequence::from_chars(vec!['l']), + move_up: KeySequence::from_chars(vec!['k']), + move_down: KeySequence::from_chars(vec!['j']), + move_shortcut_order: MoveShortcutOrder::DownThenUp, + jump_top: Some(KeySequence::from_chars(vec!['g', 'g'])), + jump_bottom: Some(KeySequence::from_chars(vec!['G'])), + quick_quit: Some(KeySequence::from_chars(vec!['q', 'q'])), + }, + } +} + +fn overlay_custom_keymap( + base: ResolvedMainPageKeymap, + custom: &UiCustomKeymapConfig, +) -> ResolvedMainPageKeymap { + let ResolvedMainPageKeymap { + focus_previous, + focus_next, + move_up, + move_down, + move_shortcut_order, + jump_top, + jump_bottom, + quick_quit, + } = base; + ResolvedMainPageKeymap { + focus_previous: custom + .focus_prev + .as_deref() + .map(KeySequence::from_config_tokens) + .unwrap_or(focus_previous), + focus_next: custom + .focus_next + .as_deref() + .map(KeySequence::from_config_tokens) + .unwrap_or(focus_next), + move_up: custom + .move_up + .as_deref() + .map(KeySequence::from_config_tokens) + .unwrap_or(move_up), + move_down: custom + .move_down + .as_deref() + .map(KeySequence::from_config_tokens) + .unwrap_or(move_down), + move_shortcut_order, + jump_top: custom + .jump_top + .as_deref() + .map(KeySequence::from_config_tokens) + .or(jump_top), + jump_bottom: custom + .jump_bottom + .as_deref() + .map(KeySequence::from_config_tokens) + .or(jump_bottom), + quick_quit: custom + .quick_quit + .as_deref() + .map(KeySequence::from_config_tokens) + .or(quick_quit), + } +} + +fn execute_main_page_action(state: &mut AppState, action: MainPageAction) -> LoopAction { + match action { + MainPageAction::FocusPrevious => { + state.clear_pending_main_page_count(); + state.move_focus_previous(); + LoopAction::Continue + } + MainPageAction::FocusNext => { + state.clear_pending_main_page_count(); + state.move_focus_next(); + LoopAction::Continue + } + MainPageAction::MoveUp => { + for _ in 0..pending_main_page_move_count(state) { + state.move_up(); + } + LoopAction::Continue + } + MainPageAction::MoveDown => { + for _ in 0..pending_main_page_move_count(state) { + state.move_down(); + } + LoopAction::Continue + } + MainPageAction::JumpTop => { + state.clear_pending_main_page_count(); + state.jump_current_pane_to_start(); + LoopAction::Continue + } + MainPageAction::JumpBottom => { + state.clear_pending_main_page_count(); + state.jump_current_pane_to_end(); + LoopAction::Continue + } + MainPageAction::QuickQuit => { + state.clear_pending_main_page_count(); + LoopAction::Exit + } + } +} + +fn set_pending_main_page_status(state: &mut AppState, input: &[char]) { + let Some(quick_quit) = state.main_page_keymap.binding(MainPageAction::QuickQuit) else { + return; + }; + if !quick_quit.starts_with(input) || quick_quit.as_chars() == input { + return; + } + + state.status = format!( + "press {} to quit or use command palette quit/exit", + quick_quit.display() + ); +} + +fn extend_sequence(prefix: &[char], next: char) -> Vec { + let mut sequence = prefix.to_vec(); + sequence.push(next); + sequence +} + +fn cycle_keymap_scheme(current: UiKeymap, forward: bool) -> UiKeymap { + const ORDER: [UiKeymap; 3] = [UiKeymap::Default, UiKeymap::Vim, UiKeymap::Custom]; + cycle_enum_value(&ORDER, current, forward) +} + +fn cycle_keymap_base(current: UiKeymapBase, forward: bool) -> UiKeymapBase { + const ORDER: [UiKeymapBase; 2] = [UiKeymapBase::Default, UiKeymapBase::Vim]; + cycle_enum_value(&ORDER, current, forward) +} + +fn cycle_enum_value(values: &[T], current: T, forward: bool) -> T { + let index = values + .iter() + .position(|value| *value == current) + .unwrap_or(0); + if forward { + values[(index + 1) % values.len()] + } else if index == 0 { + values[values.len().saturating_sub(1)] + } else { + values[index - 1] + } +} + +fn keymap_editor_field_value( + field: KeymapEditorField, + state: &AppState, + custom_keymap: &ResolvedMainPageKeymap, +) -> String { + match field { + KeymapEditorField::ActiveScheme => state.runtime.ui_keymap.as_str().to_string(), + KeymapEditorField::CustomBase => state.runtime.ui_keymap_base.as_str().to_string(), + KeymapEditorField::Action(action) => format!( + "{} [{}]", + render_binding_text(state.main_page_keymap.binding(action)), + active_keymap_binding_source_label(state, action, custom_keymap) + ), + } +} + +fn keymap_editor_details(state: &AppState, custom_keymap: &ResolvedMainPageKeymap) -> String { + match state.selected_keymap_editor_field() { + KeymapEditorField::ActiveScheme => format!( + "Select which main-page keymap is active now.\n\n\ + default: j/l focus, i/k move\n\ + vim: h/l focus, j/k move, gg/G jump, qq quit\n\ + custom: use ui.keymap_base plus ui.custom_keymap overrides\n\n\ + Current active shortcuts: {}\n", + main_page_navigation_shortcuts(&state.main_page_keymap) + ), + KeymapEditorField::CustomBase => format!( + "Choose the preset that seeds the custom scheme.\n\n\ + The custom scheme inherits any action without an override.\n\ + Current base: {}\n\ + Current custom shortcuts: {}\n", + state.runtime.ui_keymap_base.as_str(), + main_page_navigation_shortcuts(custom_keymap) + ), + KeymapEditorField::Action(action) => { + let custom_override = action_custom_binding(&state.runtime, action); + let active_binding = render_binding_text(state.main_page_keymap.binding(action)); + let custom_binding = render_binding_text(custom_keymap.binding(action)); + let source = if custom_override.is_some() { + "custom override".to_string() + } else { + format!("base {}", state.runtime.ui_keymap_base.as_str()) + }; + let capture = if matches!(state.keymap_editor.mode, KeymapEditorMode::Capture) { + format!( + "\nCapture buffer: {}\n", + state + .keymap_editor + .capture_tokens + .iter() + .collect::() + ) + } else { + String::new() + }; + format!( + "{}\n\n\ + {}\n\n\ + Active scheme: {}\n\ + Active binding now: {}\n\ + Custom binding: {}\n\ + Custom source: {}\n\ + Config key: ui.custom_keymap.{}\n\ + Override in file: {}\n{}", + action.label(), + action.description(), + state.runtime.ui_keymap.as_str(), + active_binding, + custom_binding, + source, + action.key(), + custom_override + .map(|binding| format_toml_key_sequence_literal(&binding)) + .unwrap_or_else(|| "".to_string()), + capture + ) + } + } +} + +fn action_custom_binding(runtime: &RuntimeConfig, action: MainPageAction) -> Option> { + let binding = match action { + MainPageAction::FocusPrevious => runtime.ui_custom_keymap.focus_prev.as_ref(), + MainPageAction::FocusNext => runtime.ui_custom_keymap.focus_next.as_ref(), + MainPageAction::MoveUp => runtime.ui_custom_keymap.move_up.as_ref(), + MainPageAction::MoveDown => runtime.ui_custom_keymap.move_down.as_ref(), + MainPageAction::JumpTop => runtime.ui_custom_keymap.jump_top.as_ref(), + MainPageAction::JumpBottom => runtime.ui_custom_keymap.jump_bottom.as_ref(), + MainPageAction::QuickQuit => runtime.ui_custom_keymap.quick_quit.as_ref(), + }?; + Some( + binding + .iter() + .filter_map(|token| token.chars().next()) + .collect(), + ) +} + +fn active_keymap_binding_source_label( + state: &AppState, + action: MainPageAction, + custom_keymap: &ResolvedMainPageKeymap, +) -> String { + match state.runtime.ui_keymap { + UiKeymap::Default => "default".to_string(), + UiKeymap::Vim => "vim".to_string(), + UiKeymap::Custom => { + if action_custom_binding(&state.runtime, action).is_some() { + "custom".to_string() + } else if custom_keymap.binding(action).is_some() { + format!("base {}", state.runtime.ui_keymap_base.as_str()) + } else { + "none".to_string() + } + } + } +} + +fn render_binding_text(binding: Option<&KeySequence>) -> String { + binding + .map(KeySequence::display) + .unwrap_or_else(|| "".to_string()) +} + +fn format_toml_key_sequence_literal(tokens: &[char]) -> String { + format!( + "[{}]", + tokens + .iter() + .map(|token| format!("\"{}\"", escape_toml_char(*token))) + .collect::>() + .join(", ") + ) +} + +fn escape_toml_char(character: char) -> String { + match character { + '"' => "\\\"".to_string(), + '\\' => "\\\\".to_string(), + _ => character.to_string(), + } +} + +fn is_bindable_keymap_character(character: char) -> bool { + !character.is_ascii_control() && !character.is_ascii_whitespace() && !character.is_ascii_digit() +} + +fn is_reserved_keymap_character(character: char) -> bool { + matches!( + character, + ':' | '/' + | 'e' + | 'r' + | 'a' + | 'd' + | 'u' + | 'y' + | 'n' + | '[' + | ']' + | '{' + | '}' + | 'E' + | '-' + | '=' + | '+' + ) +} diff --git a/src/ui/tui/render.rs b/src/ui/tui/render.rs index b54bc04..0d18be7 100644 --- a/src/ui/tui/render.rs +++ b/src/ui/tui/render.rs @@ -161,7 +161,7 @@ pub(super) fn draw( } UiPage::Mail => format!( "/ search | Tab page | : palette | Enter | e/r reply | [ ] expand pane | {{ }} shrink pane | {}", - main_page_navigation_shortcuts(state.runtime.ui_keymap) + main_page_navigation_shortcuts(&state.main_page_keymap) ), UiPage::CodeBrowser if state.is_code_edit_active() => { "Esc normal/exit | h/j/k/l move | i insert | x delete | s save | E external vim | :w :q :q! :wq :vim".to_string() @@ -172,7 +172,7 @@ pub(super) fn draw( } UiPage::CodeBrowser => format!( "Tab page | : palette | Enter expand/collapse | e inline edit | E external vim | {}", - main_page_navigation_shortcuts(state.runtime.ui_keymap) + main_page_navigation_shortcuts(&state.main_page_keymap) ), }; let footer_background = @@ -208,6 +208,9 @@ pub(super) fn draw( if state.config_editor.open { draw_config_editor(frame, state); } + if state.keymap_editor.open { + draw_keymap_editor(frame, state); + } if state.reply_panel.is_some() { draw_reply_panel(frame, state); } diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 8d92958..51e99a1 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -158,6 +158,8 @@ fn test_runtime_in(root: PathBuf) -> RuntimeConfig { lore_base_url: "https://lore.kernel.org".to_string(), startup_sync: true, ui_keymap: UiKeymap::Default, + ui_keymap_base: crate::infra::config::UiKeymapBase::Default, + ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(), inbox_auto_sync_interval_secs: crate::infra::config::DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, kernel_trees: Vec::new(), } @@ -629,14 +631,15 @@ fn type_text(state: &mut AppState, text: &str) { #[test] fn empty_query_returns_all_palette_commands() { let all = matching_commands(""); - assert_eq!(all.len(), 7); + assert_eq!(all.len(), 8); assert_eq!(all[0].name, "config"); assert_eq!(all[1].name, "exit"); assert_eq!(all[2].name, "help"); - assert_eq!(all[3].name, "quit"); - assert_eq!(all[4].name, "restart"); - assert_eq!(all[5].name, "sync"); - assert_eq!(all[6].name, "vim"); + assert_eq!(all[3].name, "keymap"); + assert_eq!(all[4].name, "quit"); + assert_eq!(all[5].name, "restart"); + assert_eq!(all[6].name, "sync"); + assert_eq!(all[7].name, "vim"); } #[test] @@ -893,6 +896,22 @@ fn command_palette_help_uses_vim_keymap_labels() { assert!(state.status.contains("j/k move")); } +#[test] +fn keymap_palette_command_opens_keymap_editor() { + let mut state = AppState::new(vec![], test_runtime()); + state.palette.open = true; + state.palette.input = "keymap".to_string(); + + let action = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!(action, LoopAction::Continue)); + assert!(state.keymap_editor.open); + assert!(!state.palette.open); + assert_eq!(state.status, "keymap editor opened"); +} + #[test] fn config_palette_get_and_set_roundtrip() { let root = temp_dir("palette-config"); @@ -1003,6 +1022,228 @@ keymap = "default" let _ = fs::remove_dir_all(root); } +#[test] +fn keymap_editor_custom_binding_updates_navigation_immediately() { + let root = temp_dir("keymap-editor-custom"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[ui] +keymap = "custom" +keymap_base = "default" +"#, + ) + .expect("write config file"); + + let mut runtime = test_runtime(); + runtime.config_path = config_path.clone(); + runtime.ui_keymap = UiKeymap::Custom; + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); + + state.palette.open = true; + state.palette.input = "keymap".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(state.keymap_editor.open); + + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(matches!( + state.keymap_editor.mode, + super::keymap::KeymapEditorMode::Capture + )); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + + assert!(matches!( + state.keymap_editor.mode, + super::keymap::KeymapEditorMode::Browse + )); + assert_eq!( + state.runtime.ui_custom_keymap.focus_prev, + Some(vec!["h".to_string()]) + ); + + let persisted = fs::read_to_string(&config_path).expect("read config file"); + assert!(persisted.contains("focus_prev = [\"h\"]")); + + state.keymap_editor.open = false; + state.focus = Pane::Threads; + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Subscriptions)); + + state.focus = Pane::Threads; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn custom_bindings_do_not_leak_into_default_or_vim_schemes() { + let root = temp_dir("keymap-scheme-isolation"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[ui] +keymap = "default" +keymap_base = "default" + +[ui.custom_keymap] +focus_prev = ["x"] +"#, + ) + .expect("write config file"); + + let mut runtime = test_runtime(); + runtime.config_path = config_path.clone(); + let mut state = AppState::new( + vec![ + sample_thread("t0", "a@example.com", 0), + sample_thread("t1", "b@example.com", 1), + ], + runtime, + ); + state.palette.open = true; + state.palette.input = "keymap".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(state.keymap_editor.open); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + assert_eq!(state.runtime.ui_keymap, UiKeymap::Vim); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + state.focus = Pane::Threads; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Subscriptions)); + + state.palette.open = true; + state.palette.input = "keymap".to_string(); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + assert!(state.keymap_editor.open); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + assert_eq!(state.runtime.ui_keymap, UiKeymap::Custom); + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + assert_eq!(state.runtime.ui_keymap, UiKeymap::Default); + let _ = handle_key_event(&mut state, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + state.focus = Pane::Threads; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Threads)); + + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), + ); + assert!(matches!(state.focus, Pane::Subscriptions)); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn keymap_editor_lists_active_bindings_for_selected_scheme() { + let root = temp_dir("keymap-editor-active-bindings"); + let config_path = root.join("criew-config.toml"); + fs::write( + &config_path, + r#" +[ui] +keymap = "default" +keymap_base = "default" + +[ui.custom_keymap] +focus_prev = ["x"] +"#, + ) + .expect("write config file"); + + let mut runtime = test_runtime(); + runtime.config_path = config_path; + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + state.open_keymap_editor(); + state.keymap_editor.selected_field = 2; + + let mut terminal = Terminal::new(TestBackend::new(160, 40)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw default keymap editor"); + let rendered_default = format!("{}", terminal.backend()); + assert!(rendered_default.contains("j [default]")); + + state.keymap_editor.selected_field = 0; + let _ = handle_key_event( + &mut state, + KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), + ); + state.keymap_editor.selected_field = 2; + let runtime_vim = state.runtime.clone(); + let bootstrap_vim = test_bootstrap(&runtime_vim); + terminal + .draw(|frame| draw(frame, &state, &runtime_vim, &bootstrap_vim)) + .expect("draw vim keymap editor"); + let rendered_vim = format!("{}", terminal.backend()); + assert!(rendered_vim.contains("h [vim]")); + + let _ = fs::remove_dir_all(root); +} + #[test] fn config_get_ui_keymap_returns_current_value() { let root = temp_dir("get-keymap"); From f69105e049119d32f73db661a2010a7501a5d52b Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Fri, 13 Mar 2026 01:06:36 +0800 Subject: [PATCH 28/29] fix(tui): format uptime header with clock units Signed-off-by: Chen Miao --- src/ui/tui/render.rs | 30 ++++++++++++++++++++++++++++-- src/ui/tui/tests.rs | 17 +++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/ui/tui/render.rs b/src/ui/tui/render.rs index 0d18be7..d5ce6f2 100644 --- a/src/ui/tui/render.rs +++ b/src/ui/tui/render.rs @@ -46,7 +46,7 @@ pub(super) fn draw( ]) .split(frame.area()); - let uptime = state.started_at.elapsed().as_secs(); + let uptime_label = format_uptime_label(state.started_at.elapsed().as_secs()); let sync_progress_text = state .background_sync_progress_text() .map(|value| sanitize_inline_ui_text(&value)); @@ -106,7 +106,7 @@ pub(super) fn draw( ), Span::styled(" | ", Style::default().fg(Color::White).bg(HEADER_BG)), Span::styled( - format!("up {}s", uptime), + format!("up {uptime_label}"), Style::default() .fg(Color::White) .bg(HEADER_BG) @@ -216,6 +216,20 @@ pub(super) fn draw( } } +fn format_uptime_label(uptime_secs: u64) -> String { + let hours = uptime_secs / 3_600; + let minutes = (uptime_secs % 3_600) / 60; + let seconds = uptime_secs % 60; + + if hours > 0 { + return format!("{hours:02}h:{minutes:02}m:{seconds:02}s"); + } + if minutes > 0 { + return format!("{minutes:02}m:{seconds:02}s"); + } + format!("{seconds}s") +} + fn footer_status_text(status: &str) -> Option { let sanitized = sanitize_inline_ui_text(status); if sanitized.trim().is_empty() { @@ -1594,3 +1608,15 @@ pub(super) fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect horizontal[1] } + +#[cfg(test)] +mod tests { + use super::format_uptime_label; + + #[test] + fn uptime_label_uses_the_largest_needed_unit() { + assert_eq!(format_uptime_label(59), "59s"); + assert_eq!(format_uptime_label(61), "01m:01s"); + assert_eq!(format_uptime_label(3_661), "01h:01m:01s"); + } +} diff --git a/src/ui/tui/tests.rs b/src/ui/tui/tests.rs index 51e99a1..624c5c4 100644 --- a/src/ui/tui/tests.rs +++ b/src/ui/tui/tests.rs @@ -4633,6 +4633,23 @@ fn header_shows_custom_keymap_scheme_when_configured() { assert!(rendered.contains("keymap custom")); } +#[test] +fn header_formats_uptime_with_clock_units() { + let runtime = test_runtime(); + let bootstrap = test_bootstrap(&runtime); + let mut state = AppState::new(vec![], runtime.clone()); + state.started_at = Instant::now() - Duration::from_secs(61); + + let mut terminal = Terminal::new(TestBackend::new(140, 35)).expect("create test terminal"); + terminal + .draw(|frame| draw(frame, &state, &runtime, &bootstrap)) + .expect("draw formatted uptime header"); + let rendered = format!("{}", terminal.backend()); + + assert!(rendered.contains("up 01m:")); + assert!(!rendered.contains("up 61s")); +} + #[test] fn startup_sync_progress_bar_renders_at_right_edge_of_header() { let runtime = test_runtime_in(PathBuf::from("/t")); From 84bb77a81cafb95aa40f991b98a4e866d5228336 Mon Sep 17 00:00:00 2001 From: Chen Miao Date: Fri, 13 Mar 2026 01:06:43 +0800 Subject: [PATCH 29/29] chore(release): prepare v0.0.2 - bump the crate and lockfile version to 0.0.2 - note the current v0.0.2 release in the README entry pages - update the pinned docs/wiki submodule commit for the release docs Signed-off-by: Chen Miao --- Cargo.lock | 2 +- Cargo.toml | 2 +- README-zh.md | 4 ++++ README.md | 4 ++++ docs/wiki | 2 +- 5 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8f2a51..893a4de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -354,7 +354,7 @@ dependencies = [ [[package]] name = "criew" -version = "0.0.1" +version = "0.0.2" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 8f1cdff..962da3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "criew" authors = ["Chen Miao"] -version = "0.0.1" +version = "0.0.2" edition = "2024" rust-version = "1.85" description = "Terminal-first Linux kernel patch mail workflow TUI" diff --git a/README-zh.md b/README-zh.md index a544614..187cf88 100644 --- a/README-zh.md +++ b/README-zh.md @@ -43,6 +43,10 @@ patch apply、 - [贡献流程](https://github.com/ChenMiaoi/CRIEW/wiki/Contribution) - [docs.rs API 文档](https://docs.rs/criew/) +## 当前版本 + +当前源码版本和发布 tag 为 `v0.0.2`。 + ## 发布基线 `v0.0.1` 是 CRIEW 第一版对外支持的发布基线。 diff --git a/README.md b/README.md index 3b123e9..5053169 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ or reply sending. - [Contribution](https://github.com/ChenMiaoi/CRIEW/wiki/Contribution) - [API docs on docs.rs](https://docs.rs/criew/) +## Current Release + +The current source version and release tag are `v0.0.2`. + ## Release Baseline `v0.0.1` is the first supported public baseline for CRIEW. diff --git a/docs/wiki b/docs/wiki index aa5b0b8..bc88703 160000 --- a/docs/wiki +++ b/docs/wiki @@ -1 +1 @@ -Subproject commit aa5b0b8d2b401e37759585e642d69925825cd71c +Subproject commit bc887038f5fe971fc0082eb1c3a5744a46893e03