文章

留白 0.6.7 开发复盘:全文搜索、双链和取消跳转

· 17 分钟阅读 · 本文以中文撰写

留白原来的搜索框,搜的是标题和摘要。

摘要由后端生成:跳过文稿开头的属性区,取正文前 4096 字节,合并空白,再截成最多 90 个字符。这个长度适合放在侧栏里,方便认出一篇文稿。拿它做搜索,范围就小得多。一篇文章明明写过某个词,只要标题和这段摘要里没有,结果就是空的。

0.6.7 把搜索范围扩到了整篇文稿。记得写过的文字,应该能找到。

同一篇示意文稿的搜索范围对比:旧版只匹配标题和摘要,摘要先取正文前 4096 字节再截成最多 90 字符,文末关键词未命中;新版搜索完整 Markdown,返回命中片段与行号,点击回到原文

留白是我做的 Markdown 写作应用,Go 后端,TypeScript 和 CodeMirror 6 前端,用 MyGo 打包成桌面应用。0.6.7 在 10 月 29 日发布,除了全文搜索,还加了标签筛选、双链与反向链接、浮动查找替换、独立设置窗口和随手记。这一轮实现主要交给 Claude Code,我决定范围、看结果,遇到不对的地方继续改。

搜到以后,能回到那一段

新版搜索读完整 Markdown,连代码块、链接地址和文稿开头的属性也搜。写作时,可能只记得某段代码里的名字,或者一个网址的一部分;把它们提前剔掉,还是会遇到“我写过,怎么没有”的问题。

匹配规则先定得很窄:输入一段连续文字,就找这段文字,忽略大小写。输入 .*,查找的也是这两个字符。中文可以只搜一个字。

这里没有加分词、拼音或语义搜索。那些能力各自解决别的问题,而旧搜索最明确的缺陷是范围不完整。先把全文搜到,才有必要看它还缺什么。

结果也不能只列标题。搜一篇几万字的稿子,点开后停在开头,接下来仍然要自己找一遍。所以每篇结果带上下文和行号,点片段就回到原文那一段。标签可以一起用,先缩小文稿范围,再按文字找。

搜索结果留在侧栏时,稿子可能还在改。结果里的位置属于查询时的文稿,点击时要重新检查版本和命中的原文,不能拿旧偏移直接跳。版本不符就提示结果已变化、重新查询;找到的文稿可以打开,失效的位置不能硬用。

“搜到”和“打开”因此分成了两步。查询只读,打开文稿才进入已有的保存、外部修改和冲突处理流程。正在编辑的文字也不会为了查询被强制保存;自动保存还没成功时,结果区会说明尚未包含这些修改。

扫描第一版跑超了预算

全文搜索很容易让人想到建索引。我这次先选了直接扫描,因为后端已经持有登记文稿的正文,查询可以从那里取只读快照,不必再建立一份搜索数据。

计划里也比较了另外两种做法。放到 Web Worker 里,需要先把文稿传给前端,再处理更新、删除和重载。留在 Go 里建索引,则要把这些操作接进索引的更新流程。尤其搜索允许任意中文子串,用按词建立的索引不能直接替代它。

我先给扫描定了一个预算:大工作区一次后端搜索不超过 250 ms。跑不过,再回头看耗时花在哪里。

数据集约 18 MB,里面特意放了一篇 8 MiB 的长稿。搜索词在文尾,第一版三轮均值的中位数是 298.3 ms,超了预算。

CPU profile 里,正则匹配占了约 87% 的累计时间。原来用正则在整篇正文上寻找忽略大小写的字面匹配,长稿文尾的查询要一路扫过去。改成先过滤可能的首字符,只在这些位置确认整个字符串是否匹配,三轮均值中位数降到 96.0 ms。确认时仍用锚定正则,大小写规则和命中范围没有改,测试把新旧匹配器的结果逐项对照。

扫描优化对比:同批 18 MB 文尾查询的三轮均值中位数从 298.3 ms 降到 96.0 ms,预算为 250 ms;字符扫描示意显示初版整段正则查找,优化后先过滤首字符,只在候选位置确认整个字符串,保持匹配规则与原文范围

后续和双链一起独占重测,同样的大工作区文尾搜索约 100.1 ms;1000 篇文稿的搜索从最后一次输入到结果显示,30 次查询的 P95 是 205.3 ms,包含 180 ms 防抖。

图中对比的是后端查询三轮均值的中位数。界面 P95 则是把 30 次操作按耗时排序,取第 29 个样本,包含等待和显示结果的时间,不能拿它和后端均值直接比较。

这些数字只支持当前测试规模。文稿更多、更大时,每次扫描的工作量仍会增长;最多显示 100 篇、每篇 3 个片段,只限制返回和渲染的量,不能让全库扫描凭空变少。以后如果这部分又超过预算,就要重新比较扫描和索引。现在加索引,还没有测量上的理由。

取消跳转后,为什么保存失败

搜索能找到文稿、能跳到命中位置以后,提交前的审查发现了一个更麻烦的问题。

从空白草稿点搜索结果,后端先激活目标文稿,前端等返回结果再换编辑器。如果等待期间开始中文输入,前端会中止这次跳转,避免打断正在输入的内容。

界面看上去处理对了:还停在原来的空白稿,提示先完成输入。

但后端已经切走了。原有的文稿切换逻辑还会顺手清理空白草稿——正常切换时,这是为了不让侧栏堆满没写过的空稿。这条旧逻辑被搜索导航复用后,前端取消了跳转,后端却已经把原稿移走。接着在留在屏幕上的原稿里输入,保存就失败。

这个 bug 需要卡在两件事之间才能复现:后端执行了切换,前端还没收到响应。

回归测试让请求先经过真实 Go API 执行,只扣住响应;确认后端当前文稿已变,再在浏览器里合成输入开始事件,最后放行响应。这样才会进入出问题的时序。若只在前端模拟一次返回,后端切换和清理原稿的行为就被跳过了。

修复有两部分。搜索导航用单独的激活入口,切换时保留原来的空白草稿;前端发现跳转需要中止,就再调用后端,把当前文稿恢复到原稿。

取消搜索跳转的前后端时序:修复前后端激活目标 B 并清理空白稿 A,输入期间前端中止跳转却仍显示 A,继续输入保存失败;修复后 ActivateSearch 保留 A,取消时再激活 A 恢复后端当前文稿,随后输入、保存和重载通过

测试也继续往下走:取消后输入“取消之后仍能保存”,检查自动保存成功、后端当前 ID 和正文正确,再重新加载页面,确认文字还在。只检查标题没变、编辑器没换,抓不住这次问题。

这里用浏览器合成输入事件触发取消,覆盖了请求等待期间的状态变化,原生输入法仍要单独验收。

同一个双链,三个解析器都得认

双链复用了搜索的文稿定位和导航流程。写 [[文稿名]],在编辑器里按住 ⌘/Ctrl 点击,阅读和分栏模式直接点击;侧栏的反向链接则列出哪些文稿引用了当前稿子,点片段回到引用处。

识别双链还要看它出现在哪里。代码示例里可以有 [[目标]],公式里可以有,普通 Markdown 链接也可能用双括号做标签,不能全都当成引用。

比如这段:

[[标签]](https://example.com)

它已经是一条普通链接。若先把 [[标签]] 认成双链,就抢走了原本属于 Markdown 链接的内容。代码块里的同样文字也只能是代码。

留白还有三处需要理解这些规则:Go 后端用 Goldmark 找反链,CodeMirror 里的 Lezer 决定编辑器中哪些文字能点,阅读预览用 markdown-it。只修好其中一处,就可能出现阅读里能跳、侧栏却找不到反链的情况。

做法是维护一份共享样本,每条列出原文和应该识别到的引用,三个解析器都跑这份数据。正文、列表、表格、脚注里的引用要识别;代码、公式、属性区、转义和普通链接里的要排除。

双链一致性示意:60 条原文和期望引用样本同时约束编辑器 Lezer、Go Goldmark 和预览 markdown-it;正文独立引用应识别,行内代码和普通链接应排除,导出保留原文且不带内部跳转地址

样本从 46 条补到 49、51,发布前到了 60 条。新增的内容里,有文稿开头属性区在哪一行结束、裸 URL 中的双括号是否算引用。前后端对属性结束行理解不同,后面的同一段文字就会落到不同上下文里。末端的点击处理写得再仔细,也补不了这个分歧。

还有同名文稿的问题。[[周记]] 可能对应两篇标题都叫“周记”的稿子,不能默认打开第一篇,得让用户选。反链也不能猜这条引用属于其中哪一篇。

导出则沿用原文。文稿交给别人后,对方并没有我的本地工作区,应用内部的跳转地址也没有意义。因此 Word、HTML 和富文本里保留 [[文稿名]],不把 liubai-wiki: 内部协议带出去。这里分别检查了导出 DOM 和 Word 的 OOXML,确认双括号文字还在,内部地址没有变成超链接。

新功能接到旧行为上

随手记把文字追加到原稿末尾后,文内查找还显示旧的“未找到”。正文已经更新,查找计数没有跟着刷新。修复后同一条回归显示 0 / 1,同时保留当前选区和撤销历史;追加的内容不想要了,仍然能原地撤销。

阅读模式里的查找更绕一点。Markdown 源码有软换行,显示时可能合成一行;连续空格会折叠,代码和不换行空格又要保留。用源码里的偏移去画阅读视图的高亮,位置就可能错。这部分按实际显示的正文匹配,再记录对应的 DOM 端点。23 个 Chrome 专项检查里,修复前有 10 个失败,检查的不只是匹配数量,还包括高亮落在哪里。

独立设置窗口的第一版则是另一类返工。按文字描述做出来以后,主题卡片、色条和选中状态都和我记得的旧版不一样。我找出旧截图,要求照着重做。测试能检查某个主题是否被选中,这张参考图才把我要的样子说清楚。

打包后还要核对

代码回归结束后,我中途喊停过一次发布,把安装包和更新包的校验补齐。前面的测试跑的是源码和测试构建,用户拿到的是打包后的应用,还要检查它们是不是同一份东西。

这次把 App 内的 156 个前端资源和提交的构建逐文件比对哈希;三个平台从 0.6.4、0.6.5、0.6.6 升级的增量路径,也检查了对应更新文件的签名和哈希。macOS 安装包完成签名与公证;准备发布时,Windows 的两个安装包还没有真机安装验证,发布说明里保留了内测状态。

双链的 [[ 自动补全排在后面。0.6.7 可以手写引用、点击跳转;输入标题时搜索候选文稿并选择,还要继续做。

继续阅读

相关文章

讨论

审核后公开 · 支持 Markdown

读到这里时加载讨论。