Skip to content

EPUB 全书搜索 + ⌘F 桥 + 目录过滤(B7)—— 交接稿(as-built)

物理仓库位置~/reading-browser(ReadBrowser 桌面端,Tauri + React + Rust) 2026-08-08 功能收口;2026-08-12 验证矩阵跑完 + §7.2 结案(见 §5.5 与「已结案」小节)。 范围 = backlog §🔴 EPUB 长期阅读7 条(B7-1 / B7-2 / B7-3)。 零 migration,schema.sql 未动。 前置:epub-toc-quality-handoff.md(B' + A/C/D)· epub-anchor-precision-handoff.md(B5/B6)


1. 改了什么

文件改动
src-tauri/src/commands/epub/search.rs新模块search_epub_fullbook 命令 + visible_runs / search_html / build_hit+10 条单测
src-tauri/src/commands/epub/mod.rspub mod search;;修 EpubChapter.href错误文档注释(见 §3①)
src-tauri/src/commands/epub/nav.rsdecode_entities 提为 pub(super)search.rs 复用
src-tauri/src/commands/webview.rs新命令 report_find_shortcut(emit + main.set_focus()
src-tauri/src/lib.rs注册两个新命令
src-tauri/src/content-script/events.js⌘F keydown 桥(capture + preventDefault → invoke)
src-tauri/src/content-script/features/find.jsfindInPage(query, opts) 支持 {scroll, activeIndex}
src/components/tools/FindMode.tsx258 → 491 行:作用域切换 / 全书结果列表 / replayAfterNav / splitSnippet
src/components/tools/ToolsPanel.tsx · RightPanel.tsx透传 onEpubNavigate
src/MainApp.tsxfind-shortcut listener;EPUB 导航后置钩子调 __rb_replayFindAfterNav
src/components/TocPanel.tsx过滤框 + 过滤态接管可见性
src/lib/epubToc.tsfilteredTocRows()
src/lib/commands.tssearchEpubFullbook + 3 个类型
src/lib/strings/{panels,locales/{en,zh}/panels}/{find,reader}.ts10 + 4 条新 key(双语齐备)

pnpm build:cs 已跑(content-script 是 include_str! 进 Rust 的打包产物)。


2. 关键设计决定

① 计数刻意是两段:「本章 i/j · 全书 M」,不做全书连续编号

j 来自页内 find.jscollectRanges 走渲染后 DOM 的 TreeWalker), M 来自 search.rs(扫磁盘 HTML)。两套实现,不可能恒等

  • 页面渲染后 content-script 会把生词/短语包进 <span>replaceChild),把原本一个 text node 劈成几个 → 「跨越某个被高亮词边界」的查询页内找不到、磁盘侧找得到;
  • 反过来这也是页内自身不稳定的地方 —— refreshFindRanges 存在的理由就是高亮会打碎 Range。

所以 UI 不宣称 M 含 j,跳转落点对不齐时退化成「落在附近那一处」(页内 activeIndex 有 clamp), 不报错。全书连续编号则会逼出「next 走到章末必须触发一次真实导航才能续上」,翻页时机不可预期。

② 磁盘侧刻意复刻 find.js 的三条语义

否则 ordinal 对不上、点结果跳错位置:

  1. 大小写不敏感;
  2. 跳过 <script> / <style>(同 visible_text_len);
  3. 匹配不跨文本节点 —— find.js 是逐 text node 做 indexOf 的,Ro<em>me</em> 它找不到。 visible_runs 因此按标签切成 run,只在 run 内部搜;run 之间用单个空格拼进 joined (片段从 joined 取,跨标签的上下文也读得通),空格分隔顺带成了「匹配不跨 run」的物理保证。

单测 does_not_match_across_tag_boundaries / joined_text_does_not_create_cross_run_matches 锁住这两侧。

③ 跨章跳转走「主侧导航后置钩子」,不在页内注入 pending query

选它而非 __RB_PENDING_FIND 式注入的理由:

  • query 的唯一真相源留在 FindMode 的 state 里。注入式会在页内多存一份。
  • MainAppnavigation-state-changed 已经是全仓唯一「页面加载完了」的落点, review-source__rb_reviewRecall / __rb_reviewReveal 走的就是同一拍 —— 有现成先例。
  • 主侧不需要知道查询词:它只喊一声 __rb_replayFindAfterNav(), 由 FindMode 自己决定这次要不要抢滚动位置。

两条分支的区别就是要不要抢滚动位置

  • pendingJumpRef(用户点的是搜索结果)→ {scroll:true, activeIndex:ordinal},跳转本身就是诉求;
  • 无(用户自己翻的章,只是面板恰好开着)→ {scroll:false},只重绘。 否则读者点「下一章」会被甩到该章第一处命中上。

replay 用轮询而非直接调:新页的 __rb_findInPage 可能还没注册 (同 MainApp 里「onPageReady may have run before this eval landed」那条既有竞态), 直接 eval 会静默落空。1s 窗口内每 50ms 试一次。

④ ⌘F 桥的要害是 set_focus,不是 emit

与 zoom 桥(report_zoom_shortcut)同族,但多一步app.get_webview("main").set_focus()。 只 emit 的话面板会开,但原生 key 焦点仍在 content webview 上,FindMode 里那句 inputRef.focus() 只拿到 DOM 焦点 —— 敲下去的字符照旧被正文吃掉,面板开了却打不了字, 比完全不响应更费解。顺序:先夺焦点再 emit(FindMode 自动聚焦有 50ms 延时,接得住)。

⑤ 过滤态接管可见性,忽略折叠态

反面做法是让折叠继续生效 —— 那样一个折起来的组会把命中项藏掉,用户看到「没有匹配」 但其实匹配上了,是最难自我排查的一类假象collapsed 本身不动,清空过滤词后原样恢复。 过滤态下组头一律渲染等宽占位而非箭头:那时点箭头不会有任何变化(后代已被过滤掉), 留着就是个骗人的控件。

保留祖先链、不放出后代:三层书(Romance 8/5/34)里搜出一条 level 2 的章, 不给祖先就看不出它属于哪个 Part;反过来命中一个组头就把下面几十章全放出来,那面墙就又回来了。


3. 两个开发期真 bug(都是实机才暴露,单测抓不到)

EpubChapter.href 自带 cache_base 前缀,而文档注释是错的

rust
pub href: String, // path relative to cache_base, e.g. "OEBPS/Text/ch1.xhtml"   ← 错

实际 parse_opf 里是 format!("{}/{}", cache_base, ...),真实形状是 epub/{hash}/OEBPS/ch1.xhtml。同文件的 read_toc_src 早就写对了(lib.join(cache_href))。

照注释又 join 一次 → .../epub/{hash}/epub/{hash}/... → 路径不存在 → 被 continue 静默吞掉全书总数恒为 0。单测测的是 search_html(html, ...) 算法本身,路径拼接根本没进测试。

已修lib.join(&href) 一次;注释改成正确描述并写明它坑过谁; 顺手把闲置的 cache_base 参数转成归属闸hrefs 来自前端 = 不可信输入, 必须 starts_with(cache_base) 且不含 ..,否则能把 library 下任意文件读出来当搜索结果)。 回归测试 real_chapter_href_shape_is_accepted / out_of_book_hrefs_are_rejected

② 片段加粗用 String.slice() → 多字节错位

Rust 给的 match_start / match_len字符数,JS sliceUTF-16 code unit。 BMP 内一致,遇到 emoji / 增补平面汉字(1 字符 = 2 code unit)就会加粗错位置。 已修splitSnippet()[...snippet] 展开成 code point 数组再切。


4. 实测结论(全部经 /debug 探针断言,非看屏幕)

先在磁盘上独立算出真值(Python 复刻 Rust 的 run 切分语义),再拿 app 的数字去对:

章节文件Rome
h-056
h-1104
h-2 / h-3 / h-5 / h-6 / h-7 / h-87 / 5 / 1 / 1 / 1 / 13
全书合计188(8 个文件有命中)

56 与用户界面上原本那个误导性的「共 56 处」严丝合缝,188 与 backlog 记录一致。

B7-2 ⌘F 桥

条目证据
面板打开rightPanel = tools / toolsMode = find
焦点真的抢到了<input ... value="Rome"> —— 用户点正文后按 ⌘F 直接打字,字进了输入框

B7-1 全书搜索

条目证据
计数行本章 1/56 · 全书 188 处 —— 与磁盘真值逐项吻合
分组数[aria-expanded="true"] 8 个 = 磁盘 8 个有命中文件
结果行 / 加粗[aria-label="跳转到该处"] 188 · mark 188(188 < 配额 200,未截断)
片段质量…mities of the world, and especially the sack of **Rome** by the Goths… —— <mark> 精确包住 Rome,两端带省略号
首组标题THE WORKS OF AURELIUS AUGUSTINE,(56)—— chapterLabel() 取该文件第一条 TOC 条目,符合设计
跨章跳转FOOTNOTES:(13)组首条 → url h-0h-8epubCurrentIdx 9、计数变 本章 1/13 · 全书 188 处

跨章那个 13 是新章的 content-script 自己 collectRanges 后经 report_find_matches 报回来的 —— 等于直接证明了新页高亮真的重新贴上了,__rb_replayFindAfterNav 那条回路(含轮询)工作正常。

B7-3 目录过滤(The City of God,35 条)

动作结果
过滤 BOOK35 → 15 行,提示 15 / 35 条
祖先链T. & T. CLARK, EDINBURGH.(pad=8,自身不含 BOOK)作为 SUBSCRIPTION BOOKS…(pad=20)的上下文出现
过滤态箭头折叠/展开 aria 各 0(换成等宽占位)
清空恢复35 行 + 13 个折叠箭头复现(折叠态未被破坏)
零命中没有匹配的目录项,行数 0

扁平长目录(Moby Dick,146 条里 141 条 level 0 —— B7-3 的正牌动因): 过滤 whale146 → 15 行,提示 15 / 146 条。 独立数 toc.ncx:146 个 <navPoint>,含 whale(忽略大小写)的正好 15 条 —— 精确一致。 本书近乎全平、几乎无祖先可补,所以「命中数 == 可见行数」,正好把扁平路径单独验了一遍 (对照 City of God 那组:15 行里有 1 行是祖先上下文,命中其实只有 14 条)。 那面墙确实塌了:一屏看不完的 146 条 → 15 条。

收口检查

cargo check ✅ · cargo test --lib 147 passed(145 +2 归属闸)· pnpm build ✅ · pnpm test:run 159 passed(147 → 159,+12 filteredTocRows)· /arch-check 硬规则 H1-H6 全过;改动文件上 S2/S8/S11-S16 = 0; S19/S22/S23 三件套 = 0(831 keys,MISSING 0 / EQUAL 0);S20 两新命令均被引用; S24=7 / S25=0 / S26=3/3 / S27=3 全持平基线 · /ui-check U1=2(永久豁免持平)· U2=U3=0 · 改了 content-script → 已 pnpm build:cs


5. 留下的东西

同轮追加已做(2026-08-08 第二次提交)

  • FindMode:全书结果列表抽到 tools/FullBookResults.tsx(105 行,纯呈现、不持状态; splitSnippetMIN_BOOK_QUERY 一并搬过去)。「点了怎么跳」刻意留在 FindMode —— 同章就地跳 / 跨章先导航再由 replayAfterNav 接上,那是状态逻辑不是呈现逻辑。 顺手把 prev/next 两颗按钮收进本地 StepButton(同一串 6 行 className 原本抄了两遍)。 491 → 449 行(⚠️ 上一版这里写"抽完回到 ~330 行"是估错了,实际 449 —— 剩下的都是组件自身内聚的 state / callback / effect,再切就是为拆而拆)。
  • ncx 实体解码(backlog §7.1,走方案 A = 展示层)epubToc.ts 新增 decodeTocEntities 并接进 cleanTocLabel。落点选它是因为 cleanTocLabel 的三个调用方 (TocPanel 渲染 / FindMode 分组名 / filteredTocRows 匹配)全是展示路径、无写入路径, 于是一处改动三处同时生效,且 reading_pages.title 一个字节都不动。 语义对齐 Rust nav.rs::decode_entities(同一套命名实体 + 十进制/十六进制数字实体, 不认识的原样保留)。刻意不走 innerHTML 借浏览器解析 —— 那等于把目录标签当 HTML 执行。 连带修好:搜 T & T 此前零命中(标签里是字面量 &amp;),现在能搜到。+6 条单测

    代价(已知并接受)reading_pages.title仍然存着 &amp;,即笔记/来源页标题 不会变干净。彻底修需要源头解码 + 一次性存量标题迁移 —— 该等哪天有别的理由必须迁移 标题时搭车做,为这个低频缺陷单独动存量数据不划算(City of God 35 条目录只命中 1 条)。

用户手验捞出的 6 条(2026-08-08 第三/四次提交)

前两条是 B7-1 引入的回归,其余是设计缺口。全部已修。

  1. 计数频繁跳回「本章 1/56」 —— replayAfterNav 挂在 navigation-state-changed 上, 但同一页也会反复触发:滚动联动(6.6 的 D)滚过章节锚点 → writeFragmenthistory.replaceState → tracking.js 的 patch → reportNavigationState。 而 findNext 自身就会滚动页面 —— 等于自己踩自己,「下一个匹配」根本走不下去。 修法MainApp 按 url 的 **base(剥 fragment)**比对 lastEpubDocRef, 只在 xhtml 文件真的换了时才喊重放。
  2. 跳过去正文没黄色高亮,要等生词高亮跑完再点一次 —— refreshFindRanges 的守卫是 !state.find.active || !query return;跨章重放正好撞上正文还没进 DOM 的那一刻 → 收集到 0 处 → active 停在 false → 此后每次都直接 return,再也不会自愈。 这其实是个早就存在的潜伏 bug(页面加载中搜索就会中招),只是跨章重放把它稳定触发了。 修法:守卫只看 query,0 处时显式置 active=false;之后 highlight-queue 每跑完 一个 job 都会重试,内容到位那一刻自动补上。
  3. 仍要等 vocab 跑完才见黄色(延迟) —— drain() 抢占不了正在跑的 job,而 vocab / discovery 各是一个整块的全 DOM 遍历,find 优先级再高也得排在后面。 修法:给 findInPageimmediate —— 跨章重放绕开队列当场画。 为什么安全:队列存在的理由是串行化 DOM 改动(vocab 靠 replaceChild), 而 find 走 CSS Custom Highlight API 一个 DOM 节点都不碰,本来就没有必须排队的 物理理由;排队原本只为①连打 dedup ②读一致的 state.find键盘输入仍走队列(那里的 dedup 是必需的),只有重放这一条走直连。
  4. 输入单字符仍满屏涂黄 —— 一边显示「请至少输入 2 个字符」一边画 9958 条 Range。 修法:门槛提到 src/lib/findQuery.ts::meetsMinQueryLen两个作用域同一把尺

    ⚠️ 不是简单的 length >= 2:一个汉字往往就是一个完整的词(搜「城」「神」 完全合理),一刀切会直接废掉中文查找 —— 而本 app 既能读中文 EPUB 也能查中文网页。 真正的噪声源是拉丁单字母。所以规则是「长度够 2 放行;只有 1 个字符时含 CJK 才放行」。 Rust 侧 meets_min_query_len 必须同步(两端各一份实现,不一致的症状是 「面板说能搜、结果永远 0 处」),两边用同一组单测用例锁住。

  5. 点全书反应慢 —— 全书扫盘与页内 find 共用 200ms 防抖,打一个 Rome 就扫 4 遍全书。 拆成两条:页内 200ms / 全书 500ms;关面板时把 bookTimer 一并掐掉。
  6. check:no-inline-zh 误报单测夹具 —— findQuery.test.ts'城' 验 CJK 判定, 被守门脚本当成"内联 UI 中文"。是脚本的真实盲区(此前没有测试文件带中文字面量): 该规则管的是会渲染给用户的文案,测试夹具永远不进界面。已给脚本加 *.test.ts 排除, 并反向验证过排除不过宽(非测试文件里的中文仍会被抓)。

✅ 已结案:旧查询词的高亮偶尔不消失(2026-08-12 根因 + 修复 + 复验)

根因不是状态,是像素 —— WKWebView 在 CSS.highlights.delete()不把原先画过的区域标脏, 注册表已空、计数行已对,但屏幕上的黄色要等别的东西触发一次重绘才会消失。

复现配方(2026-08-12 一次即中,此前一整轮抓不到是因为配方不对): 搜一个高频词(City of God h-3 搜 the = 2785 处)→ 立刻改成0 命中的词(zzqq)。

证据面修复前
_diag 探针afterWipe hlKeys=[] · done ranges=0 —— 状态完全干净
面板计数行「无匹配」、上/下一个按钮置灰
截图(0.8s 后)the 的黄色一处不少
截图(+8s,其间无任何操作)自己消失了 —— 与用户报的「浮现几秒后恢复」完全吻合

为什么之前三条状态假设全是 ❌、而且间歇 ——它们查的都是状态,状态一直是对的。 只要有任何东西顺带触发重绘就看不见这个 bug:新查询画上了高亮、scrollActiveIntoView 真的滚了、后续 vocab job 改了 DOM、关面板让 content webview 改了宽度…… 只有「什么都没发生」时才露出来 —— 新词 0 命中,或首个命中已在视口内导致 scrollIntoView 不移动。 用户那次 thevital共 2 处)正是后一种。

修法find.js::_forceHighlightRepaint,在 _wipeAllHighlights 末尾、且只在真的删掉过东西时调): 把 <html>opacity 设成 0.9999跨帧(rAF + 100ms timer 兜底)再还原。

  • 必须跨帧:同一拍里设完又改回会被样式重算合并掉,等于没变、根本不会重绘。
  • 0.9999 肉眼不可辨;CSS.highlights.size === 0 时直接跳过,不给每次按键白加一次样式重算。
  • 红线 #4(改 DOM 前停 MutationObserver)不适用:observer 挂在 document.body 且只听 {childList, subtree},而这里动的是 <html> 的 style 属性(代码里已写明这条推理)。

复验:同一本书同一章同一配方,修后 0.8s 截图干净,而同轮「搜 the」的截图证明黄色确实画上过 (2785 处)。加 timer 兜底后又重跑一遍,结果相同。

探针 _diag 已连同 5 个埋点一起删除(结案即删,见 v20/v25 phrase_interaction_log 教训)。

两条附带确凿数据(仍然有效)

  • find 执行本身是 6-15ms(本轮复测 8-16ms),不是「点下一个卡顿」的原因 —— 去看 smooth scroll 动画与 vocab job 抢主线程。
  • 中间态会造成视觉误判:搜到一半的 vi 命中 38 处,看起来很像旧高亮没清。 判据是计数行与涂色是否一致

已排除的假线索:日志里第一次 eval 收到 t'h(带撇号)、0 命中,400ms 后才是正确的 the。一度怀疑 esc()evalInWebview 途中破坏查询词 —— 用户已确认是拼音输入法的中间态 (敲拼音时输入框真的短暂含撇号)。不是 bug,别再追。 附带启示:中文输入法下 handleInput 会收到拼音中间态并真的去搜一次。目前只是白跑一次 页内 find(200ms 防抖内,很便宜),不值得为它引入 compositionstart/end 处理; 但全书搜索若将来把防抖调小,这条会变成真实浪费(每个拼音中间态扫一遍全书)。

未做(刻意)

  • A1「⌘F 后看不见查找面板,另一个 tab 的网页内容盖在面板位置上」 —— 2026-08-08 首见、 2026-08-12 用户再次撞到并给了截图(露出来的是 TIME for Kids 那个非活动 tab 的页面), 但当场受控复现失败:同一本书、同样 ⌘F、面板正常,三个量全正常 (content-1 稳在 x=-20000;content-2 x=744 w=2136x=168 w=2072, 目录收起 +576 / 面板占走 640,算术分毫不差)。故仍未结案。 牵扯原生 webview 层级(content webview 是 main 的原生兄弟层,永远在上), 至少三种可能:面板打开后 ResizeObserver 没及时重摆 / set_focus 影响 z-order / 非活动 tab 的 webview 没藏干净。不要猜着加规则

    下次一次探测就能定性GET /debug/snapshotcontent_tabs[] 现在每个都带 bounds{x,y,w,h}(物理像素)(2026-08-12 为这条加的)。藏起来的 tab 恒在 -20000,-20000(= hide_content_webview 的 -10000 逻辑像素 ×2)。 于是「该藏的没藏住」与「活动 webview 被摆错位置」当场可分,不必再靠截图印象。 与既有记录「原生焦点卡在 content webview → 主侧快捷键哑火」大概率同族。

  • B4 章内偏移(需新列 → 迁移从 v32 起,独立立项)、B8B9

已知近似 / 边界

  • 全书片段配额 MAX_SNIPPETS_TOTAL = 200:命中总数永远精确,只是不把上万条片段搬过 IPC。 超配额时 truncated=true,UI 提示「片段仅显示前 N 条,计数仍是全书真实数」。 实测 Rome 188 < 200 未触发 —— 这条分支还没被真机跑过,下次拿高频词(如 the)验一次。
  • 全书搜索最短 2 字符MIN_QUERY_LEN,Rust 与前端 MIN_BOOK_QUERY 两处必须一致)。 页内 ⌘F 不受此限。
  • chapterLabel() 取该 xhtml 的第一条 TOC 条目作为分组名。一个文件住多条目录条目时 (实测 h-0 有 7 条)分组名会偏前 —— 与 resolveTocEntry 的同文件兜底同一口径,可接受。

5.5 ✅ 验证矩阵 —— 2026-08-12 全部跑完

图例:🤖 = /debug 探针断言 · 👤 = 用户肉眼验过 · 📷 = 截图为证 环境:dev app 单实例 + The City of God(35 条目录)/ Romance(47 条、三层)/ 一个网页 tab

A. ⌘F 桥

#验什么结果
A1点正文 → ⌘F → 面板弹出且光标能直接打字🤖👤 通过。但用户遇到过面板被另一 tab 的 webview 盖住 → 见 §未做 A1
A2先点主界面(地址栏)再 ⌘F —— 两条路径行为应一致🤖 通过:查找框 value='Rome'#url-input 原样是那个 epub 路径(字没落回地址栏)
A3网页 tab 里 ⌘F → 弹出且不出现「本章/全书」切换🤖 通过[aria-label="搜索范围"] found=false
A4面板内按 Esc → 关闭 + 页面黄色高亮清干净🤖👤📷 通过(Romance 359 处 → Esc → rightPanel:null + 截图无残留)。⚠️ 这条不受 §7.2 重绘 bug 影响是有原因的:关面板会改 content webview 宽度,resize 本身强制重绘
A5关面板的另外两条路径(点「页面工具」按钮 / 切「排版」)也应清高亮🤖📷 2026-08-12 补测 → 发现不清 → 已修(backlog §7.3)。FindMode 卸载即清;切「排版」那条不伴随 resize,等于连 §7.2 的强制重绘一起压测

B. 全书搜索

#验什么结果
B1「本章 i/j · 全书 M」计数正确🤖 通过(本章 56 / 全书 188,与磁盘独立计数逐项吻合)
B2连点「下一个匹配」计数稳定递增、不跳回 1/56🤖👤 通过:连点 8 次 1/7→7/7→回 1/7,期间滚动联动一直在改 url fragment 却没触发重放f0d1ed0 的 base 比对生效)
B3点跨章结果 → 跳章 + 落到该处 + 计数更新🤖👤 通过(h-0→h-8,本章 1/13 · 全书 188 处
B4跳过去后黄色高亮立刻出现(不等生词高亮)🤖 通过 —— 0776af8immediate 实证findInPage:enter(带 {scroll:true,activeIndex:0,immediate:true}16:48:56.965 → done .981(16ms)、ranges=13、hlKeys=[rb-find,rb-find-active];同页 phrase/vocab 管线 16:49:03.9 才跑完、drain 的 refresh:enter:04.342。即黄色比生词高亮早 7.4 秒
B5面板开着时切章:高亮重贴、计数更新,但页面不该被甩到该章第一处命中🤖 通过(走 ‹上一章,见下方 ⚠️):日志 {scroll:false,immediate:true}、ranges=1、计数 本章 1/1 · 全书 188;页面停在锚点 y=38162(该锚点在 h-7 的 85% 处)。对照组:随后点同章那条结果(scroll:true)→ y=8730,正是 Rome 在文件 21% 处的位置 —— 两分支泾渭分明
B6全书模式下「上一个/下一个」只在本章内循环、不跨章🤖 通过:8 次 next 全程停在 h-2,7/7 后回 1/7
B7折叠/展开某个章节分组,不影响其它组🤖 通过:折起 BOOK FIFTH(7) → 结果行 188→181、展开组 8→7、相邻 BOOK SIXTH 仍 aria-expanded=true;再点回来 8 组复原
B8改成不存在的词 → 「全书无匹配」🤖 通过:计数行 本章无匹配 · 全书 0 处 + 列表区 全书无匹配 + 结果行 0
B9输单个拉丁字母 → 不涂黄,只显示「请至少输入 2 个字符」🤖👤 通过
B10搜高频词触发片段配额 200 截断 → 出现「片段仅显示前 N 条」🤖 通过(该分支首次真机跑到):搜 the本章 1/2874 · 全书 26380 处、结果行正好 200、11 个分组、提示「片段仅显示前 200 条,计数仍是全书真实数」。磁盘独立复算 = 26380 / 11 个文件命中 / h-2 单章 2874 —— 三项全中(连页内 TreeWalker 与磁盘扫描在 2874 这个量级上都没差)

⚠️ B5 的原始表述(「用目录切章」)在当前 UI 下不可能发生 —— 左右辅助面板互斥usePanelStore.openLeftPanel/openTools 互相清空,CLAUDE.md §Zustand),开目录 = 关查找面板, FindMode 随之卸载、__rb_replayFindAfterNav 被注销。所以 scroll:false 分支的真实入口是 ‹ › 上一章/下一章AddressBar 的同一对按钮,EPUB tab 上走的正是 onTocNavigate, 与目录点击同一条导航路径),本次即以它验证。

C. 目录过滤

#验什么结果
C1Moby Dick 输 whale → 146 → 15 行 + 15 / 146 条🤖 通过(与 ncx 独立计数一致)
C2清空过滤词 → 回全量 + 折叠箭头回来🤖 通过(35 行 + 13 箭头)
C3先折叠某组 → 输过滤词 → 清空 → 原折叠态原样保留🤖 通过:折起 BOOK SECOND(35→34 行)→ 过滤 BOOK(15 行、0 箭头)→ 清空 → 回 34 行(不是 35)、aria-expanded=false 仍是 1 个、第 7 行仍是 BOOK THIRD(说明那条 ARGUMENT 子项还藏着)
C4过滤态下组头没有折叠箭头🤖 通过(折叠/展开 aria 各 0)
C5输乱码 → 「没有匹配的目录项」🤖 通过
C6三层嵌套书搜子章名 → 命中项 + 其上级 Part 一起显示🤖 通过:Romance 过滤 VIII → 恰 3 行 + 3 / 47 条,且三级祖先链完整Romance(pad 8) → Part IV: Blade and Guitar(pad 20) → VIII(pad 32)
C7过滤态下点某一行 → 正常跳章🤖 通过:过滤 SIXTH → 唯一行 → 点击 → url 到 h-3#pgepubid00243epubCurrentIdx 4
C8换一本书 → 过滤词自动清空🤖 通过:Romance 上留着 VIII → 切到 City of God tab → 输入框 value=''、35 行全量、x / y 条 提示消失(折叠态也一并复位,同 useEffect([items])

D. 回归(别让新功能弄坏旧的)

#验什么结果
D1目录高亮跟随滚动(6.6 的 D)🤖 通过:h-3 内滚动 → 活动行 BOOK SIXTH.ARGUMENT.;用「活动行的下一行 = BOOK EIGHTH.」确认命中的是第二个 ARGUMENT(idx 16)而非同名的 idx 14
D2切到另一本 EPUB 的 tab → 目录立刻高亮(6.7)🤖 通过:切过去 ~1s 内两本书各自有且只有 1 个活动行(City of God=BOOK SIXTH./Romance=II)
D3⌘+ / ⌘- / ⌘0 仍正常调字号(⌘F 桥不该影响 zoom 桥)🤖👤 通过:⌘+ ×2(焦点在查找框,走主侧 useKeyboardShortcuts)→ tab.zoom 1.0→1.25;⌘- + ⌘0(焦点在正文,走 content-script report_zoom_shortcut)→ 1.0 + domain_zoom 落库。⚠️ 中间有一轮 ⌘-/⌘0 完全没生效且没有任何状态写入(两条路径都没走到),复现不了——最可能是在终端/app 间来回切换后原生焦点没落在任何 webview 上,与 backlog「原生焦点卡在 content webview → 主侧快捷键哑火」同族
D4网页 tab 用 ⌘F → 单章逻辑照旧🤖 通过:计数是单章格式 第 1 / 共 69 处,无作用域切换器

6. 工作方式(沿用,已验证有效)

  • 用户负责"到达现场"(开书 / 按键),只做窄探针检查。给编号步骤,等回「好了」再批量探测。
  • 禁止 osascript 合成按键——本机对 dev app 不抵达,硬试会导致误诊。
  • 探针(注意是 $TMPDIR):
    bash
    P=$(cat "$TMPDIR/rb-debug-port")
    curl -s --get "http://127.0.0.1:$P/debug/dom" --data-urlencode 'selector=...' \
      --data-urlencode 'webview=main' | jq -c '.result'
  • 本轮新增的可复用探针 / 踩坑
    bash
    # 计数行(.tabular-nums 太泛,会撞上地址栏缩放药丸 110%)
    --data-urlencode 'selector=.text-sm.tabular-nums.text-text-secondary.px-1'
    # FindMode 整块
    --data-urlencode 'selector=.flex.flex-col.p-3.gap-3'
    # 全书结果:分组头 / 结果行 / 加粗
    --data-urlencode 'selector=[aria-expanded="true"]'      # 看 total_matches
    --data-urlencode 'selector=[aria-label="跳转到该处"]'
    --data-urlencode 'selector=mark'
    # 目录过滤框 / 空态(空态要用 p 限定,否则同样撞药丸)
    --data-urlencode 'selector=[aria-label="过滤目录"]'
    --data-urlencode 'selector=p.text-xs.text-text-secondary'
    # 打字
    curl -X POST ".../debug/type" -d '{"webview":"main","selector":"[aria-label=\"过滤目录\"]","text":"BOOK"}'
  • ⚠️ /debug/dom 只返回首个匹配document.querySelector),响应是对象不是数组: {found, outerHTML, tag, total_matches, truncated}数个数要读 total_matches, 别对 .result| length(会报 "Cannot index array with string",或数错)。
  • ⚠️ outerHTML 在 16384 字符截断truncated:true)。长列表逐项核对读不全 —— 改用 total_matches + 抽样。
  • ⚠️ /debug/* 的 JSON 可能含未转义控制字符(片段带原书换行),jq 会 parse error。 用 python3 -c "json.loads(raw, strict=False)" 宽松解析。
  • ⚠️ /debug/clickdocument.querySelector,支持任意 CSS 选择器 —— 可用 > div:last-child / :nth-child() 精确点到列表里某一项,省掉人工往返。
  • ⚠️ 改 Rust / content-script 会让 tauri dev 重编重启,tab 全清、debug port 变。 本轮为此白开了两次书。先把代码全部改完再请用户开书;改完 Rust 后务必重读端口文件。

⚠️ 环境卫生(本轮真踩到,代价是一小时机器卡死)

不要让用户手动跑 pnpm tauri dev —— 它本来就常驻并自己监听重编。本轮因此起了两套 (2 个 vite + 2 个 app),加上用户又从 Terminal 直接跑了一次 target/debug/app, 最终 3 个实例 + 4 个僵尸 rb-debug-mcp 把 16G 内存吃到只剩 63MB unused, 机器 swap 抖动到切不动窗口。症状极像"过热",实则不是 —— pmset -g therm 当时明确报 no thermal warning、CPU_Speed_Limit = 100、CPU 74% idle。

诊断三连(下次直接抄):

bash
pmset -g therm                      # 先排除过热:看 CPU_Speed_Limit 是不是 100
top -l 2 -n 5 -o cpu | grep PhysMem # 真正要看的是 "unused" 和 swapins/swapouts
ps -axo pid,ppid,command | grep "target/debug/app"   # ppid 认归属:tauri dev vs 裸 zsh
  • 多实例判据ppidtauri dev 的才是正牌;ppid-zsh 的是手工起的。
  • 端口文件会被最后启动的实例覆盖 → 探针可能打到空实例上(本轮就打到了 0 tab 的那个)。 多实例时显式写死正确端口,别信 $TMPDIR/rb-debug-port
  • 清理顺序:先 kill 父 tauri dev(否则它会把 app 重新拉起来),再 kill 子进程。