鸿蒙 PC Markdown 编辑器全文搜索:后台扫描、取消语义与准确跳转

鸿蒙 PC Markdown 编辑器全文搜索:后台扫描、取消语义与准确跳转

工作区搜索的界面通常只有一个输入框和一列结果,工程代价却隐藏在文件系统与并发里。扫描多少目录、跟不跟符号链接、如何处理非 UTF-8、大文件何时降级、用户继续输入时旧任务怎样取消、结果点击前文件变化怎么办,这些条件共同决定搜索是一个专业工具,还是一个偶尔卡住编辑器的演示功能。

OhMarkdown 的工作区全文搜索已进入公开仓库 https://gitcode.com/VON-/codex_md_oh,完成提交为2ca99e9。本文基于SearchService.ets、ArkUI 搜索面板、CodeMirror 精确选区、Playwright29/29、设备 ohosTest7/7和 MateBook Pro 2in1 模拟器真实目录证据。1000 文件压力、鸿蒙 PC 真机 Release 和竞品统一计时仍属于 G3-10,不会用两文件结果替代。

搜索的四条红线

第一,文件仍是唯一事实来源。当前不建立私有数据库,也不要求用户把 Markdown 导入知识库;每轮搜索在授权工作区重新枚举并读取。第二,搜索不能阻塞编辑输入,正文匹配和正则在低优先级 TaskPool 执行。第三,任务必须可取消,旧结果不能覆盖新查询。第四,扫描不能越过授权目录或通过符号链接绕出边界。

这四条红线比“支持正则”更重要。正则是可见功能,边界和时序决定用户是否敢在真实项目中使用。实现先建立目录、文件、字节、结果和取消上限,再接 UI。

当前支持.md.markdown.mdown.mkd.txt。排除.git.hg.svnnode_modules与所有.assets资源目录。图片资源不应进入正文扫描,版本库和依赖目录也会制造大量噪声。

上限是服务契约而不是隐藏魔数

服务集中定义预算:每目录最多 2000 个子项,最多 2000 个目录、5000 份文档;单文件最多 4 MiB;每文档最多 50 条、全局最多 500 条;读取块 64 KiB。快速打开另有 50 项上限。

constMAX_DIRECTORY_CHILDREN:number=2000;constMAX_WORKSPACE_DIRECTORIES:number=2000;constMAX_WORKSPACE_DOCUMENTS:number=5000;constMAX_SEARCHABLE_DOCUMENT_BYTES:number=4*1024*1024;constMAX_WORKSPACE_SEARCH_RESULTS:number=500;constMAX_RESULTS_PER_DOCUMENT:number=50;constREAD_CHUNK_BYTES:number=64*1024;

达到上限时摘要标记truncated,UI 用500+等形式表达,不能假装结果完整。单个目录超出子项上限或子目录不可读也会标记截断。上限既保护内存,也让测试有确定断言。

这些数值不是行业领先证明。它们是 Beta 纵切的保守预算,后续需要用真实 1000 文件语料测量发现耗时、匹配耗时、取消延迟和内存,再决定是否调整或引入增量索引。

目录枚举从授权根开始

WorkspaceSearchController.collectDocuments使用队列广度遍历,根 URI 来自系统文件夹选择器。每次取目录先复核代际,再调用非递归listFile,只处理安全单段名称。子项通过lstat判断,符号链接直接跳过。

constpending:Array<PendingWorkspaceDirectory>=[{uri:rootUri,relativePath:''}];while(pending.length>0&&documents.length<MAX_WORKSPACE_DOCUMENTS){this.assertActive(generation);constcurrent=pending.shift();constlistedNames=awaitfileIo.listFile(getPathFromUri(current.uri),{recursion:false,listNum:MAX_DIRECTORY_CHILDREN+1});this.assertActive(generation);for(constnameoflistedNames.slice(0,MAX_DIRECTORY_CHILDREN)){constchildUri=createChildUri(current.uri,name);conststat=awaitfileIo.lstat(childUri);if(stat.isSymbolicLink())continue;// 目录进入队列,白名单文档进入列表。}}

不使用文件系统递归选项,是为了在每一层执行排除、上限和取消检查。一次不可取消的深递归 API 会让 UI 的 Cancel 只有视觉效果。广度遍历还让浅层常用文件更早被发现,虽然当前结果在匹配前统一排序以保证稳定。

根目录不可读会让整轮失败,因为工作区授权已经失效;非根子目录不可读只标记截断并继续。区分致命错误与局部错误,才能既诚实又不因一个权限目录放弃全部结果。

路径构造拒绝越界片段

目录项名称必须非空、不为...、不含正反斜杠与空字符。对子 URI 的构造只追加单段。URI 解析使用平台uri.URI,普通路径与 URI 分支都不接受用户查询直接进入路径。

functionisSafeEntryName(name:string):boolean{returnname.length>0&&name!=='.'&&name!=='..'&&!name.includes('/')&&!name.includes('\\')&&!name.includes('\u0000');}functionshouldExcludeDirectory(name:string):boolean{constlowerName=name.toLowerCase();returnEXCLUDED_DIRECTORY_NAMES.includes(lowerName)||lowerName.endsWith('.assets');}

lstat而不是stat是关键。stat可能跟随符号链接到授权目录之外,搜索随后读取外部文件。NOFOLLOW还会在文件打开阶段再次保护,避免枚举与读取之间被替换成链接的竞态。

排除目录使用大小写归一化,避免Node_Modules等变体。资源目录按后缀排除,覆盖文档专属${name}.assets

文档读取使用严格 UTF-8 与分块复核

服务先以READ_ONLY | NOFOLLOW打开文件,再基于文件描述符stat。超过 4 MiB 立即跳过。读取以 64 KiB ArrayBuffer 分块,使用 fatal UTF-8 decoder 流式解码,每块完成后复核取消代际。

constdecoder=util.TextDecoder.create('utf-8',{fatal:true,ignoreBOM:false});constchunks:Array<string>=[];lettotalBytesRead=0;while(totalBytesRead<stat.size){constrequestedBytes=Math.min(READ_CHUNK_BYTES,stat.size-totalBytesRead);constchunk=newArrayBuffer(requestedBytes);constbytesRead=awaitfileIo.read(file.fd,chunk,{length:requestedBytes});assertActive();if(bytesRead===0)break;totalBytesRead+=bytesRead;chunks.push(decoder.decodeToString(newUint8Array(chunk,0,bytesRead),{stream:totalBytesRead<stat.size}));}if(totalBytesRead!==stat.size){thrownewError('The workspace document changed while it was being searched.');}

fatal 解码意味着损坏 UTF-8 不会被替换字符悄悄改变偏移。搜索结果偏移最终传给 JavaScript CodeMirror,必须基于一致字符串。BOM 解码后从正文移除,与编辑器正文模型保持一致。

文件读取期间大小变化会被识别。相同大小内容变化无法完全通过长度发现,但结果点击时还会重新读取并验证匹配文本。单文件失败计入documentsSkipped,不终止整轮。

正文匹配进入低优先级 TaskPool

读取是异步 I/O,正文扫描和正则是 CPU 工作。searchDocumentContent标记@Concurrent,由taskpool.execute(..., Priority.LOW)运行。主 ArkUI 线程只管理状态和结果列表。

consttask=newtaskpool.Task(searchDocumentContent,document,content,query,options,Math.min(MAX_RESULTS_PER_DOCUMENT,remainingResults));this.activeTask=task;try{output=awaittaskpool.execute(task,taskpool.Priority.LOW)asWorkspaceDocumentSearchOutput;}finally{if(this.activeTask===task)this.activeTask=undefined;}this.assertActive(generation);

低优先级不能保证绝对不影响输入,但向调度器表达了后台性质。每次只执行当前文档任务,控制内存和取消。未来并行多个文件前必须测量 CPU 与 UI 抢占,不能因为核心多就盲目并发。

TaskPool 函数只接收可序列化文档元数据、正文、查询和选项,不捕获 ArkUI 对象或文件描述符。结果也只是结构化数组,满足并发边界。

普通、大小写、整词和正则共享结果模型

普通大小写敏感搜索使用indexOf;不敏感搜索用转义后的全局正则;正则模式先在 UI 服务入口编译验证,再在 TaskPool 构造。整词判断支持 ASCII 字母、数字、下划线和常见 CJK 区间,比较命中前后字符。

每条结果包含:类型、名称、URI、相对路径、UTF-16 offset、行、列、单行预览、实际matchedText和评分。UTF-16 与 JavaScript 字符串和 CodeMirror 偏移一致,避免 emoji 前缀导致跳转偏差。

results.push({kind:'text',name:document.name,uri:document.uri,relativePath:document.relativePath,offset,line:currentLine,column:offset-lineStart+1,preview,matchedText:content.slice(offset,offset+length),score:0});

预览限制为命中前后有界字符并压缩空白,不把整段文档复制到 UI。行列单次顺序扫描计算,避免每条命中都从头统计换行造成平方复杂度。

取消由服务代际和 TaskPool 两层组成

WorkspaceSearchController持有generation和当前activeTask。每次开始新搜索先调用cancel,代际增加并请求取消当前 TaskPool。目录等待、每个目录项、读取块、TaskPool 返回和最终汇总都复核代际。

cancel():void{this.generation+=1;if(this.activeTask){try{taskpool.cancel(this.activeTask);}catch(_){}this.activeTask=undefined;}}privateassertActive(generation:number):void{if(generation!==this.generation){thrownewError('Workspace search canceled.');}}

TaskPool 取消解决正在进行的 CPU 工作,代际解决无法立即取消的异步目录与文件 API,也防止已经完成但晚到的旧结果提交。二者缺一不可。只调用taskpool.cancel无法撤销目录枚举;只用布尔值会被新任务重置并让旧任务误以为仍有效。

UI 还有独立workspaceSearchRequestSequence。即使旧 Promise 的catchfinally晚到,也只有序号仍匹配时才能更新结果、错误和 loading。服务保护计算,UI 保护展示。

搜索面板不占用全局文件操作锁

保存、系统选择器和资源移动使用operationInProgress,搜索没有占用这把锁。用户可以在后台扫描期间继续编辑。搜索读取磁盘快照,不改文档;结果点击才进入打开文件流程。

ArkUI 面板区分当前文档、工作区和快速打开三种模式,各自保存查询。切换模式会取消当前任务、清列表和状态。工作区选项包括大小写、整词和正则,修改选项使旧结果失效。

运行时显示扫描详情与 Cancel。空查询不会启动全文扫描。快速打开输入使用 160 ms debounce,全文搜索由 Enter 或按钮触发,避免每次字符都读取所有正文。

结果点击前重新验证磁盘

搜索结果生成后,文件可能被 Git、其他编辑器或当前应用修改。打开结果时原生重新调用readUtf8Document,然后用resolveWorkspaceSearchOffset检查原 offset 的matchedText。不匹配时搜索距离原位置最近的同样文本。

exportfunctionresolveWorkspaceSearchOffset(content:string,result:WorkspaceSearchResult):number{if(result.kind==='file')return0;if(result.offset>=0&&content.slice(result.offset,result.offset+result.matchedText.length)===result.matchedText){returnresult.offset;}letbestOffset=-1;letbestDistance=Number.MAX_SAFE_INTEGER;letcandidate=content.indexOf(result.matchedText);while(candidate>=0){constdistance=Math.abs(candidate-result.offset);if(distance<bestDistance){bestOffset=candidate;bestDistance=distance;}candidate=content.indexOf(result.matchedText,candidate+1);}returnbestOffset;}

如果真实匹配已经消失,应用提示重新搜索,不跳到一个过期偏移。若存在多个相同文本,选择距离旧位置最近者,尽量保留用户语境。重新定位不尝试用行号硬跳,因为前面插入几行后 offset 和行号都会变化,实际匹配文本更可靠。

CodeMirror 选区完成最终准确跳转

原生应用打开文档、切换源码视图,再调用 Web 的jumpToOffset(offset, length)。函数验证整数与范围,把 selection 从 offset 拉到 offset+length,并滚动到视口顶部附近。

functionjumpToOffset(offset:number,length:number=0):boolean{if(!Number.isInteger(offset)||!Number.isInteger(length)||offset<0||length<0||offset+length>editor.state.doc.length){returnfalse;}if(currentMode==='preview')setMode('source');editor.dispatch({selection:{anchor:offset,head:offset+length},effects:EditorView.scrollIntoView(offset,{y:'start',yMargin:18})});editor.focus();returntrue;}

选中匹配比只移动光标更易确认,尤其同一行有多个结果。范围验证避免原生错误数据破坏编辑器事务。原生状态栏显示Opened requirements.md:430,提供路径与行号反馈。

真实工作区设备证据

模拟器授权目录为/mnt/user/100/currentUser/filemgr/Documents/OhMarkdownSearchTest,包含根目录requirements.md和子目录docs/plan.md。搜索SRC-003返回 1 条结果、扫描 2/2 文件、32 ms,定位第 430 行。

点击后打开真实requirements.md并选中匹配文本:

完整报告在docs/test/ohmarkdown/2026-07-19-g3-05-workspace-search/。32 ms 是两文件模拟器单次结果,不可宣传为大工作区或行业领先。

自动化、设备 TaskPool 与边界

纯函数测试覆盖普通匹配、大小写、整词、正则、单文件上限、上下文、UTF-16 偏移和失效重定位。Playwright 覆盖Ctrl+Shift+F命令以及范围选区。ohosTest 在设备创建两层目录、两份 Markdown、一个排除资源目录和非文本文件,真实执行 TaskPool 搜索、快速排序与取消。

统一验证结果 Playwright29/29、ohosTest7/7,Debug HAP 与 UnitTestBuild 通过。主 HAP SHA-256 为ad812363d17e19011f1558e061acf3cf9b0ffa83a7f9bf11a570a4ca2faa3eb6,但它是未签名 Debug 产物。

代码路径检查不能替代压力测试。G3-10 仍需 1000 文件、连续快速取消、CPU/内存、损坏 UTF-8、大量结果、4 MiB 边界和真机输入响应,并与竞品使用同一语料和计时边界。

错误隔离与用户反馈

根目录不可读终止并显示 Search failed;子目录不可读使结果 truncated;单文件超过 4 MiB、非 UTF-8、读取失败或变化计入 skipped;非法正则在扫描前报错;用户取消显示 Canceled;没有匹配显示 No workspace matches。

错误消息不含文档正文,也不上传路径。列表显示相对路径、行列和预览,帮助用户理解结果。达到上限明确加号,不以“500 results”暗示正好完整。

Cancel 不保证底层每个系统 I/O 瞬间停止,但保证其结果不会提交。这个语义必须在技术文档中讲清:可取消的用户承诺首先是旧任务不污染当前状态,其次才是尽快释放后台资源。

没有采用持久索引的原因

持久索引能降低重复搜索延迟,但会引入文件观察、索引失效、数据库迁移、隐私存储和初始构建成本。当前 Beta 需要先验证搜索任务、结果模型和 PC 交互,5000 文件以内重新扫描更容易保持文件事实。

没有把全文扫描放在 Web Worker。ArkWeb 不应持有工作区 URI和读取权限,文件仍由 ArkTS 访问;TaskPool 提供原生并发。没有并行扫描所有文档,因为峰值内存和 CPU 会与编辑输入竞争。

未来若数据证明需要索引,可以把SearchService的收集和匹配边界替换为增量实现,同时保留授权、排除、取消、结果上限和点击复核。索引是优化,不应改变安全和正确性契约。

验收清单与结论

工作区搜索发布前应验证:授权根不越界;符号链接与资源目录跳过;五种文本扩展;严格 UTF-8;4 MiB 限制;单文件/全局/目录上限;大小写、整词和正则;查询切换取消;旧 catch/finally 不覆盖;编辑可继续输入;结果显示相对路径和上下文;文件变化后重新定位;匹配消失时拒绝过期跳转;模拟器和真机性能边界分别记录。

当前 OhMarkdown 已完成可用的后台搜索纵切。它的优势不是结果数量,而是文件事实、授权范围、取消语义和跳转准确性都能解释,并有真实 TaskPool 与文件夹证据。等 G3-10 补齐压力和竞品测量后,才能进一步判断性能是否形成领先;在此之前,可靠地不夸大也是专业工具应有的工程纪律。