鸿蒙 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。

export interface WorkspaceSearchResult {
  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-16

ArkTS/JavaScript 字符串的 indexOfslice.length 都以 UTF-16 code unit 计数,CodeMirror 6 的文档位置也与 JavaScript 字符串坐标兼容。搜索在解码后的 string 上运行,因此保存其 indexOf offset 可以直接交给 Web selection。

UTF-8 文件字节偏移不能直接用。中文通常占三个 UTF-8 字节,但 JavaScript length 为一个 code unit;emoji 在 UTF-8 常占四字节,在 UTF-16 占两个 code unit。如果用字节 offset 选 CodeMirror,前面每个中文和 emoji 都会累积误差。

Unicode code point 数也不能直接使用,因为 CodeMirror/JS 仍以 UTF-16 code unit 定位。项目选择与执行端一致的坐标系,避免每次 Bridge 传输再转换。文章把它明确写作 UTF-16 偏移,而不是模糊“字符位置”。

匹配阶段生成 offset、line 与 column

TaskPool 中的 searchDocumentContent 在内容字符串上运行。普通大小写敏感路径使用 indexOf;不敏感路径用转义后的全局 RegExp;正则模式使用用户表达式。所有 match.index 与 query.length 都是 UTF-16 单位。

if (options.caseSensitive) {
  let from: number = 0;
  let offset: number = content.indexOf(query, from);
  while (offset >= 0) {
    canAppend(offset, query.length);
    if (foundMore) break;
    from = offset + Math.max(1, query.length);
    offset = content.indexOf(query, from);
  }
} else {
  const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const matcher = new RegExp(escapedQuery, 'gi');
  let match: RegExpExecArray | null = matcher.exec(content);
  while (match !== null) {
    canAppend(match.index, match[0].length);
    match = matcher.exec(content);
  }
}

行号由 offset 前的换行数量得到,列号由最近行首 offset 相减加一。它们用于人类阅读,是 1-based;CodeMirror offset 是 0-based。把两种基数混在一个字段是常见 off-by-one 来源,当前模型命名和使用位置明确区分。

预览截取匹配所在单行上下文,去除或限制过长内容。列表显示预览,点击仍使用 URI/offset/matchedText,不从渲染后的预览反推位置。

正则零长度匹配必须前进

用户正则可能产生零长度匹配,例如边界表达式。如果 matcher.lastIndex 不前进,全局循环会无限重复同一位置。实现遇到 match[0].length === 0 时手动增加 lastIndex。

let match: RegExpExecArray | null = matcher.exec(content);
while (match !== null) {
  canAppend(match.index, match[0].length);
  if (foundMore) break;
  if (match[0].length === 0) {
    matcher.lastIndex += 1;
  }
  if (taskpool.Task.isCanceled()) {
    throw new Error('Workspace search canceled.');
  }
  match = matcher.exec(content);
}

canAppend 拒绝 length <= 0,所以零长度结果不会进入列表,但循环仍必须前进。正则无效时抛出明确错误;取消时停止 TaskPool,不让旧搜索继续生成结果。

这些保护发生在搜索阶段,却直接影响跳转可靠性。结果列表中每个正文项都保证 matchedText 非空,打开时最近命中循环才能按至少一字符前进。

打开时必须重新读取目标文件

搜索结果只是某一时刻的快照。用户点击前,Git checkout、同步软件、其他编辑器或当前应用可能已经修改文件。openWorkspaceSearchResult 不直接激活搜索缓存内容,而是调用 readUtf8Document(result.uri) 读取最新文档。

private async openWorkspaceSearchResult(result: WorkspaceSearchResult): Promise<void> {
  if (this.operationInProgress) return;
  this.operationInProgress = true;
  this.operationStatus = 'Opening search result...';
  try {
    const openedDocument = await readUtf8Document(result.uri);
    const offset = resolveWorkspaceSearchOffset(openedDocument.content, result);
    if (offset < 0) {
      throw new Error('The match changed on disk; run the workspace search again.');
    }
    await this.applyOpenedDocument(openedDocument);
    // 后续切换源码并调用 Web 跳转。
  } finally {
    this.operationInProgress = false;
  }
}

重新读取继承文档服务的 UTF-8、BOM、LF/CRLF、大小和错误边界。目标文件被删除、权限失效或编码非法时,打开失败,原标签和文档不被伪造替代。

operationInProgress 防止双击或连续 Enter 并发打开两个会话。状态栏先显示 Opening,结束后显示具体路径和行号或失败原因。

原 offset 仍真实时走快速路径

resolveWorkspaceSearchOffset 首先处理快速打开 file 结果返回 0。正文结果检查 offset 非负,并比较最新内容在 [offset, offset + matchedText.length) 的切片是否完全等于旧 matchedText。相等就直接返回。

export function resolveWorkspaceSearchOffset(
  content: string,
  result: WorkspaceSearchResult
): number {
  if (result.kind === 'file') {
    return 0;
  }
  if (result.offset >= 0 &&
    content.slice(result.offset, result.offset + result.matchedText.length) ===
      result.matchedText) {
    return result.offset;
  }
  // 偏移失效时查找最近真实命中。
}

快速路径不重新遍历全文,绝大多数未变化文件只做一次 slice。比较区分大小写,因为 matchedText 是当时实际字符;即使原搜索不敏感,也应确认同一个可见文本仍在原位。

没有额外比较 line/column。只要匹配切片仍在 offset,前面行结构也必然没有改变到影响该位置;结果列表行号仍是搜索时数据,状态栏打开后当前代码仍显示旧 line,这在前面不影响 offset 的变化场景通常一致。若文件局部换行变化但 offset/文本巧合,状态栏行号可能陈旧,后续可从最新内容重算。

偏移失效时选择最近相同文本

如果原切片不匹配,函数遍历最新内容中所有 matchedText,计算与旧 offset 的绝对距离,保留最小值。这样在文件头插入几行后,原目标通常整体后移,最近项仍是它;同一文本多次出现时比“永远第一个”更接近用户上下文。

let bestOffset = -1;
let bestDistance = Number.MAX_SAFE_INTEGER;
let candidate = content.indexOf(result.matchedText);
while (candidate >= 0) {
  const distance = Math.abs(candidate - result.offset);
  if (distance < bestDistance) {
    bestOffset = candidate;
    bestDistance = distance;
  }
  candidate = content.indexOf(
    result.matchedText,
    candidate + Math.max(1, result.matchedText.length)
  );
}
return bestOffset;

步进至少 1,避免空字符串循环;正常搜索结果 matchedText 本就非空。相同距离时保留先出现者,结果稳定。复杂度 O(n × 比较成本),只在打开单个失效结果时执行,不是所有搜索结果都重扫。

这不是语义 diff。若同文本被删除而另一个副本恰好更近,仍可能跳到不同语义位置。当前策略可解释、成本低,并在找不到任何匹配时明确失败;更强上下文重定位可利用 preview 前后文,但需要独立测试。

为什么不用旧行号直接跳

文件开头插入一行,旧行 430 的目标可能变成 431;按 430 跳会选错。删除前面内容同理。行号只能表达搜索时的人类位置,无法确认内容身份。

按 preview 查找也不可靠,预览可能截断、去空白或包含同一行其他内容。按 query 查找会丢失正则实际匹配和大小写。matchedText + old offset 最近距离 保留了最少但有效的信息。

状态栏仍显示 Opened requirements.md:430 作为原结果反馈。后续若重定位发生,应重算新行列并显示“已重定位到 431”,让用户知道文件变化。当前失败路径已避免静默错误,但成功重定位的可见解释仍可增强。

应用文档后再调用 Web

resolve 成功后,原生先 applyOpenedDocument 建立或激活正确会话,再强制 viewMode='source',调用 Web setMode('source'),最后传 offset 和 matchLength。不能先在旧文档中跳转再替换正文。

await this.applyOpenedDocument(openedDocument);
this.viewMode = 'source';
this.setEditorMode('source');
const matchLength = result.kind === 'text' ? result.matchedText.length : 0;
const jumpResult = await this.editorController.runJavaScript(
  `window.OhMarkdownEditor?.jumpToOffset(${offset}, ${matchLength}) === true`
);
if (jumpResult !== 'true') {
  throw new Error('The editor could not select the search result.');
}

参数都是经过整数计算的数字,不直接拼用户字符串。Web 返回 boolean,经 runJavaScript 编码后严格比较 'true'。失败不会显示假成功状态。

applyOpenedDocument 复用文件树打开路径,保留 BOM、换行、指纹、多标签和冲突检查。搜索跳转没有创建绕过文档安全的“快速打开”分支。

CodeMirror 做最终范围验证

Web jumpToOffset 再次检查 offset 与 length 都是整数、非负,且总和不超过当前文档长度。原生和 Web 之间可能因异步 session 更新出现差异,第二层验证保护 EditorView transaction。

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

正文结果选中完整 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。

const result: 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

全量 Playwright 30/30。最终 Debug HAP 大小 1,520,352 字节,SHA-256 367ab8650479aa1fa8fe73bd1ebadd9a53f46659c850c2e388fc799d5cb88e5b;ohosTest HAP 大小 2,360,824 字节,SHA-256 b7230037b51044fe16168d2c835fb891e1c70f675941a1046165bc895217592c。两份为未签名测试产物。

测试分层分别证明纯算法、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 两文件搜索、Playwright 30/30 与 ohosTest 7/7 共同证明当前闭环。相比只跳旧行号,这套实现更能应对中文、emoji 坐标和文件变化,也为后续索引与导航能力建立了“不静默跳错”的可靠性底线。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐