Hunk 扩展 API 边界实测:从 review-triage 扩展看终端审阅扩展的能力与缺口

原创2026-09-24 23:32:101,447 阅读
文章标签:开发工具代码评审CLIAI 应用

Hunk 扩展 API 边界实测:从 review-triage 扩展看终端审阅扩展的能力与缺口

本文是一份基于 Hunk 官方示例扩展 review-triage 的扩展 API 实战评估报告。它既还原了该扩展的完整实现(右侧面板、命令、对话框、生命周期与事件总线),也逐条核对了 Hunk 扩展 API 当前已解决与仍然开放的设计边界,帮助第三方扩展作者判断「哪些能力今天就能用、哪些还需要绕过或等待」。文中的“当时状态”对应示例引入时(2026 年 7 月)的 API,而“当前状态”则以本仓库现存的 扩展指南 与 扩展架构文档 为准。

一、样本:一个“刻意普通”的可安装扩展

examples/extensions/review-triage/ 是一个会话级(session-local)hunk 分诊面板(triage board):审阅者可以打开右侧栏,遍历公开的 hunk 摘要,把当前 hunk 标记为 approved / investigate / blocked(可附带理由),也能清除全部决策。它的所有命令都是普通 Extensions 菜单条目,生命周期与总线事件则让面板始终跟随审阅状态更新。

它被刻意设计为“只依赖 hunkdiff/extension 公开契约”的用户扩展,不 import Hunk 内部任何模块。构建它验证了 API 的中央路径:一个第三方扩展可以只靠公开 API 组合出 React 侧栏、菜单可达命令、宿主拥有的模态对话框、选区快照、生命周期订阅、通知和小型扩展间总线。

1.1 运行与安装方式

从本仓库直接以单次运行方式加载(不安装、不落盘):

bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-triage

也可以把整个目录复制到 Hunk 扩展目录,并保留其 package.json——清单把该文件夹声明为单一 review-triage 扩展:

{
  "name": "hunk-review-triage-extension",
  "private": true,
  "hunk": { "extensions": ["./index.tsx"] }
}

启用后,通过 Extensions → Toggle review triage(快捷键 Y)打开右侧面板。面板按文件列出每个可见 hunk,点击一行即可驱动审阅流跳转;Extensions → Mark selected hunk…(x)选择状态并输入可选理由;Center current review line、Set review focus…、Clear triage decisions 是仅菜单命令。PTY 集成测试会直接加载这个真实目录(而非字符串夹具),见 test/pty/extensions-integration.test.ts:测试断言菜单中出现 Toggle review triage(带 Y 键位)、Mark selected hunk…(带 x 键位)以及三条菜单命令,从而证明这些命令是真实的 Extensions 菜单项而非私有钩子。

二、逐条发现:五个缺口与它们的当前状态

2.1 侧栏几何与“选中跟随”曾缺失(部分解决)

当时状态: 公开的侧栏 props 只有宽度(width),没有面板高度、视口边界、滚动位置,也没有把某个条目滚入视野的能力。内置文件侧栏使用宿主内部的 ScrollBoxRenderable 视口事件和 scrollChildIntoView,第三方侧栏无法复刻它的 windowing 或选中跟随行为。因此 review-triage 只能用一个简单 scrollbox 加紧凑行,在大审阅中选中的 hunk 可能滚出视野。

当前状态: 面板 props 现已暴露宿主拥有的精确宽高(width 与 height),但视口边界与宿主导航滚动能力仍未公开。不过,扩展指南给出了替代契约:滚动:scrollbox ref 契约——给行设置稳定的 id props、持有 scrollbox 的 ref,并在 effect 里调用 scrollChildIntoView(id) 跟随选中:

import { useEffect, useRef } from "react";
import type { ScrollBoxRenderable } from "@opentui/core";
import type { ExtensionPaneProps } from "hunkdiff/extension";

function HunkList({ files, selectedFileId, selectedHunkIndex, theme, actions }: ExtensionPaneProps) {
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);

  useEffect(() => {
    if (selectedFileId !== null) {
      scrollRef.current?.scrollChildIntoView(`row-${selectedFileId}-${selectedHunkIndex ?? 0}`);
    }
  }, [selectedFileId, selectedHunkIndex]);

  return (
    <scrollbox ref={scrollRef} width="100%" height="100%" scrollY={true} focused={false}>
      {files.flatMap((file) =>
        (file.hunks ?? []).map((hunk) => {
          const selected = file.id === selectedFileId && hunk.index === selectedHunkIndex;
          return (
            <box
              key={`${file.id}:${hunk.index}`}
              id={`row-${file.id}-${hunk.index}`}
              style={{ width: "100%", height: 1 }}
              onMouseUp={() => actions.selectHunk(file.id, hunk.index)}
            >
              <text content={` ${file.path}  ${hunk.header}`} style={{ fg: selected ? theme.accent : theme.text }} />
            </box>
          );
        }),
      )}
    </scrollbox>
  );
}

这个 ref 面正是内置文件侧栏赖以运行的同一套调用:scrollChildIntoView(id)、scrollTop 与 viewport.height 读数、verticalScrollBar.on("change") / viewport.on("layout-changed") / viewport.on("resized") 事件,足够自行 window 一个长列表。文档诚实标注了代价:该契约骑在 OpenTUI renderable API 上,比 hunkdiff/extension 本身更宽。

建议补全(原报告): 暴露只读的面板视口几何,外加一个窄的 actions.scrollItemIntoView(id) 能力(或受支持的 scrollbox ref 契约),让扩展能虚拟化并保持选中可见,而不必暴露 OpenTUI 内部。

2.2 没有安全的、宿主托管的持久化(仍未解决)

分诊板只能会话内存活。原因很实际:

  • 扩展配置不可信。 hunk.config([extension.<id>] 表)是仓库可覆盖的,且明确不能用于 exec-adjacent 决策(二进制路径、shell 命令、模块加载),把它当可写存储本身就是错误的用法;
  • 自行写文件策略各异。 每个扩展各自写任意文件会带来位置、生命周期、隐私、清理策略的分裂;
  • 身份不稳定。 reload 会改变 file id 或 hunk 索引,盲目持久化当前 key 会把决策错配到已变更的代码上。

review-triage 的源码恰好示范了“会话级 + 重载调和”的务实做法:reconcileChangeset 在 changeset_loaded / session_reload 时把 decisions / viewed / noteCounts 中所有不再存在的 hunk key 过滤掉,而不是把决策静默转移到新代码上(见 examples/extensions/review-triage/index.tsx)。

建议补全(原报告): 一个带命名空间的、用户所有的存储 API,带显式作用域(如 session 与 local-user/repository),外加可供对账的 changeset 身份;由 Hunk 拥有文件位置与信任语义。

2.3 命令处理器无法导航审阅流(已解决)

当时状态: 命令收到选区快照、对话框与侧栏开关控制,但没有 selectFile / selectHunk。于是“跳转到下一个 blocked hunk”这样的命令无法直接导航,只能依赖已挂载的侧栏执行导航——这在窄终端上既间接又不可靠。review-triage 因此故意不提供那条误导性命令,而是让 hunk 行可点击。

当前状态: ExtensionCommandContext.navigation 现已暴露带守卫的 selectFile、selectHunk、revealLine,与面板 actions 走的是同一条受守卫导航路径(实现见 packages/hunk/src/ui/lib/extensionNavigation.ts 的说明,行为契约见 docs/extensions.md)。与 selection 快照不同,navigation 是活的:调用作用于“此刻”的审阅流,所以先 await 对话框再导航依然有效;不可见的 file id 会被警告拒绝,hunk 索引会被夹取到真实范围。revealLine(fileId, side, line) 是粒度最细的目标——搜索命中、lint 发现、高亮标记所在行都适用——line 按侧边以 1 为基准;目标行落在折叠 gap、残缺补丁或当前行标记关闭处时,会软化降级到该 hunk。

2.4 对话框刻意简单,但分诊场景暴露了极限(保持现状 + 建议)

select / input 序列对“简短状态 + 单行理由”工作良好。review-triage 的标记流程正是如此:

const selectedStatus = await ctx.dialogs.select({
  title: `Triage ${file.path}, hunk ${hunkIndex + 1}`,
  options: ["approved", "investigate", "blocked"],
});
if (selectedStatus === null) return;
const rationale = await ctx.dialogs.input({
  title: `${selectedStatus}: optional rationale`,
  placeholder: "Why should a reviewer care?",
});

三种对话框形态(均返回 Promise):confirm({ title, body?, confirmLabel?, cancelLabel? }) → true | false;select({ title, options }) → 选中的字符串或 null;input({ title, placeholder?, initial? }) → 输入文本或 null。当前限制包括:没有结构化选项值(只有显示字符串)、没有校验钩子、没有多行输入,也没有在会话重载后保留对话框目标的能力。reload 取消是安全且正确的(重载会取消排队中的对话框,因为它们所询问的审阅正在被替换),但扩展必须围绕它设计。

建议补全(原报告): 保留当前简单原语,再考虑带标签的 { value, label } 选项与多行输入原语;对话框请求仍应在 reload 时取消,而不是作用于过期的审阅状态。

2.5 Extensions 菜单是命令生成的,而非可扩展布局(保持现状)

命令让扩展在 Extensions 菜单中可见,对这个工作流足够。但命令无法添加自定义子菜单、分隔线、选中态、禁用态,或菜单栏其他位置的条目。报告认为这是合适的初始边界,只是命令标题不得不承担比专用菜单模型更多的 UI 工作。

建议(原报告): 当前无需改动;若将来要加入更丰富的菜单集成,应建模为声明式命令状态,而不是在 chrome 里放任意扩展渲染物。架构文档印证了这一点:Extensions 菜单就是由已注册的扩展命令生成的(每个扩展一组、按扩展分组),完全没有扩展命令时菜单整体消失(见 docs/extension-architecture.md)。

三、被扩展确认为“非缺口”的五项能力

报告特意记录了 review-triage 证明的、当前 API 已足够的五个方向——这些对后来的扩展作者是直接的可行性背书:

  1. 宿主托管的 React 可正常渲染。 从磁盘加载的 React 组件能渲染进 Hunk 的树并使用 hooks,只要它 import 宿主提供的 react 模块。原理是宿主把 react、@opentui/*、hunkdiff/extension 重写为宿主实例背后的虚拟模块(见 packages/hunk/src/extensions/hostRuntimeModules.ts 的架构描述,位于 docs/extension-architecture.md)。切勿在扩展里打包第二份 React——第二个 hooks dispatcher 会让组件渲染失败。
  2. 公开的 hunk 摘要足以驱动 hunk 级面板。 每个文件的 hunks 提供公开的 ExtensionDiffHunk 摘要(index、@@ 头、含首尾的 old/new 行区间),selectedHunkIndex 与 actions.selectHunk(fileId, hunkIndex) 使用同一索引——无需触碰不透明的 diff metadata 就能渲染并驱动分诊板。
  3. useSyncExternalStore 是可行的桥。 生命周期回调运行在 React 之外,而面板组件只在 React 看到变化时重渲染;模块级 store + useSyncExternalStore 能连通两者,且面板关闭时 store 仍在累积。review-triage 正是这样做的:事件处理器更新模块级 snapshot,useTriageSnapshot() 订阅它(index.tsx)。快照必须不可变——替换引用而非原地修改,让 useSyncExternalStore 能按引用比较。
  4. 宿主渲染的对话框归属与模态行为正确。 来自已安装扩展的对话框带 ext <你的id> 归属行(与通知 toast 同一标记),第三方提示无法伪装成 Hunk 提问;命令注册则通过同一机制提供菜单与键盘访问。对话框一次只上一个,并发请求按调用顺序排队(跨扩展也如此),重载/关停时解析为取消值。
  5. 生命周期事件与命名空间总线足够做会话级协调。 只要扩展把它们当作观察者而非持久化或请求/响应通道即可。hunk.events 是全部已加载扩展共享的小总线:review-triage 在标记决策时 emit("review-triage:decision", …),并监听 review-triage:open 让另一个扩展无需 import 其模块状态即可打开面板(index.tsx 与 index.tsx)。总线与事件处理器还额外获得 panes、review.requestReload、events.emit;命令处理器额外获得 panes、selection、navigation、dialogs。

四、分诊板完整工作流中的 API 触点

把整份 review-triage 的注册逻辑作为“对照表”,可以看到一个生产形态扩展如何在注册期(factory 运行期间)一次性完成所有挂载:

export default function registerReviewTriage(hunk: HunkExtensionAPI) {
  hunk.registerPane({ id: "triage", title: "Review triage", placement: "right", component: ReviewTriagePane });
  hunk.registerCommand({ id: "toggle", title: "Toggle review triage", key: "Y" }, (ctx) => ctx.panes.toggle("triage"));
  hunk.registerCommand({ id: "mark", title: "Mark selected hunk…", key: "x" }, async (ctx) => { /* select → input → setDecision → emit → notify */ });
  // …focus / clear / center 命令、生命周期与总线订阅
}
  • registerPane:渲染公开的 file/hunk 摘要,并用面板 actions(selectFile / selectHunk)导航审阅流。
  • registerCommand:提供 Extensions 菜单项与用户可重映射的默认键位;ctx.commands.execute("hunk.review.alignCurrentLineCenter") 把专用居中动作委托给 Hunk 的公开语义命令(不可用时 notify 给出警告)。命令处理器还通过 ctx.selection 拿到命令触发时的审阅位置快照(file + hunkIndex)。
  • dialogs.select / dialogs.input / dialogs.confirm:实现决策、焦点设置与清空流程。
  • 生命周期处理器:changeset_loaded / session_reload 调和决策、selection_changed 更新当前项、hunk_viewed 标记已看、note_created 累计 Hunk 审阅注释数、filter_changed 展示当前过滤、watch_reload_pending 显示 “Reload pending…”。
  • hunk.events:发布决策、监听 review-triage:open。

一个细节值得注意:命令处理器是快照式的(ctx.selection 反映命令触发时的位置),事件与总线处理器则是观察式的;任何跨表面共享的可变审阅代数据都应按键控的事件身份维护或替换。API 版本(当前为 28,导出为 HUNK_EXTENSION_API_VERSION,见 packages/hunk/src/extension-api/types.ts)可供单文件扩展分支支持多个 Hunk 版本。

五、结论:这份评估报告的使用方式

对扩展作者,这份报告给出了三条最实际的结论:

  1. 导航与滚动今天已可用:命令侧用 ctx.navigation(selectFile / selectHunk / revealLine),面板侧用 actions 与 scrollbox ref 契约——两者都带守卫、都有警告归属,且与内置文件面板走同一审阅控制器。
  2. 持久化仍是禁区:会话内 store + 重载调和是当前唯一稳妥范式;等待宿主托管的命名空间存储 API 落地前,不要自己发明跨会话落盘方案。
  3. 对话框与菜单保持简单是有意的:当前原语够用则用,若需要结构化选项或更丰富菜单,要么等待建议中的 { value, label } 选择项与声明式命令状态,要么在自己的 UI 层解决。

review-triage 之所以是“普通”示例,正是因为它只使用文档化契约、由真实测试驱动、并在源码中显式注释了每个边界(如“只清除本扩展的会话级决策,不触碰 Hunk 自己的审阅注释”)。把它的 完整源码、扩展指南 与 扩展架构文档 对照阅读,就能在一份可运行的样本上验证本文的全部结论。

登录后查看全文
hunk