鸿蒙 PC Markdown 编辑器搜索准确跳转:UTF-16 偏移、失效重定位与 CodeMirror 选区

📅 2026/7/23 14:34:40
鸿蒙 PC Markdown 编辑器搜索准确跳转:UTF-16 偏移、失效重定位与 CodeMirror 选区
鸿蒙 PC Markdown 编辑器搜索准确跳转UTF-16 偏移、失效重定位与 CodeMirror 选区工作区全文搜索返回文件名和行号只是检索的一半。用户点击结果后编辑器必须打开正确文档、定位真实匹配、选中完整文本、滚动到可见位置并归还输入焦点。搜索与打开之间文件可能被外部程序修改中文、emoji 和代理对又让“字符数”在 UTF-8 字节、Unicode code point 与 JavaScript UTF-16 code unit 之间产生差异。只按旧行号跳转很容易落错位置。OhMarkdown 的准确跳转已进入公开仓库 https://gitcode.com/VON-/codex_md_oh完成提交为2ca99e9当前验证基线为0d8d38b。本文只讨论已经实现的结果模型、UTF-16 偏移、打开时重读、最近真实命中、CodeMirror 选区和设备第 430 行证据不把语义索引、增量索引或跨文件替换描述为现有能力。跳转成功的严格定义点击工作区结果后应用应读取目标文件的最新字节并按原格式建立文档会话若原匹配仍存在使用原 offset若文件前面插入内容查找距离旧位置最近的同文本若匹配已消失提示重新搜索而不是跳到错误行若 Web 拒绝偏移原生报告失败。最终视图必须切到源码模式因为预览 DOM 与 CodeMirror 文档偏移不是同一坐标selection 从匹配起点到起点加 matchedText.length目标滚动到顶部附近并留 18 px 上边距EditorView 获得焦点用户可以继续编辑。行号和列号仍用于结果列表与状态栏反馈但真正跳转以最新内容中的文本偏移为准。这是“展示坐标”和“执行坐标”的分离。结果模型保存足够的重定位信息WorkspaceSearchResult不只保存 line/column。它包含目标 URI、相对路径、UTF-16 offset、单行 preview、实际 matchedText、kind 和 score。exportinterfaceWorkspaceSearchResult{kind:string;name:string;uri:string;relativePath:string;offset:number;line:number;column:number;preview:string;matchedText:string;score:number;}matchedText是失效重定位关键。如果只存查询大小写不敏感搜索可能查询src-003实际命中SRC-003正则查询SRC-[0-9]也不能直接在新内容中查正则表达式字符串。保存当时真实切片才能在打开时验证。kind区分正文结果和快速打开文件。文件结果 offset 为 0、match length 为 0正文结果需要选中 matchedText。统一模型减少 UI 分支但 resolve 函数必须对 file 明确返回 0。搜索偏移为什么选择 UTF-16ArkTS/JavaScript 字符串的indexOf、slice和.length都以 UTF-16 code unit 计数CodeMirror 6 的文档位置也与 JavaScript 字符串坐标兼容。搜索在解码后的 string 上运行因此保存其 indexOf offset 可以直接交给 Web selection。UTF-8 文件字节偏移不能直接用。中文通常占三个 UTF-8 字节但 JavaScript length 为一个 code unitemoji 在 UTF-8 常占四字节在 UTF-16 占两个 code unit。如果用字节 offset 选 CodeMirror前面每个中文和 emoji 都会累积误差。Unicode code point 数也不能直接使用因为 CodeMirror/JS 仍以 UTF-16 code unit 定位。项目选择与执行端一致的坐标系避免每次 Bridge 传输再转换。文章把它明确写作 UTF-16 偏移而不是模糊“字符位置”。匹配阶段生成 offset、line 与 columnTaskPool 中的searchDocumentContent在内容字符串上运行。普通大小写敏感路径使用indexOf不敏感路径用转义后的全局 RegExp正则模式使用用户表达式。所有match.index与 query.length 都是 UTF-16 单位。if(options.caseSensitive){letfrom:number0;letoffset:numbercontent.indexOf(query,from);while(offset0){canAppend(offset,query.length);if(foundMore)break;fromoffsetMath.max(1,query.length);offsetcontent.indexOf(query,from);}}else{constescapedQueryquery.replace(/[.*?^${}()|[\]\\]/g,\\$);constmatchernewRegExp(escapedQuery,gi);letmatch:RegExpExecArray|nullmatcher.exec(content);while(match!null){canAppend(match.index,match[0].length);matchmatcher.exec(content);}}行号由 offset 前的换行数量得到列号由最近行首 offset 相减加一。它们用于人类阅读是 1-basedCodeMirror offset 是 0-based。把两种基数混在一个字段是常见 off-by-one 来源当前模型命名和使用位置明确区分。预览截取匹配所在单行上下文去除或限制过长内容。列表显示预览点击仍使用 URI/offset/matchedText不从渲染后的预览反推位置。正则零长度匹配必须前进用户正则可能产生零长度匹配例如边界表达式。如果 matcher.lastIndex 不前进全局循环会无限重复同一位置。实现遇到match[0].length 0时手动增加 lastIndex。letmatch:RegExpExecArray|nullmatcher.exec(content);while(match!null){canAppend(match.index,match[0].length);if(foundMore)break;if(match[0].length0){matcher.lastIndex1;}if(taskpool.Task.isCanceled()){thrownewError(Workspace search canceled.);}matchmatcher.exec(content);}canAppend拒绝 length 0所以零长度结果不会进入列表但循环仍必须前进。正则无效时抛出明确错误取消时停止 TaskPool不让旧搜索继续生成结果。这些保护发生在搜索阶段却直接影响跳转可靠性。结果列表中每个正文项都保证 matchedText 非空打开时最近命中循环才能按至少一字符前进。打开时必须重新读取目标文件搜索结果只是某一时刻的快照。用户点击前Git checkout、同步软件、其他编辑器或当前应用可能已经修改文件。openWorkspaceSearchResult不直接激活搜索缓存内容而是调用readUtf8Document(result.uri)读取最新文档。privateasyncopenWorkspaceSearchResult(result:WorkspaceSearchResult):Promisevoid{if(this.operationInProgress)return;this.operationInProgresstrue;this.operationStatusOpening search result...;try{constopenedDocumentawaitreadUtf8Document(result.uri);constoffsetresolveWorkspaceSearchOffset(openedDocument.content,result);if(offset0){thrownewError(The match changed on disk; run the workspace search again.);}awaitthis.applyOpenedDocument(openedDocument);// 后续切换源码并调用 Web 跳转。}finally{this.operationInProgressfalse;}}重新读取继承文档服务的 UTF-8、BOM、LF/CRLF、大小和错误边界。目标文件被删除、权限失效或编码非法时打开失败原标签和文档不被伪造替代。operationInProgress 防止双击或连续 Enter 并发打开两个会话。状态栏先显示 Opening结束后显示具体路径和行号或失败原因。原 offset 仍真实时走快速路径resolveWorkspaceSearchOffset首先处理快速打开 file 结果返回 0。正文结果检查 offset 非负并比较最新内容在[offset, offset matchedText.length)的切片是否完全等于旧 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;}// 偏移失效时查找最近真实命中。}快速路径不重新遍历全文绝大多数未变化文件只做一次 slice。比较区分大小写因为 matchedText 是当时实际字符即使原搜索不敏感也应确认同一个可见文本仍在原位。没有额外比较 line/column。只要匹配切片仍在 offset前面行结构也必然没有改变到影响该位置结果列表行号仍是搜索时数据状态栏打开后当前代码仍显示旧 line这在前面不影响 offset 的变化场景通常一致。若文件局部换行变化但 offset/文本巧合状态栏行号可能陈旧后续可从最新内容重算。偏移失效时选择最近相同文本如果原切片不匹配函数遍历最新内容中所有 matchedText计算与旧 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,candidateMath.max(1,result.matchedText.length));}returnbestOffset;步进至少 1避免空字符串循环正常搜索结果 matchedText 本就非空。相同距离时保留先出现者结果稳定。复杂度 O(n × 比较成本)只在打开单个失效结果时执行不是所有搜索结果都重扫。这不是语义 diff。若同文本被删除而另一个副本恰好更近仍可能跳到不同语义位置。当前策略可解释、成本低并在找不到任何匹配时明确失败更强上下文重定位可利用 preview 前后文但需要独立测试。为什么不用旧行号直接跳文件开头插入一行旧行 430 的目标可能变成 431按 430 跳会选错。删除前面内容同理。行号只能表达搜索时的人类位置无法确认内容身份。按 preview 查找也不可靠预览可能截断、去空白或包含同一行其他内容。按 query 查找会丢失正则实际匹配和大小写。matchedText old offset 最近距离保留了最少但有效的信息。状态栏仍显示Opened requirements.md:430作为原结果反馈。后续若重定位发生应重算新行列并显示“已重定位到 431”让用户知道文件变化。当前失败路径已避免静默错误但成功重定位的可见解释仍可增强。应用文档后再调用 Webresolve 成功后原生先applyOpenedDocument建立或激活正确会话再强制viewModesource调用 WebsetMode(source)最后传 offset 和 matchLength。不能先在旧文档中跳转再替换正文。awaitthis.applyOpenedDocument(openedDocument);this.viewModesource;this.setEditorMode(source);constmatchLengthresult.kindtext?result.matchedText.length:0;constjumpResultawaitthis.editorController.runJavaScript(window.OhMarkdownEditor?.jumpToOffset(${offset},${matchLength}) true);if(jumpResult!true){thrownewError(The editor could not select the search result.);}参数都是经过整数计算的数字不直接拼用户字符串。Web 返回 boolean经 runJavaScript 编码后严格比较true。失败不会显示假成功状态。applyOpenedDocument复用文件树打开路径保留 BOM、换行、指纹、多标签和冲突检查。搜索跳转没有创建绕过文档安全的“快速打开”分支。CodeMirror 做最终范围验证WebjumpToOffset再次检查 offset 与 length 都是整数、非负且总和不超过当前文档长度。原生和 Web 之间可能因异步 session 更新出现差异第二层验证保护 EditorView transaction。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;}正文结果选中完整 matchedText快速打开 length 0 只放光标。选中比仅定位光标更容易确认尤其同一行出现多次关键词时。scrollIntoView 把目标靠近顶部并留 18 px用户能看到后续上下文。最后 focus 归还编辑器完成 PC 键盘路径。预览模式在 Web 内也有保护原生与 Web 双重切 source避免调用时序导致目标不可见。中文和 emoji 的坐标验证思路单元测试内容包含“鸿蒙 PC”等中文第二次匹配 offset 用lastIndexOf比较证明搜索函数与 JS 字符串坐标一致。更强测试应在匹配前加入 emoji例如前缀 SRC-003断言 offset 比 Unicode code point 数多 1但 CodeMirror selection 正确。当前结果的 column 也是 UTF-16 code unit 列不是用户感知字形列。emoji 前的列号可能比视觉字素数大。跳转正确不受影响但状态栏列号语义可在未来改为 grapheme cluster 计数这会增加 Intl.Segmenter 或等效逻辑需与性能权衡。对编辑器内部协议使用 UTF-16 是正确选择对人类展示行列可能需要更友好定义。把两者混成一个“字符”概念会掩盖差异。真实第 430 行设备闭环MateBook Pro 2in1 模拟器授权工作区包含根目录requirements.md与子目录docs/plan.md。搜索SRC-003扫描 2/2 文件耗时 32 ms返回requirements.md第 430 行。点击结果后源码编辑器选中SRC-003并滚动到目标状态栏显示打开路径与行号。这个测试刻意选择较深行号不用第一屏结果掩盖 scrollIntoView 问题。工作区还有排除目录和非文本文件的设备测试保证候选来自安全枚举。32 ms 是两文件模拟器样本不代表 1000 文件性能。准确跳转的核心证据是目标选择和行号不应把小样本时间扩大为产品性能承诺。文件变化重定位单元测试纯函数测试构造旧 offset 20、matchedText“鸿蒙 PC”新内容中出现两次相同文本。原位置不再匹配函数返回距离旧位置最近的第二个实际 offset 16。constresult:WorkspaceSearchResult{kind:text,name:document.name,uri:document.uri,relativePath:document.relativePath,offset:20,line:2,column:1,preview:鸿蒙 PC,matchedText:鸿蒙 PC,score:0};expect(resolveWorkspaceSearchOffset(前置内容\n鸿蒙 PC\n更多内容\n鸿蒙 PC,result)).assertEqual(16);还应增加三类测试原 offset 仍匹配直接返回匹配彻底消失返回 -1两个候选等距选择前者。当前代码行为明确但完整分支覆盖可以继续加强。自动化、构建与设备结果ArkTS 单元测试覆盖普通匹配、大小写、整词、正则、单文件上限、上下文、UTF-16 offset 和失效重定位。Playwright 覆盖范围选区和 jumpToOffset 边界。ohosTest 在设备创建两层目录和真实 Markdown执行 TaskPool 搜索、快速排序与取消最终7/7。全量 Playwright30/30。最终 Debug HAP 大小 1,520,352 字节SHA-256367ab8650479aa1fa8fe73bd1ebadd9a53f46659c850c2e388fc799d5cb88e5bohosTest HAP 大小 2,360,824 字节SHA-256b7230037b51044fe16168d2c835fb891e1c70f675941a1046165bc895217592c。两份为未签名测试产物。测试分层分别证明纯算法、Web selection、真实 fileIo/TaskPool 和可见 PC 跳转。任何一层通过都不能单独替代其余证据。并发与旧请求保护工作区搜索使用 generation 与请求序号取消旧任务结果列表只提交当前查询。打开结果又用operationInProgress防双击。文件重读与 apply 之间仍有很小变化窗口外部程序可能在 read 后立刻修改磁盘但当前会话使用刚读内容offset 与 EditorView 一致外部修改轮询随后会提示冲突。用户在打开期间切标签由 operation 锁限制。runJavaScript 返回前 EditorView 文档应已应用若 session 时序不一致Web 长度检查返回 false原生显示失败。没有用 setTimeout 猜编辑器加载完成而是 await apply 与脚本结果。快速连续点击不同结果目前会忽略第二次因为 operationInProgress true。未来可实现“最后点击优先”队列但必须保证文档会话和选区原子切换。安全边界结果 URI 来自已授权工作区枚举不由查询字符串拼接。打开使用readUtf8Document文件名和路径仍受 CoreFileKit 与工作区安全规则。Web 只接收整数 offset/length不接收文件 URI、路径或用户正则。正则在 TaskPool 执行并有取消与结果上限但 JavaScript RegExp 仍可能遇到复杂回溯当前查询长度和文档大小上限降低风险真正 ReDoS 预算仍需压力测试。跳转阶段不重新执行正则只查字面 matchedText避免打开时再次承受表达式成本。匹配消失时返回错误不把 offset clamp 到文档末尾。静默 clamp 会打开错误位置并让用户误以为命中仍存在属于不可接受的假成功。已知限制与后续改进最近文本策略不使用上下文重复短词可能重定位到语义不同但距离更近的位置。可以保存匹配前后固定窗口并做组合评分或用行哈希但结果模型和隐私/内存要重新评估。成功重定位后状态栏仍显示旧 line应该从最新 content 与新 offset 重算。column 是 UTF-16不是字素列。外部文件在 read/apply 之后再次变化由轮询冲突处理跳转本身不锁磁盘文件。持久索引、跨文件替换、符号导航和语义搜索尚未实现。准确跳转应先作为这些能力的底层契约结果必须携带可验证身份打开必须面对过期数据执行端必须校验范围。结论OhMarkdown 将工作区结果跳转实现为一条可验证链路搜索阶段以 UTF-16 生成 offset 和真实 matchedText点击时重读最新文件原切片失效则选择最近相同文本匹配消失明确失败文档应用后切源码CodeMirror 再次验证范围、选中文本、滚动并归还焦点。模拟器第 430 行SRC-003、32 ms 两文件搜索、Playwright30/30与 ohosTest7/7共同证明当前闭环。相比只跳旧行号这套实现更能应对中文、emoji 坐标和文件变化也为后续索引与导航能力建立了“不静默跳错”的可靠性底线。