鸿蒙 PC Markdown 编辑器快速打开:模糊排序、最近权重与纯键盘路径
鸿蒙 PC Markdown 编辑器快速打开:模糊排序、最近权重与纯键盘路径
快速打开是桌面编辑器中频率极高的导航动作。用户通常记得文件名的一部分,未必记得目录,也不想先把手从键盘移到文件树。一个可用的快速打开需要在几次按键内完成入口、输入、候选选择、打开和焦点归还;与此同时,它不能把扫描工作放在 UI 线程,不能让旧查询覆盖新结果,也不能因“最近使用”权重把不相关文件推到前面。
OhMarkdown 的快速打开随工作区搜索进入公开仓库 https://gitcode.com/VON-/codex_md_oh,完成提交为2ca99e9。本文只讨论当前基于每轮授权目录枚举、TaskPool 模糊排序、最近标签权重和Ctrl+P键盘交互的实现。持久索引、拼音搜索、用户自定义排序和 1000 文件竞品测量仍未完成。
快速打开与全文搜索是不同任务
全文搜索读取文档正文,返回匹配行、列和上下文;快速打开只需要文件元数据,返回文件候选。两者可以共享安全目录枚举,但不应共享“查询为空”的规则、结果模型展示和触发频率。
快速打开允许空查询。空查询用于显示最近文件和工作区文件,用户按一次方向键即可选择。全文搜索空查询会匹配所有位置,没有意义且成本巨大,所以拒绝。快速打开输入时 160 ms 防抖自动刷新,全文搜索通常由 Enter 或按钮显式触发。
界面把 Current、Workspace、Quick Open 做成三段式模式。每种模式保存自己的查询,切换时取消旧任务、清理结果和序号。这样用户从全文搜索切到快速打开后,不会在输入框看到语义不同的旧正则。
目录事实与全文搜索共享
WorkspaceSearchController.quickOpen调用同一个collectDocuments,所以继承授权根、符号链接跳过、目录排除、文本扩展白名单和 5000 文档上限。快速打开不会为了速度绕过安全枚举,也不会显示图片资源、.git对象或node_modules里的 Markdown。
asyncquickOpen(rootUri:string,query:string,recentUris:Array<string>):Promise<WorkspaceSearchSummary>{this.cancel();if(rootUri.length===0){thrownewError('Open a workspace before using Quick Open.');}if(query.length>128){thrownewError('Enter a file name query up to 128 characters.');}constgeneration=this.generation;constcollection=awaitthis.collectDocuments(rootUri,generation);// 文件名排序进入 TaskPool。}查询最多 128 字符,避免异常输入扩大评分成本和 UI 宽度。文件相对路径来自枚举过程,不由用户查询构造。结果打开仍使用文档 URI,但 URI 只保留在 ArkTS 内,不传给 ArkWeb。
当前每次查询都会重新枚举。两文件热缓存时很快,但数千文件会重复 I/O。这是已知边界,不会用“快速打开”名称掩盖。持续索引是否值得引入,必须由 G3-10 压力数据决定。
排序函数在 TaskPool 中执行
文件名模糊评分是 CPU 遍历,标记@Concurrent并以低优先级执行:
consttask=newtaskpool.Task(rankQuickOpenDocuments,collection.documents,query,recentUris,MAX_QUICK_OPEN_RESULTS);this.activeTask=task;letresults:Array<WorkspaceSearchResult>;try{results=awaittaskpool.execute(task,taskpool.Priority.LOW)asArray<WorkspaceSearchResult>;}finally{if(this.activeTask===task){this.activeTask=undefined;}}this.assertActive(generation);文件列表和最近 URI 都是可序列化数据,不携带 ArkUI 对象。排序最多返回 50 项,UI 不会渲染数千候选。若结果刚好达到上限,摘要标记截断。
TaskPool 返回后复核代际。用户输入第二个字符时第一轮可能仍在排序,取消请求与代际令牌保证第一轮不会提交。UI 还有请求序号,旧 Promise 的finally不能把新一轮 loading 关闭。
评分从可解释的匹配层级开始
排序依次判断:文件名完全等于查询、文件名以前缀开始、完整相对路径包含查询、子序列模糊匹配。每层有明显分数区间,保证完全匹配通常高于前缀,前缀高于路径包含,包含高于松散子序列。
if(normalizedQuery.length===0){score=1;}elseif(name===normalizedQuery){score=1200;}elseif(name.startsWith(normalizedQuery)){score=900-Math.min(200,name.length-normalizedQuery.length);}elseif(candidate.includes(normalizedQuery)){score=700-Math.min(250,candidate.indexOf(normalizedQuery)*3);}else{// 进入子序列评分。}前缀分数对多余长度做有限惩罚,短而精确的文件名更靠前。路径包含根据出现位置惩罚,目录深处很晚出现的查询略降。分数都设下限范围,避免长名称因为惩罚跌到比模糊子序列更低的意外区间。
当前使用toLocaleLowerCase做大小写归一化,没有拼音转写或语言词法。中文查询需要直接输入对应汉字,英文大小写不敏感。引入拼音会增加依赖、索引和多音字排序,需要独立产品验证。
子序列匹配奖励连续与边界
当查询不是直接包含时,算法按顺序寻找每个字符。连续字符奖励 22,非连续奖励 9;匹配位于路径开头、分隔符、连字符、下划线或空格之后,再奖励 18。全部查询字符都匹配才成为候选。
letqueryIndex=0;letpreviousMatch=-2;for(letcandidateIndex=0;candidateIndex<candidate.length&&queryIndex<normalizedQuery.length;candidateIndex+=1){if(candidate[candidateIndex]===normalizedQuery[queryIndex]){score+=previousMatch===candidateIndex-1?22:9;if(candidateIndex===0||candidate[candidateIndex-1]==='/'||candidate[candidateIndex-1]==='-'||candidate[candidateIndex-1]==='_'||candidate[candidateIndex-1]===' '){score+=18;}previousMatch=candidateIndex;queryIndex+=1;}}if(queryIndex!==normalizedQuery.length)return;score+=300-Math.min(200,candidate.length);例如gsa更倾向命中g3-search-architecture.md的词边界,而不是一个很长路径里分散的三个字母。算法是线性扫描,文件数乘相对路径长度的复杂度在当前 5000 上限内可控。
评分没有随机因素。相同分数按relativePath.localeCompare排序,保证列表稳定。稳定性对键盘用户很重要:输入和删除同一查询时,不应因遍历顺序变化让选择跳动。
最近文档只是加权信号
原生层构造 recentUris:当前文档 URI 在最前,随后是标签会话倒序去重列表。排序函数按索引加分,越近分数越高,但最低仍有限:
constrecentIndex=recentUris.indexOf(document.uri);if(recentIndex>=0){score+=Math.max(40,320-recentIndex*32);}空查询下所有文档基础分为 1,最近文件因此排在前面。输入明确查询时,完全匹配 1200 分,最近权重最多 320,不会让一个仅模糊匹配的最近文件轻易压过完全匹配。权重是导航习惯的补充,不改变文件事实。
最近列表只来自当前进程标签,不写入私有历史数据库,也不跨设备同步。关闭应用后空查询顺序可能回到相对路径排序。持久历史若要做,需要隐私开关、容量和清理策略;当前没有假装已实现。
输入防抖减少无意义扫描
快速打开每次输入变化先取消计划任务、使当前结果失效,再安排 160 ms 后搜索:
privatescheduleQuickOpenSearch():void{this.cancelScheduledWorkspaceSearch();this.workspaceSearchTimerId=setTimeout(()=>{this.workspaceSearchTimerId=-1;if(this.searchPanelMode===SearchPanelMode.QUICK_OPEN){this.executeWorkspaceSearch();}},160);}防抖不是缓存。它只把连续按键合并为较少查询,目录仍会重新枚举。160 ms 需要在真机输入延迟与扫描成本之间复测;太短会频繁 I/O,太长会让候选明显滞后。
打开 Quick Open 模式时空查询立即执行,用户可以不输入直接选择最近文件。输入框通过 ArkUIfocusControl.requestFocus在面板打开后获得焦点,实现Ctrl+P后直接打字。
PC 快捷键入口避免与命令面板冲突
Web 编辑器监听Ctrl+P请求quickOpen,Ctrl+Shift+P打开命令面板。修饰键分支顺序明确:
if(key==='p'&&event.shiftKey){event.preventDefault();commandPalette.hidden?openCommandPalette():closeCommandPalette();return;}if(key==='p'&&!event.shiftKey){event.preventDefault();window.OhMarkdownEditor?.requestCommand('quickOpen');return;}原生收到quickOpen后切换搜索面板模式、打开侧栏并聚焦输入。ArkWeb 不枚举文件,仍然只发送受限命令。Playwright 通过 Bridge mock 断言两个快捷键产生不同动作。
Meta 键兼容保留,但鸿蒙 PC 设备主路径验证使用 Control。Alt 组合不被抢占。后续用户自定义快捷键需要冲突检测,不能简单覆盖这一监听。
方向键选择与列表滚动同步
ArkUI 输入框接收 KeyEvent。只有非 Current 模式、Key Down 事件才进入快速导航。上下方向更新workspaceSearchSelectedIndex,限制在有效范围,并让 Scroller 滚动到对应项;Enter 打开当前结果;搜索运行时 Escape 取消。
if(event.keyCode===KeyCode.KEYCODE_DPAD_DOWN||event.keyCode===KeyCode.KEYCODE_DPAD_UP){constdelta=event.keyCode===KeyCode.KEYCODE_DPAD_DOWN?1:-1;constnextIndex=Math.max(0,Math.min(this.workspaceSearchResults.length-1,this.workspaceSearchSelectedIndex+delta));this.workspaceSearchSelectedIndex=nextIndex;this.workspaceSearchScroller.scrollToIndex(nextIndex,true);returntrue;}if(event.keyCode===KeyCode.KEYCODE_ENTER){this.openWorkspaceSearchResult(this.workspaceSearchResults[this.workspaceSearchSelectedIndex]);returntrue;}输入控件的onSubmit还调用submitSearchInput,作为 Enter 在某些平台事件路径未被onKeyEvent消费时的保障。函数只在结果存在且非运行中时打开,避免一键触发两次。
列表项具有稳定高度约束,选中背景不会改变尺寸。长相对路径限制行数并省略,防止动态内容让方向键滚动跳动。完整路径仍可通过当前结果文本识别,未来可增加 tooltip。
打开结果沿用安全文档会话
快速打开结果kind为file,offset 为 0。点击或 Enter 后原生调用readUtf8Document(result.uri),进入与文件树打开相同的文档会话逻辑,而不是直接让 Web 改页面 URL。已有标签应激活,未打开文件创建新会话,BOM、换行和指纹照常保留。
工作区搜索文本结果会精确选区;快速打开只把光标放到开头,状态栏显示Opened docs/plan.md。打开期间使用文件操作锁,防止重复 Enter 创建两个标签。完成后焦点回到编辑器,用户可以继续输入。
文件在候选生成后被删除或权限失效时,读取失败并给出错误,列表不会构造任意替代路径。文件系统是事实来源,候选只是短期导航快照。
真实纯键盘设备路径
MateBook Pro 2in1 模拟器中,在编辑器按Ctrl+P,Quick Open 输入框获得焦点;输入plan后,同一工作区热缓存条件下扫描两份文件耗时 1 ms,返回docs/plan.md;按 Enter 打开目标。
清空查询后显示两项,按方向下键再按 Enter 打开requirements.md。这条路径验证了入口、焦点、空查询、最近权重、方向选择、滚动和打开,而不只是鼠标点击列表。
完整报告在docs/test/ohmarkdown/2026-07-19-g3-05-workspace-search/。1 ms 是两文件热缓存结果,不能用于和 Typora、Obsidian、VS Code 比较。
自动化与 TaskPool 设备验证
纯函数测试构造中文文件名与不同相对路径,验证完全、前缀、包含、子序列、最近权重和稳定排序。Playwright 验证Ctrl+PBridge 命令。ohosTest 在设备 TaskPool 中递归两层工作区,查询quick返回唯一docs/quick-notes.md,并执行取消断言。
统一基线结果为 Playwright29/29、ohosTest7/7、Web TypeScript、Debug HAP 和 UnitTestBuild 通过。代码提交2ca99e9已推送主分支。HAP 未签名,远程 CI Runner 与真机 Release 仍待验证。
后续测试应加入相同文件名不同目录、超长中文路径、5000 文件、快速连续输入与退格、空查询最近顺序、打开时文件删除、重复 Enter、标签已打开和多个窗口焦点。
性能边界与索引演进
当前查询成本包含每轮目录枚举和 TaskPool 评分。评分本身线性且轻量,主要成本可能是文件系统枚举。热缓存两文件 1 ms 不代表冷启动数千文件。G3-10 要分别记录首次打开、热查询、每键防抖、取消延迟和内存。
如果 1000 文件任务无法达到交互阈值,可以引入会话内目录缓存,并在工作区变化或明确刷新时失效;再往后才是持久增量索引。每次升级都必须保留符号链接、排除目录、授权根和取消规则。
最近使用列表也可持久化,但属于隐私数据。应提供清除入口、容量上限和工作区隔离,不能将完整路径上传。排序权重需要真实任务实验,避免“智能”让完全匹配退后。
安全与隐私
Quick Open 只展示授权工作区内白名单文本文件,相对路径留在应用 UI,ArkWeb 不获得 URI。查询与最近列表不联网、不上传。符号链接跳过,资源、版本库和依赖目录排除。
查询字符串只参与内存匹配,不进入正则,也不构造路径,避免正则拒绝服务和路径注入。相对路径显示使用 ArkUI Text,不作为富 HTML。打开文件重新经过文档读取和格式验证。
空查询显示最近文件可能暴露文件名给旁观者,这是桌面导航功能的固有界面信息。未来隐私模式可关闭最近权重或面板预览,但当前不保存跨启动历史,风险范围有限。
没有采用的方案
没有调用全文搜索后从结果推导文件列表,因为无匹配文件会消失,且读取正文浪费资源。没有使用首字母排序冒充模糊搜索,当前子序列对目录边界和连续字符有明确奖励。没有使用机器学习排序,缺少数据且不可解释。
没有默认扫描所有文件扩展名。二进制、JSON 和代码文件不属于当前 Markdown 编辑器工作区范围,显示它们会增加噪声与读取风险。未来可配置扩展名,但需要明确产品边界。
没有持久化最近绝对路径,也没有云同步历史。标准文件和本地工作区仍是产品中心,导航便利不能引入隐形隐私数据。
验收清单与结论
快速打开验收需要覆盖:Ctrl+P聚焦;Ctrl+Shift+P不冲突;空查询;完全/前缀/包含/子序列;最近权重不压过强匹配;相同分数稳定;方向键边界;Enter 单次打开;Escape 取消;160 ms 防抖;旧查询不覆盖;工作区未打开提示;删除文件失败可解释;已打开标签复用;编辑输入不被后台排序阻塞。
当前 OhMarkdown 已形成一条完整纯键盘路径,并把文件权限、排序和 UI 状态分在合适层。它不是持久索引产品的最终形态,却已经具备可解释匹配、最近权重、代际取消和真实设备打开证据。下一步应以大工作区数据决定缓存与索引,而不是为了宣传“秒开”提前增加难以维护的数据库。
