鸿蒙 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、.svn、node_modules与所有.assets资源目录。图片资源不应进入正文扫描版本库和依赖目录也会制造大量噪声。上限是服务契约而不是隐藏魔数服务集中定义预算每目录最多 2000 个子项最多 2000 个目录、5000 份文档单文件最多 4 MiB每文档最多 50 条、全局最多 500 条读取块 64 KiB。快速打开另有 50 项上限。constMAX_DIRECTORY_CHILDREN:number2000;constMAX_WORKSPACE_DIRECTORIES:number2000;constMAX_WORKSPACE_DOCUMENTS:number5000;constMAX_SEARCHABLE_DOCUMENT_BYTES:number4*1024*1024;constMAX_WORKSPACE_SEARCH_RESULTS:number500;constMAX_RESULTS_PER_DOCUMENT:number50;constREAD_CHUNK_BYTES:number64*1024;达到上限时摘要标记truncatedUI 用500等形式表达不能假装结果完整。单个目录超出子项上限或子目录不可读也会标记截断。上限既保护内存也让测试有确定断言。这些数值不是行业领先证明。它们是 Beta 纵切的保守预算后续需要用真实 1000 文件语料测量发现耗时、匹配耗时、取消延迟和内存再决定是否调整或引入增量索引。目录枚举从授权根开始WorkspaceSearchController.collectDocuments使用队列广度遍历根 URI 来自系统文件夹选择器。每次取目录先复核代际再调用非递归listFile只处理安全单段名称。子项通过lstat判断符号链接直接跳过。constpending:ArrayPendingWorkspaceDirectory[{uri:rootUri,relativePath:}];while(pending.length0documents.lengthMAX_WORKSPACE_DOCUMENTS){this.assertActive(generation);constcurrentpending.shift();constlistedNamesawaitfileIo.listFile(getPathFromUri(current.uri),{recursion:false,listNum:MAX_DIRECTORY_CHILDREN1});this.assertActive(generation);for(constnameoflistedNames.slice(0,MAX_DIRECTORY_CHILDREN)){constchildUricreateChildUri(current.uri,name);conststatawaitfileIo.lstat(childUri);if(stat.isSymbolicLink())continue;// 目录进入队列白名单文档进入列表。}}不使用文件系统递归选项是为了在每一层执行排除、上限和取消检查。一次不可取消的深递归 API 会让 UI 的 Cancel 只有视觉效果。广度遍历还让浅层常用文件更早被发现虽然当前结果在匹配前统一排序以保证稳定。根目录不可读会让整轮失败因为工作区授权已经失效非根子目录不可读只标记截断并继续。区分致命错误与局部错误才能既诚实又不因一个权限目录放弃全部结果。路径构造拒绝越界片段目录项名称必须非空、不为.或..、不含正反斜杠与空字符。对子 URI 的构造只追加单段。URI 解析使用平台uri.URI普通路径与 URI 分支都不接受用户查询直接进入路径。functionisSafeEntryName(name:string):boolean{returnname.length0name!.name!..!name.includes(/)!name.includes(\\)!name.includes(\u0000);}functionshouldExcludeDirectory(name:string):boolean{constlowerNamename.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 流式解码每块完成后复核取消代际。constdecoderutil.TextDecoder.create(utf-8,{fatal:true,ignoreBOM:false});constchunks:Arraystring[];lettotalBytesRead0;while(totalBytesReadstat.size){constrequestedBytesMath.min(READ_CHUNK_BYTES,stat.size-totalBytesRead);constchunknewArrayBuffer(requestedBytes);constbytesReadawaitfileIo.read(file.fd,chunk,{length:requestedBytes});assertActive();if(bytesRead0)break;totalBytesReadbytesRead;chunks.push(decoder.decodeToString(newUint8Array(chunk,0,bytesRead),{stream:totalBytesReadstat.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 线程只管理状态和结果列表。consttasknewtaskpool.Task(searchDocumentContent,document,content,query,options,Math.min(MAX_RESULTS_PER_DOCUMENT,remainingResults));this.activeTasktask;try{outputawaittaskpool.execute(task,taskpool.Priority.LOW)asWorkspaceDocumentSearchOutput;}finally{if(this.activeTasktask)this.activeTaskundefined;}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-lineStart1,preview,matchedText:content.slice(offset,offsetlength),score:0});预览限制为命中前后有界字符并压缩空白不把整段文档复制到 UI。行列单次顺序扫描计算避免每条命中都从头统计换行造成平方复杂度。取消由服务代际和 TaskPool 两层组成WorkspaceSearchController持有generation和当前activeTask。每次开始新搜索先调用cancel代际增加并请求取消当前 TaskPool。目录等待、每个目录项、读取块、TaskPool 返回和最终汇总都复核代际。cancel():void{this.generation1;if(this.activeTask){try{taskpool.cancel(this.activeTask);}catch(_){}this.activeTaskundefined;}}privateassertActive(generation:number):void{if(generation!this.generation){thrownewError(Workspace search canceled.);}}TaskPool 取消解决正在进行的 CPU 工作代际解决无法立即取消的异步目录与文件 API也防止已经完成但晚到的旧结果提交。二者缺一不可。只调用taskpool.cancel无法撤销目录枚举只用布尔值会被新任务重置并让旧任务误以为仍有效。UI 还有独立workspaceSearchRequestSequence。即使旧 Promise 的catch或finally晚到也只有序号仍匹配时才能更新结果、错误和 loading。服务保护计算UI 保护展示。搜索面板不占用全局文件操作锁保存、系统选择器和资源移动使用operationInProgress搜索没有占用这把锁。用户可以在后台扫描期间继续编辑。搜索读取磁盘快照不改文档结果点击才进入打开文件流程。ArkUI 面板区分当前文档、工作区和快速打开三种模式各自保存查询。切换模式会取消当前任务、清列表和状态。工作区选项包括大小写、整词和正则修改选项使旧结果失效。运行时显示扫描详情与 Cancel。空查询不会启动全文扫描。快速打开输入使用 160 ms debounce全文搜索由 Enter 或按钮触发避免每次字符都读取所有正文。结果点击前重新验证磁盘搜索结果生成后文件可能被 Git、其他编辑器或当前应用修改。打开结果时原生重新调用readUtf8Document然后用resolveWorkspaceSearchOffset检查原 offset 的matchedText。不匹配时搜索距离原位置最近的同样文本。exportfunctionresolveWorkspaceSearchOffset(content:string,result:WorkspaceSearchResult):number{if(result.kindfile)return0;if(result.offset0content.slice(result.offset,result.offsetresult.matchedText.length)result.matchedText){returnresult.offset;}letbestOffset-1;letbestDistanceNumber.MAX_SAFE_INTEGER;letcandidatecontent.indexOf(result.matchedText);while(candidate0){constdistanceMath.abs(candidate-result.offset);if(distancebestDistance){bestOffsetcandidate;bestDistancedistance;}candidatecontent.indexOf(result.matchedText,candidate1);}returnbestOffset;}如果真实匹配已经消失应用提示重新搜索不跳到一个过期偏移。若存在多个相同文本选择距离旧位置最近者尽量保留用户语境。重新定位不尝试用行号硬跳因为前面插入几行后 offset 和行号都会变化实际匹配文本更可靠。CodeMirror 选区完成最终准确跳转原生应用打开文档、切换源码视图再调用 Web 的jumpToOffset(offset, length)。函数验证整数与范围把 selection 从 offset 拉到 offsetlength并滚动到视口顶部附近。functionjumpToOffset(offset:number,length:number0):boolean{if(!Number.isInteger(offset)||!Number.isInteger(length)||offset0||length0||offsetlengtheditor.state.doc.length){returnfalse;}if(currentModepreview)setMode(source);editor.dispatch({selection:{anchor:offset,head:offsetlength},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 覆盖CtrlShiftF命令以及范围选区。ohosTest 在设备创建两层目录、两份 Markdown、一个排除资源目录和非文本文件真实执行 TaskPool 搜索、快速排序与取消。统一验证结果 Playwright29/29、ohosTest7/7Debug 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-84 MiB 限制单文件/全局/目录上限大小写、整词和正则查询切换取消旧 catch/finally 不覆盖编辑可继续输入结果显示相对路径和上下文文件变化后重新定位匹配消失时拒绝过期跳转模拟器和真机性能边界分别记录。当前 OhMarkdown 已完成可用的后台搜索纵切。它的优势不是结果数量而是文件事实、授权范围、取消语义和跳转准确性都能解释并有真实 TaskPool 与文件夹证据。等 G3-10 补齐压力和竞品测量后才能进一步判断性能是否形成领先在此之前可靠地不夸大也是专业工具应有的工程纪律。