Merge pull request #8126 from deepseek-harness/perf/forge-changes-view

perf(forge): keep the changes view responsive on large diffs
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-11-09 13:34:23 +08:00
commit 544fdb0b9c
51 files changed
+794 -147

No files matched your search

@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-11-07-forge-changes-view-responsiveness.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md
/agent-note-forge-changes-view-responsiveness:
en: e809ffea0d880a4b
zh: 6725e6a9bd1c3bd1
/agent-note-forge-changes-view-responsiveness/problem:
en: 77fab27f3d4a4a59
zh: 46e0fa8b9ef978a7
/agent-note-forge-changes-view-responsiveness/decision:
en: 40550cf129ab4c0c
zh: 2e9a3597d03ce185
/agent-note-forge-changes-view-responsiveness/alternatives-considered:
en: c9833999164e8b67
zh: 9459a95f307db854
/agent-note-forge-changes-view-responsiveness/consequences:
en: 2d74147e306ed174
zh: f1369020f27f92c0
@@ -0,0 +1,41 @@
# Agent Note: Forge changes view responsiveness
Status: implemented
English | [中文](2026-11-07-forge-changes-view-responsiveness.zh.md)
## Problem
The forge changes view of a large change request froze the browser. Opening #7985 (366 files, 37,563 drawn lines) kept the main thread busy for about five seconds, "expand all" for fifteen, and every later state change, such as a poll result, a tool result, a file jump, or a keystroke in the file filter, re-rendered every file section. While the view was visible, the whole app slowed down: two subject-less `:has()` rules in `ui-sidebar-files` made each DOM change restyle the entire document, and the view's hundred thousand nodes made each restyle cost 170–260 ms, so a streaming Agent turn kept the main thread saturated.
## Decision
**Scoped `:has()` rules.** The navigator's covering rules name their row through `data-files-navigator-row`, which both hosts of `sidebar.files.navigator` set, so a DOM change outside such a row schedules no document-wide invalidation.
**Sections draw what changed and what the reader reaches.** `ChangesTab` passes each memoized `DiffFile` values whose identity survives its renders: one handler object, one comment-actions object, and per-file strings and booleans. Tab visibility is a prop of every section, so a visibility change redraws them all; leaving the view measured +57 ms against reading it from a React context, which the client packages forbid business components to do. The scrollport's reach tracker travels in the handler object. An expanded file holds a 22 px row per drawn line until its section first comes within 1200 px of the scrollport, then draws its lines and keeps them. After a jump, the browser's scroll anchoring keeps the target in place while the sections above it draw.
**Highlighting in the drawing render.** A section highlights its lines in the same render that draws them, as before, so the drawn text is always the file's current text and a native selection keeps its text nodes.
**Fewer serial reads.** A panel state equal to the published one reaches no follower. `changeRequestDiff` reads the patch and `.gitattributes` together. Snapshots carry the base commit as `baseSha`; `ForgeService.diff` passes it to the provider, and forge-github starts the compare read while its GraphQL read confirms the head.
**Pixel measurement.** Whether a section is near the scrollport decides whether React renders its lines at all, which CSS cannot express: `content-visibility` only skips layout and paint of nodes that already exist. The client packages offer no injected measurement hook, so one `IntersectionObserver` on the scrollport measures it off the main thread.
## Alternatives considered
**`content-visibility: auto` alone** cut style work as much as scoping the rules did, but kept the React render and Shiki tokenization of every expanded file at open, about 3 s of script, and its placeholder sizes would need estimates for every collapsed and expanded section.
**Full virtualization that also unmounts far sections** would bound the DOM after long scrolls, but a section holds a comment draft and a text selection, which unmounting loses.
**Deferred idle-time highlighting** drew plain text and applied tokens once the page was idle and the scrollport still. It kept fast scrolling smooth, but tokens carry their own text, so a file whose content changed at the same path showed its previous text until the next pass, and replacing the plain text nodes collapsed a selection made in the meantime. Against it, highlighting while drawing raised the blocking time of scrolling all of #7985 from 22 ms to 1,634 ms and its longest frame gap from 34 ms to 817 ms; other operations stayed within 0.1 s.
**The CSS Custom Highlight API** would paint token colors over unchanged text nodes and keep deferral without either defect, but it cannot apply the themes' bold and italic, and its cost over tens of thousands of ranges is unmeasured.
**Re-aligning a jumped-to section every frame for a second** kept the last file flush with the end of the list, but it also pulled back any scroll it did not start, such as one from find in page; scroll anchoring already holds every other target at the top.
**Highlighting in a Web Worker** removes tokenization from the main thread entirely, including a heavy grammar's first use, but the shared highlighter in `ui-primitives` owns Shiki; moving it is a separate change for every consumer.
**A provider-private cache of base commits** would avoid the optional `baseSha` field, but it adds retained state without an owner that knows when a change request stops being watched.
## Consequences
Opening #7985 now takes 0.5 s of main-thread work instead of 5.2 s, and file jumps, filtering, polling, and Agent turns no longer produce long tasks. In exchange, the browser's find in page does not see lines of files the reader has not approached, and scrolling onto files in a slow grammar's language, such as C++, highlights them during the scroll, up to about 0.8 s of main-thread work when that grammar is first used. A diff read costs a second compare request when the base commit changed after the latest status.
@@ -0,0 +1,41 @@
# Agent Note: Forge 变更视图的响应速度
Status: implemented
[English](2026-11-07-forge-changes-view-responsiveness.md) | 中文
## Problem
大型变更请求的 forge 变更视图会让浏览器卡住。打开 #7985(366 个文件、37,563 个绘制行)时主线程忙约 5 秒,“全部展开”约 15 秒;之后的每次状态变化,例如轮询结果、工具结果、跳转文件或在文件筛选框中按键,都会重渲染所有文件段。视图可见时整个应用都变慢:`ui-sidebar-files` 中两条没有主体的 `:has()` 规则让每次 DOM 变化都重算整个文档的样式,而视图的十万个节点让每次重算耗时 170–260 ms,所以 Agent 流式输出的回合会让主线程一直饱和。
## Decision
**限定 `:has()` 规则。** 导航器的遮盖规则通过 `data-files-navigator-row` 指定所在行,`sidebar.files.navigator` 的两个宿主都会设置它,因此这类行之外的 DOM 变化不再触发全文档失效。
**文件段只绘制变化的部分和读者接近的部分。** `ChangesTab` 传给每个 memo 化的 `DiffFile` 的值在多次渲染之间保持同一标识:一个处理函数对象、一个评论操作对象,以及每个文件的字符串和布尔值。标签页可见性作为每个文件段的参数传入,可见性变化时全部文件段都会重绘;离开视图实测比从 React context 读取多 57 ms,而客户端包禁止业务组件读取 context。滚动区域的接近跟踪对象放在处理函数对象中传递。展开的文件在它的段第一次进入滚动区域 1200 px 范围之前,按每个绘制行 22 px 占位,之后绘制各行并一直保留。跳转后,上方文件段绘制时,浏览器的滚动锚定让目标保持原位。
**在绘制的渲染中高亮。** 文件段与修复前一样,在绘制各行的同一次渲染中完成高亮,所以画出的始终是文件当前的文本,原生选区所在的文本节点也不会被替换。
**减少串行读取。** 与已发布状态相同的面板状态不会通知任何关注方。`changeRequestDiff` 同时读取补丁和 `.gitattributes`。快照把基准提交作为 `baseSha` 携带;`ForgeService.diff` 把它传给提供方,forge-github 在 GraphQL 读取确认 head 的同时就开始 compare 读取。
**像素测量。** 文件段是否接近滚动区域,决定 React 是否渲染它的各行,这一点 CSS 无法表达:`content-visibility` 只能跳过已有节点的布局与绘制。客户端包没有可注入的测量 hook,所以在滚动区域上用一个 `IntersectionObserver`,在主线程之外测量。
## Alternatives considered
**只用 `content-visibility: auto`** 减少的样式工作与限定规则相当,但打开时仍要对每个展开文件做 React 渲染和 Shiki 分词,约 3 秒脚本时间,而且每个折叠和展开的段都需要估计占位尺寸。
**同时卸载远处文件段的完整虚拟化** 可以在长距离滚动后限制 DOM 规模,但文件段持有评论草稿和文本选区,卸载会丢失它们。
**空闲时延迟高亮** 先画纯文本,等页面空闲且滚动区域静止后再应用 token。它让快速滚动保持流畅,但 token 自带文本,同一路径内容变化后会显示旧文本直到下一轮高亮完成;替换纯文本节点还会清掉期间做出的选区。与它相比,绘制时高亮让滚动整个 #7985 的阻塞时间从 22 ms 增到 1,634 ms,最长帧间隔从 34 ms 增到 817 ms;其他操作的差异在 0.1 秒以内。
**CSS Custom Highlight API** 可以在不变的文本节点上绘制 token 颜色,保留延迟高亮又没有上述两个问题,但它无法应用主题中的粗体和斜体,几万个范围的绘制成本也没有测过。
**跳转后一秒内每帧重新对齐** 能让最后一个文件贴紧列表末端,但它也会把不是它发起的滚动拉回去,例如页内查找引起的滚动;而对其他目标,滚动锚定已经能让它们保持在顶部。
**在 Web Worker 中高亮** 能把分词完全移出主线程,包括重型语法的首次使用,但 Shiki 由 `ui-primitives` 中的共享高亮器持有;迁移它是影响所有使用方的另一项改动。
**由提供方私有缓存基准提交** 可以省去可选的 `baseSha` 字段,但会引入保留状态,而且没有一个知道变更请求何时不再被 watch 的所有者。
## Consequences
打开 #7985 的主线程工作从 5.2 秒降到 0.5 秒,跳转文件、筛选、轮询和 Agent 回合不再产生长任务。代价是:浏览器的页内查找看不到读者尚未接近的文件中的行;滚动到慢语法(例如 C++)的文件时会在滚动中完成高亮,该语法首次使用时最多约 0.8 秒的主线程工作。最新状态之后基准提交有变化时,一次 diff 读取会多一次 compare 请求。
+9 -2
View File
@@ -141,7 +141,7 @@ if (args[0] === 'api' && args[1] === 'graphql') {
const pulls = {
7: {
id: 'pull-7', number: 7, url: 'https://github.com/octo/demo/pull/7', title: 'Add the pull request panel', state: 'OPEN', isDraft: false,
updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40),
updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40), baseRefOid: BASE,
baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED',
reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ isResolved: false }] },
commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-7', state: 'FAILURE', contexts: {
@@ -152,7 +152,7 @@ if (args[0] === 'api' && args[1] === 'graphql') {
9: {
id: 'pull-9', number: 9, url: 'https://github.com/octo/demo/pull/9', title: 'Add the merge button', state: state.mergedAt === null ? 'OPEN' : 'MERGED',
isDraft: !state.ready, updatedAt: '2026-10-09T00:00:00Z', closedAt: state.mergedAt, author: { login: 'octo' },
headRefName: 'feat/merge', headRefOid: heads()[9], baseRefName: 'main', additions: 30, deletions: 2, mergeable: 'MERGEABLE',
headRefName: 'feat/merge', headRefOid: heads()[9], baseRefName: 'main', baseRefOid: BASE, additions: 30, deletions: 2, mergeable: 'MERGEABLE',
mergeStateStatus: state.ready ? 'CLEAN' : 'BLOCKED', reviewDecision: 'APPROVED',
reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-9', state: 'SUCCESS', contexts: {
@@ -1175,6 +1175,8 @@ it.skipIf(MODE === 'record')('retains a wrapped code line and tree width across
return { path: element.getAttribute('data-forge-diff-file'), line: line.dataset.newLine,
offset: line.getBoundingClientRect().top - top, scrollTop: viewport.scrollTop }
})
// An expanded file draws its lines once the reader scrolls near it.
await file.scrollIntoViewIfNeeded()
await code.waitFor()
for (const width of [240, 320]) {
if (width === 320) {
@@ -1252,6 +1254,11 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre
expect(await scroll.evaluate(element => document.activeElement === element)).toBe(true)
await page.keyboard.press('ArrowRight')
await expect.poll(() => tree.getByRole('button', { name: 'README.md', exact: true }).getAttribute('aria-current')).toBe('true')
// The last file draws its lines after the jump, and scroll anchoring adjusts the position at the next layout.
await c.locator('[data-review-code]').first().waitFor()
await scroll.evaluate(() => new Promise<void>((resolve) => {
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
}))
const lastPosition = await scroll.evaluate(element => element.scrollTop)
const lastBox = await c.boundingBox(), viewport = await scroll.boundingBox()
if (lastBox === null || viewport === null) throw new Error('The last file and scroll viewport must be rendered')
+1 -1
View File
@@ -1188,7 +1188,7 @@ export type SourceSigning = 'ad-hoc' | {
## `@deepseek-ai/dsh-experimental-forge`
- `source`: [`packages/experimental/forge/src/types.ts:273`](../packages/experimental/forge/src/types.ts)
- `source`: [`packages/experimental/forge/src/types.ts:279`](../packages/experimental/forge/src/types.ts)
```ts config-catalog
/** Poll cadence and fetch deadline for watched change requests. */
+1 -1
View File
@@ -1190,7 +1190,7 @@ export type SourceSigning = 'ad-hoc' | {
## `@deepseek-ai/dsh-experimental-forge`
- `source`: [`packages/experimental/forge/src/types.ts:273`](../packages/experimental/forge/src/types.ts)
- `source`: [`packages/experimental/forge/src/types.ts:279`](../packages/experimental/forge/src/types.ts)
```ts config-catalog
/** Poll cadence and fetch deadline for watched change requests. */
+10 -3
View File
@@ -71,9 +71,10 @@ interface ForgeProvider {
/**
* Read the changes of a change request at head commit `headSha` as the host computes them, as one unified
* patch with `a/` and `b/` path prefixes. A patch is returned only when it describes `headSha`; once the
* head is another commit, the read resolves to a `stale` failure.
* head is another commit, the read resolves to a `stale` failure. `base`, when given, is the `baseSha` the
* latest status recorded with `headSha`; a provider may start reading from it before it confirms the head.
*/
diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure>
diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise<string | ForgeFailure>
/** Read the text of `path` at `commit` in the change request's repository. */
fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>
/** Build the web page of `path` at `commit` in the change request's repository. */
@@ -199,6 +200,11 @@ interface ChangeRequestSnapshot {
readonly headSha: string
/** Branch the change request merges into. */
readonly baseBranch: string
/**
* Base commit the host recorded with `headSha`, whose merge base with the head starts the change request's diff;
* omitted when the provider does not report it.
*/
readonly baseSha?: string
/** Lines the change request adds. */
readonly additions: number
/** Lines the change request deletes. */
@@ -907,7 +913,8 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise<ChangeReq
* computes them. The first `stale` failure at a head commit polls a watched
* change request again at once, so its next status names the current head;
* later stale reads at the same commit wait for the regular poll, which
* bounds the reads while the host's answers disagree.
* bounds the reads while the host's answers disagree. When the latest status of a watched change request names
* `headSha`, its `baseSha` goes to the provider, which may then read the patch while it confirms the head.
* @param ref - change request served by a registered provider.
* @param headSha - head commit the patch must describe, usually the head of the latest status.
* @param signal - cancellation of the read.
+10 -3
View File
@@ -71,9 +71,10 @@ interface ForgeProvider {
/**
* Read the changes of a change request at head commit `headSha` as the host computes them, as one unified
* patch with `a/` and `b/` path prefixes. A patch is returned only when it describes `headSha`; once the
* head is another commit, the read resolves to a `stale` failure.
* head is another commit, the read resolves to a `stale` failure. `base`, when given, is the `baseSha` the
* latest status recorded with `headSha`; a provider may start reading from it before it confirms the head.
*/
diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure>
diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise<string | ForgeFailure>
/** Read the text of `path` at `commit` in the change request's repository. */
fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>
/** Build the web page of `path` at `commit` in the change request's repository. */
@@ -199,6 +200,11 @@ interface ChangeRequestSnapshot {
readonly headSha: string
/** Branch the change request merges into. */
readonly baseBranch: string
/**
* Base commit the host recorded with `headSha`, whose merge base with the head starts the change request's diff;
* omitted when the provider does not report it.
*/
readonly baseSha?: string
/** Lines the change request adds. */
readonly additions: number
/** Lines the change request deletes. */
@@ -907,7 +913,8 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise<ChangeReq
* computes them. The first `stale` failure at a head commit polls a watched
* change request again at once, so its next status names the current head;
* later stale reads at the same commit wait for the regular poll, which
* bounds the reads while the host's answers disagree.
* bounds the reads while the host's answers disagree. When the latest status of a watched change request names
* `headSha`, its `baseSha` goes to the provider, which may then read the patch while it confirms the head.
* @param ref - change request served by a registered provider.
* @param headSha - head commit the patch must describe, usually the head of the latest status.
* @param signal - cancellation of the read.
@@ -15,8 +15,8 @@
en: 0dd8c3a22e28aacf
zh: c27c11bf4b5cceba
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/what-it-registers:
en: 5686f49d98437f49
zh: 40c154f8cc7b8d14
en: 691249b09395eae1
zh: e2ae49df2ebe130e
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses:
en: 57942270e23d3a8e
zh: 6617124e652b3f67
@@ -31,7 +31,7 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo
Image drag actions arrive through the image entry’s inject face from [ui-file-drag](../ui-file-drag/README.md).
- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, a whole-address file-resource pattern, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls and before the navigator toggle, at the same position in every state that shows the header so it is not remounted when the state changes, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). The preview directly renders [`ui-sidebar-files`](../ui-sidebar-files/README.md#the-navigator)'s Session-scoped Factories: `sidebar.files.navigator` after the body in its content row with `{ useTabInfo, page: false, onOpen }`, where `onOpen(address)` calls `tab.actions.openResource(address, { replaceTab: true })`, and `sidebar.files.navigator.toggle` at the header's end with `{ useTabInfo }`. Both stay mounted from loading to loaded in docked and floating panes; unregistered Factories render nothing. The navigator owns its width within the row's positioning box and stacking context, narrowing or overlaying the body without covering the floating pane's resize handle. A renderer layer handling Escape calls `preventDefault()` to keep the navigator from also closing. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the file tree's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls and before the navigator toggle, at the same position in every state that shows the header so it is not remounted when the state changes, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). The preview directly renders [`ui-sidebar-files`](../ui-sidebar-files/README.md#the-navigator)'s Session-scoped Factories: `sidebar.files.navigator` after the body in its content row, which carries `data-files-navigator-row`, with `{ useTabInfo, page: false, onOpen }`, where `onOpen(address)` calls `tab.actions.openResource(address, { replaceTab: true })`, and `sidebar.files.navigator.toggle` at the header's end with `{ useTabInfo }`. Both stay mounted from loading to loaded in docked and floating panes; unregistered Factories render nothing. The navigator owns its width within the row's positioning box and stacking context, narrowing or overlaying the body without covering the floating pane's resize handle. A renderer layer handling Escape calls `preventDefault()` to keep the navigator from also closing. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the file tree's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. `sourceLines` declares that a `text-pages` body exposes source-line anchors for [navigation](#navigation). Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode.
@@ -31,7 +31,7 @@ kind: "package-reference"
图片拖放回调通过图片入口的注入接口从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。
- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,匹配完整文件资源地址的 pattern,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后、导航栏开关之前,位置在各状态间相同,因此状态切换时不会重新挂载;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。预览直接渲染 [`ui-sidebar-files`](../ui-sidebar-files/README.zh.md#the-navigator) 的 Session 作用域 Factory:`sidebar.files.navigator` 位于内容行的正文之后,传入 `{ useTabInfo, page: false, onOpen }`,其中 `onOpen(address)` 调用 `tab.actions.openResource(address, { replaceTab: true })`;`sidebar.files.navigator.toggle` 位于头部末端,传入 `{ useTabInfo }`。两者在停靠与浮动分栏中从加载到完成保持挂载;未注册的 Factory 不绘制内容。导航栏在内容行的定位框与层叠上下文内决定宽度,让正文变窄或覆盖正文,不遮挡浮窗缩放手柄。自行处理 Escape 的渲染器浮层需调用 `preventDefault()`,避免同时关闭导航栏。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与文件树右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后、导航栏开关之前,位置在各状态间相同,因此状态切换时不会重新挂载;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。预览直接渲染 [`ui-sidebar-files`](../ui-sidebar-files/README.zh.md#the-navigator) 的 Session 作用域 Factory:`sidebar.files.navigator` 位于内容行的正文之后(该行带有 `data-files-navigator-row`),传入 `{ useTabInfo, page: false, onOpen }`,其中 `onOpen(address)` 调用 `tab.actions.openResource(address, { replaceTab: true })`;`sidebar.files.navigator.toggle` 位于头部末端,传入 `{ useTabInfo }`。两者在停靠与浮动分栏中从加载到完成保持挂载;未注册的 Factory 不绘制内容。导航栏在内容行的定位框与层叠上下文内决定宽度,让正文变窄或覆盖正文,不遮挡浮窗缩放手柄。自行处理 Escape 的渲染器浮层需调用 `preventDefault()`,避免同时关闭导航栏。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与文件树右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。`sourceLines` 声明 `text-pages` 正文为[导航](#navigation)提供源码行锚点。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。
@@ -73,7 +73,7 @@ export function PreviewFrame({
{handoff}
<NavigatorToggleSeat useTabInfo={useTabInfo} renderFactorySlot={renderFactorySlot} />
</div>
<div className={css.content} data-textpreview-content>
<div className={css.content} data-textpreview-content data-files-navigator-row>
{children}
<NavigatorSeat useTabInfo={useTabInfo} renderFactorySlot={renderFactorySlot} />
</div>
@@ -861,6 +861,8 @@ describe('TextPreview — workspace navigator Factories', () => {
return {
toggle: header?.lastElementChild?.getAttribute('data-navigator-factory'),
navigator: row?.lastElementChild?.getAttribute('data-navigator-factory'),
// The navigator's filled layout hides the body of the row this attribute names.
navigatorRow: row?.hasAttribute('data-files-navigator-row'),
}
}
@@ -876,7 +878,7 @@ describe('TextPreview — workspace navigator Factories', () => {
])
expect(view.container.querySelector('[data-textpreview-state="loading"]')).not.toBeNull()
expect(seats(view.container)).toEqual({
toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator',
toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', navigatorRow: true,
})
const navigator = view.container.querySelector<HTMLElement>('[data-textpreview-content] > [data-navigator-factory]')!
const toggle = view.container.querySelector('[data-textpreview-path]')?.parentElement?.lastElementChild
@@ -890,7 +892,7 @@ describe('TextPreview — workspace navigator Factories', () => {
await settle()
expect(view.container.querySelector('[data-textpreview-state="text"]')).not.toBeNull()
expect(seats(view.container)).toEqual({
toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator',
toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', navigatorRow: true,
})
expect(view.container.querySelector('[data-textpreview-content] > [data-navigator-factory]')).toBe(navigator)
expect(view.container.querySelector('[data-textpreview-path]')?.parentElement?.lastElementChild).toBe(toggle)
@@ -18,8 +18,8 @@
en: c4b8f3a52f68bd51
zh: 30b580320637572c
/deepseek-ai-dsh-client-ui-sidebar-files/the-navigator:
en: 7e3aebc6079b023b
zh: 11911cc59fcec9b1
en: 3424600b15bde2d2
zh: 2cf1c99ffef2ad50
/deepseek-ai-dsh-client-ui-sidebar-files/the-tree:
en: 3d71e50801761213
zh: e4e9ab19dd22288d
+1 -1
View File
@@ -41,7 +41,7 @@ The [registration](src/client/index.ts) assembles the page, navigator, toggle, a
<a id="the-navigator"></a>
## The navigator
Render `sidebar.files.navigator` with `FileNavigatorInput`: `{ useTabInfo, page, onOpen }`. The required `onOpen: (address: string) => void` callback belongs to the containing plugin; the navigator builds the file address without choosing placement. `page` controls presentation only: `true` always fills the files page, while `false` follows the preview's pane preference and available width.
Render `sidebar.files.navigator` with `FileNavigatorInput`: `{ useTabInfo, page, onOpen }`. The required `onOpen: (address: string) => void` callback belongs to the containing plugin; the navigator builds the file address without choosing placement. `page` controls presentation only: `true` always fills the files page, while `false` follows the preview's pane preference and available width. Render the Factory as a direct child of the row it shares with the body, and mark that row with `data-files-navigator-row`: the layout that fills the row hides the row's other children only under that attribute, so a host without it leaves the covered body reachable by keyboard.
The navigator is drawn below the preview's header, which spans the pane, at the right of the row it shares with the body. Beside a preview, its pane's record decides whether it shows: a pane starts with it hidden, and the choice holds for every file tab of the pane until the toggle, Escape, the workspace command, or navigation to a covered preview changes it. A pane without a record of its own, such as a floating panel a tab was dragged out into, starts hidden at the default width. A click outside the tree does not hide it, and opening content in another pane does not hide it either. On the files page the tree fills the page below the header, docked or floating, without a separator, and always shows: the page draws no navigator toggle and Escape leaves it. A file the page opens starts with the destination pane's own choice, hidden for a pane that has made none.
@@ -41,7 +41,7 @@ kind: "package-reference"
<a id="the-navigator"></a>
## 导航栏
用 `FileNavigatorInput` 渲染 `sidebar.files.navigator`:`{ useTabInfo, page, onOpen }`。必填的 `onOpen: (address: string) => void` 回调由容纳它的插件提供;导航栏构造文件地址,不决定落位。`page` 只控制呈现:`true` 时始终铺满文件页,`false` 时遵循预览所在分栏的偏好与可用宽度。
用 `FileNavigatorInput` 渲染 `sidebar.files.navigator`:`{ useTabInfo, page, onOpen }`。必填的 `onOpen: (address: string) => void` 回调由容纳它的插件提供;导航栏构造文件地址,不决定落位。`page` 只控制呈现:`true` 时始终铺满文件页,`false` 时遵循预览所在分栏的偏好与可用宽度。Factory 要作为它与正文共用的那一行的直接子元素渲染,并给该行加上 `data-files-navigator-row`:铺满整行的布局只在带这个属性的行里隐藏其他子元素,宿主不加时,被盖住的正文仍可用键盘访问。
导航栏画在横跨分栏的预览标题行下方,位于它与正文共用的那一行的右侧。在预览旁,是否显示由其分栏的记录决定:分栏默认隐藏,这一选择对分栏内的每个文件 tab 都有效,直到开关、Escape、工作区命令,或导航到被目录树遮住的预览改变它。自身没有记录的分栏(例如 tab 拖出后所在的浮窗)从隐藏、默认宽度开始。点击目录树以外的地方不会隐藏它;在另一分栏打开内容也不会隐藏它。在文件页上,目录树铺满标题行下方的整页,停靠与浮动时都一样,不显示分隔条,且始终显示:文件页不画导航栏开关,Escape 也不关闭它。文件页打开的文件沿用落定分栏自己的选择,尚未做过选择的分栏为隐藏。
@@ -41,7 +41,11 @@ export interface FileNavigatorInput {
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotFactoryMap {
/** One pane's workspace tree with its width and show/hide state. */
/**
* One pane's workspace tree with its width and show/hide state. The host renders it as a direct child of
* the content row it shares with the body, and that row carries `data-files-navigator-row`, which the
* filled layout needs to hide the covered body.
*/
'sidebar.files.navigator': {
scope: 'session'
props: FileNavigatorInput
@@ -74,14 +74,16 @@
border-left: 0;
}
/* Covered content stays laid out but cannot receive keyboard focus. */
:has(> .navigator.fill) > :not(.navigator) {
/* Covered content stays laid out but cannot receive keyboard focus. The rule
names its row through `data-files-navigator-row`: a `:has()` without a
subject makes the browser restyle the whole document after any DOM change. */
[data-files-navigator-row]:has(> .navigator.fill) > :not(.navigator) {
visibility: hidden;
}
/* navigator-layout `FULLSCREEN_QUERY`: every layout fills the row. */
@media (width < 768px) {
:has(> .navigator:not(.wholePage)) > :not(.navigator) {
[data-files-navigator-row]:has(> .navigator:not(.wholePage)) > :not(.navigator) {
visibility: hidden;
}
@@ -113,7 +113,7 @@ export function FilesBody(props: FilesBodyProps): ReactNode {
</Tooltip>
{renderSlot('sidebar.right.tab.files.actions', { absolutePath: cwd })}
</div>
<div className={css.content} data-files-content>{navigator}</div>
<div className={css.content} data-files-content data-files-navigator-row>{navigator}</div>
</div>
)
}
@@ -56,6 +56,8 @@ describe('FilesBody', () => {
expect([...header.querySelectorAll('button:not([data-files-auto-refresh])')].map(button => button.getAttribute('aria-label')))
.toEqual([zh.reload, zh['search.placeholder']])
const content = page.querySelector('[data-files-content]')!
// The filled layout's rule finds the row the navigator covers by this attribute.
expect(content.hasAttribute('data-files-navigator-row')).toBe(true)
expect(content.children).toHaveLength(1)
const navigator = content.firstElementChild!
expect(navigator.getAttribute('data-files-navigator')).toBe(PANE)
@@ -18,8 +18,8 @@
en: c7e1b7d87ccccd2e
zh: 42d864b918a6175c
/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation:
en: 7495fe1e0d0ee643
zh: f820a5790ae6e2a7
en: eaf062b1439ed8b3
zh: d7c6a68779774825
/deepseek-ai-dsh-experimental-client-ui-forge/further-exploration:
en: fa4d8c579ffc49ff
zh: 5856ed7469e4585b
@@ -30,8 +30,8 @@
en: 2bc25a99e17789c4
zh: 4bec723fbf249d28
/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work:
en: 5e29ac36aad15d02
zh: e46339bb637a69ef
en: 4124d453778daf7b
zh: 33cab3137381942e
/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
@@ -58,7 +58,7 @@ All files opens a searchable directory tree with the selected file scrolled to i
<details>
<summary>Implementation internals — click to expand</summary>
`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` for every change. Each change request keeps the latest snapshot a poll returned while later polls fail. A failed parallel Git read cancels and joins its siblings. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. When an inspection finds an upstream branch on a remote a provider recognizes, the panel asks `ctx.forgeSession.discoverBranch` to bind that branch's open change requests, once per remote and branch until a refresh. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`; a refresh settles once the inspection and the probe it started have ended, and poll results arrive later.
`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` only when the state differs from the published one, so a reinspection that finds the same checkout reaches no follower. Each change request keeps the latest snapshot a poll returned while later polls fail. A failed parallel Git read cancels and joins its siblings. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. When an inspection finds an upstream branch on a remote a provider recognizes, the panel asks `ctx.forgeSession.discoverBranch` to bind that branch's open change requests, once per remote and branch until a refresh. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`; a refresh settles once the inspection and the probe it started have ended, and poll results arrive later.
Inspection runs `rev-parse --show-toplevel`, `rev-parse --path-format=absolute --git-common-dir`, `rev-parse --path-format=absolute --git-dir`, `symbolic-ref --short HEAD`, `rev-parse --short HEAD`, `rev-parse --abbrev-ref @{upstream}`, and `remote -v`; `branches` runs `symbolic-ref` and `for-each-ref --count=<maxBranches>`; `diffBases` finds the default fork point as `localDiff` does below, runs `for-each-ref --sort=-committerdate` over `refs/heads/` and `refs/remotes/` within `maxDiffBytes`, leaves out symbolic refs such as `origin/HEAD`, and keeps at most `maxBranches` branches of each kind whose names contain the query. Inspection then sizes the `branch` scope for the local changes card: it finds the fork point, runs `diff --numstat -z -M` against it with the same helper and submodule restrictions as `localDiff` below, and lists untracked files. Every command uses the Session's resolved sandbox mode: `read-only` and `workspace-write` require confinement, while `danger-full-access` runs without a sandbox backend. Commands disable optional index writes, hooks, the filesystem monitor, prompts, and lazy object fetches, remove inherited Git selectors and secret-bearing variables, and use their own `gitTimeoutMs` deadline and `maxOutputBytes` limit per stream. The top-level, Git, and common Git directories Git prints are resolved through `ctx.fs` into the canonical process paths that the working-directory service records; a checkout whose Git directory differs from the common one is a linked worktree. The worktree option is usable under the `danger-full-access` sandbox mode, never under `read-only`, and under `workspace-write` only when both directories lie inside the canonical workspace root of the Session's sandbox policy.
@@ -69,11 +69,11 @@ Restoration records `restoring` before mutation and `restored` only after succes
Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists.
`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes/<remote>/HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :<path>` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the Line truncated
`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes/<remote>/HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `localText` reads the new side of one file, from the index through `show :<path>` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, a patch together with its `.gitattributes`, where a failed patch read cancels the attributes read and both reads end before the result, the patch's failure passing on before the attributes failure, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to tLine truncated
`changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service.
The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes/<session>/local` and `dsh-resource://forge-changes/<session>/change/<url>`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.<Session>`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon.
The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes/<session>/local` and `dsh-resource://forge-changes/<session>/change/<url>`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.<Session>`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and passes each file section values that keep their identity across its renders, including the tab's visibility, so a render redraws only the sections whose values changed and a visibility change redraws every section. An expanded file draws its lines, highlighted with the shared code highlighter in the same render, once its section first comes within 1200px of the scrollport, keeps them drawn after that, and until then holds a 22px row per drawn line; a chip title shows its name with the changes icon.
| Source | Responsibility |
|---|---|
@@ -92,6 +92,7 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t
| [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | Changes view |
| [`src/client/BaseMenu.tsx`](src/client/BaseMenu.tsx) | Base branch menu of the Branch scope |
| [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | One file of the changes view |
| [`src/client/section-reach.ts`](src/client/section-reach.ts) | When file sections draw their lines |
| [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | Lens, test file switch, and viewed progress |
| [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | Commit picker |
| [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | Viewed marks kept in the browser |
@@ -139,6 +140,8 @@ None for a new worktree, as its notice belongs to the first request of the Sessi
- **Changes scopes** — deferred: a scope for the last turn. The Branch scope's Default needs a remote whose default branch HEAD shares history with, or a worktree the Session created; otherwise a base branch must be chosen.
- **Very many branches** — the base branch menu reads every ref before it filters, so a search can reach any branch. Refs over `maxDiffBytes`, about 165,000 at the default, or a listing over `gitTimeoutMs`, leave the menu saying that branches could not be read; Default keeps working.
- **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge.
- **Undrawn files** — until the reader scrolls near an expanded file, it holds 22 px per drawn line, which leaves out side-by-side pairing, wrapped lines, unchanged-line rows, and review cards, so the scroll range shifts a little as files draw; its saved review comments also appear only once it draws.
- **Find in page over long diffs** — an expanded file draws its lines only once the reader scrolls near it, so the browser's find in page does not match lines of files further down until they are drawn; a page-wide search of the diff needs a find control of the view's own.
- **Viewed files on the code host** — viewed marks of a change request stay in this browser and are not read from or written to GitHub's viewed state (`markFileAsViewed`) until the provider's review operations exist; another browser or device starts without them.
- **Review layers** — the built-in rules cover common conventions only, so a repository records others in `.gitattributes`; a path cannot show Rust's inline `#[cfg(test)]` modules or which declarations a language exports, and the Interface layer keeps whole files, so finding changed exports needs a language analyzer that a later lens extension point can supply. `linguist-generated` is read only from the root `.gitattributes`, without nested `.gitattributes` files or attribute macros.
- **Chosen commits** — a change request lists at most `maxCommits` commits of the provider, the oldest 250 for GitHub, while the Branch scope lists its newest `maxCommits`; a run must follow first parents, so commits of a merged branch can be shown one at a time but not together with the branch's own; a commit range is read from the host on every view; comments on chosen commits can only be added to the input, as a GitHub review addresses the head.
@@ -58,7 +58,7 @@ diff 按文件树的深度优先顺序排列:同层目录在前、文件在后
<details>
<summary>实现细节——点击展开</summary>
`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,每次变化都发布新的 `ForgePanelState`。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。并行 Git 读取失败时会取消并等待同批读取结束。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。检查发现提供方识别的远端上有上游分支时,面板调用 `ctx.forgeSession.discoverBranch` 绑定该分支打开状态的变更请求;同一远端与分支在刷新前只查找一次。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。
`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,只在状态与已发布的不同时发布新的 `ForgePanelState`,所以检查结果与原来相同时不会通知任何关注方。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。并行 Git 读取失败时会取消并等待同批读取结束。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。检查发现提供方识别的远端上有上游分支时,面板调用 `ctx.forgeSession.discoverBranch` 绑定该分支打开状态的变更请求;同一远端与分支在刷新前只查找一次。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。
检查运行 `rev-parse --show-toplevel`、`rev-parse --path-format=absolute --git-common-dir`、`rev-parse --path-format=absolute --git-dir`、`symbolic-ref --short HEAD`、`rev-parse --short HEAD`、`rev-parse --abbrev-ref @{upstream}` 与 `remote -v`;`branches` 运行 `symbolic-ref` 与 `for-each-ref --count=<maxBranches>`;`diffBases` 像下文的 `localDiff` 一样找出默认分叉点,在 `maxDiffBytes` 以内对 `refs/heads/` 与 `refs/remotes/` 运行 `for-each-ref --sort=-committerdate`,排除 `origin/HEAD` 等符号引用,每类最多保留 `maxBranches` 个名称包含查询文字的分支。之后,检查会为本地变更卡片统计 `branch` 范围的规模:找出分叉点,对它运行 `diff --numstat -z -M`,采用与下文 `localDiff` 相同的辅助程序和子模块限制,并列出未跟踪文件。每个命令都使用 Session 解析出的沙箱模式:`read-only` 与 `workspace-write` 需要沙箱,`danger-full-access` 无需沙箱后端。命令禁用可选的索引写入、钩子、文件系统监视器、提示与对象的延迟获取,移除继承的 Git 选择器与携带密钥的变量,并各自受 `gitTimeoutMs` 时限与每个输出流的 `maxOutputBytes` 上限约束。Git 打印的顶层目录、Git 目录与公共 Git 目录经 `ctx.fs` 解析为工作目录服务记录的规范进程路径;Git 目录与公共 Git 目录不同的检出是链接 worktree。工作树选项在 `danger-full-access` 沙箱模式下可用,在 `read-only` 下不可用,在 `workspace-write` 下只有两个目录都位于 Session 沙箱策略的规范工作区根内时才可用。
@@ -69,11 +69,11 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWo
每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。
Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes/<remote>/HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :<path>` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。
Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes/<remote>/HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :<path>` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,补丁与它的 `.gitattributes` 同时读取,补丁读取失败时取消 `.gitattributes` 的读取,两者都结束后才给出结果,补丁的失败优先于 `.gitattributes` 的失败,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分Line truncated
`changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。
浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes/<session>/local` 与 `dsh-resource://forge-changes/<session>/change/<url>` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.<Session>` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。
浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes/<session>/local` 与 `dsh-resource://forge-changes/<session>/change/<url>` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.<Session>` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并且传给每个文件段的值(包括标签页可见性)在多次渲染之间保持同一标识,所以一次渲染只重绘值有变化的文件段,可见性变化时重绘全部文件段。展开的文件在它的段第一次进入滚动区域 1200px 范围内时才绘制各行,并在同一次渲染中用共享的代码高亮器高亮,此后一直保留;在此之前按每个绘制行 22px 占位;标签页标题显示变更图标与视图名称。
| 源码 | 职责 |
|---|---|
@@ -92,6 +92,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi
| [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | 变更视图 |
| [`src/client/BaseMenu.tsx`](src/client/BaseMenu.tsx) | “分支”范围的基准分支菜单 |
| [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | 变更视图中的一个文件 |
| [`src/client/section-reach.ts`](src/client/section-reach.ts) | 文件段何时绘制各行 |
| [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | 分层透镜、测试文件开关与已审阅进度 |
| [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | 提交选择器 |
| [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | 保存在浏览器中的已审阅标记 |
@@ -139,6 +140,8 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi
- **变更范围**——待办:上一轮的范围。“分支”范围的“默认”需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree;否则必须选择基准分支。
- **分支极多**——基准分支菜单在筛选前读取全部引用,这样搜索才能找到任意分支。引用超过 `maxDiffBytes`(默认约 165,000 个)或列出时间超过 `gitTimeoutMs` 时,菜单提示无法读取分支;“默认”仍可使用。
- **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。
- **尚未绘制的文件**——读者滚动到展开的文件附近之前,它按每个绘制行 22 px 占位,没有计入左右对比的配对、自动换行、未变更行按钮和评审卡片,所以文件绘制时滚动范围会略有变化;它已保存的评审评论也要等它绘制后才显示。
- **长 diff 中的页内查找**——展开的文件要等读者滚动到附近才绘制各行,所以浏览器的页内查找在更靠后的文件绘制之前匹配不到它们的行;要在整个 diff 中查找,需要视图自己的查找控件。
- **代码托管平台上的已审阅状态**——变更请求的已审阅标记只保存在本浏览器中,在提供方评审接口完成之前,不读取也不写入 GitHub 的已审阅状态(`markFileAsViewed`);换浏览器或设备后没有这些标记。
- **分层**——内置规则只覆盖常见约定,其他约定由仓库写在 `.gitattributes` 中;按路径无法识别 Rust 的内联 `#[cfg(test)]` 模块,也无法判断一种语言导出了哪些声明,“接口”层保留整个文件,找出变更的导出需要语言分析器,可由之后的透镜扩展点提供。`linguist-generated` 只读取根目录的 `.gitattributes`,不读子目录中的 `.gitattributes`,也不展开属性宏。
- **所选提交**——变更请求最多列出提供方的 `maxCommits` 个提交(GitHub 为最早的 250 个),“分支”范围则列出最新的 `maxCommits` 个;范围必须沿第一父提交相连,因此已合并分支的提交只能逐个查看,不能与本分支的提交一起查看;每次查看提交范围都从托管平台读取;所选提交上的评论只能添加到输入框,因为 GitHub 评审针对 head。
@@ -567,6 +567,12 @@
font: var(--dsw-font-markdown-code-block);
}
/* An expanded file the reader has not scrolled near yet: one 22px row (`.line`) per drawn line, so the scroll
range and the positions of the files below stay close to their drawn values. */
.unreached {
height: calc(var(--forge-rows) * 22px);
}
/* One intrinsic-width track paints every unwrapped unified row across the scrolled width. */
.code:not([data-wrap]):not([data-split]) {
display: grid;
@@ -5,6 +5,9 @@
* layout. The view reads the changes when it opens, on each navigation, on
* refresh, and when the change request's head or the checkout's `branch`
* scope summary changes; a read keeps the previous changes on screen until it settles.
* File sections receive values that keep their identity across renders, so a
* render of the view redraws only the sections whose own values changed, and
* an expanded file draws its lines once its section comes near the scrollport.
*/
import {
@@ -39,7 +42,8 @@ import type { ChangesLens, createChangesStore } from './changes-store.ts'
import { CommitMenu } from './CommitMenu.tsx'
import { HideTestsSwitch, LensControl, LensMenu, LENSES, ViewedProgress } from './ReviewControls.tsx'
import { viewedStateOf, type ViewedFiles, type ViewedMarks } from './viewed-files.ts'
import { Counts, DiffFile, type DiffFileAction } from './DiffFile.tsx'
import { Counts, DiffFile, type DiffFileAction, type DiffFileHandlers } from './DiffFile.tsx'
import { SectionReach } from './section-reach.ts'
import { FileTreePane, type FileTreePosition } from './FileTreePane.tsx'
import { changedFileTree, filesInTreeOrder } from './file-tree.ts'
import { ChangedFilesMenu, type ChangedFilesProps, type ChangedFilesState } from './ChangedFiles.tsx'
@@ -564,7 +568,7 @@ export function ChangesTab({
const files = useMemo(() => allFiles.filter(file => shownBy(file, lens, hideTests)), [allFiles, lens, hideTests])
const lensCounts = useMemo(() => Object.fromEntries(LENSES.map(item => [item,
allFiles.filter(file => shownBy(file, item, hideTests)).length])) as Record<ChangesLens, number>, [allFiles, hideTests])
const shownSource = data === null ? null : reviewSourceOf(data)
const shownSource = useMemo(() => data === null ? null : reviewSourceOf(data), [data])
const viewedKey = shownSource === null ? '' : viewedSourceOf(shownSource)
const marks = useSyncExternalStore(viewedFiles.subscribe, () => viewedFiles.marksOf(viewedKey))
// The centered loader marks a first read; the toolbar's glyph turns only while shown changes are read again.
@@ -582,7 +586,12 @@ export function ChangesTab({
const expandedByDefault = (file: ForgeDiffFile): boolean => initial.has(file.path) && viewedStateOf(marks, file.path, file.digest) !== 'viewed'
const isExpanded = (file: ForgeDiffFile): boolean => chosen[file.path] ?? expandedByDefault(file)
const sections = useRef(new Map<string, HTMLElement>())
const scrollport = useRef<HTMLDivElement>(null)
const scrollport = useRef<HTMLDivElement | null>(null)
const reach = useMemo(() => new SectionReach(), [])
const attachScrollport = useCallback((element: HTMLDivElement | null) => {
scrollport.current = element
reach.attach(element)
}, [reach])
// A short final file cannot align with the viewport top; retain its jump until the reader scrolls away.
const jumpPosition = useRef<{ readonly data: ChangesData; readonly top: number } | null>(null)
useLayoutEffect(() => {
@@ -594,6 +603,7 @@ export function ChangesTab({
if (hideTests && hidden.layer === 'test') actions.toggledTests(tab.id)
return
}
// The browser's scroll anchoring keeps the section in place while the sections above it draw their lines.
sections.current.get(wanted)?.scrollIntoView({ block: 'start' })
if (scrollport.current !== null) jumpPosition.current = { data, top: scrollport.current.scrollTop }
setWanted(null)
@@ -751,6 +761,58 @@ export function ChangesTab({
markViewed(data, file, viewedStateOf(marks, file.path, file.digest) !== 'viewed')
}
// What the section handlers and comment actions read when they run, replaced on every render. The assignment happens
// during render rather than in an effect because the file menus read their actions while this same render draws them;
// an effect would hand them the previous diff's links. A render React discards leaves values derived from the same
// props and state, which the retried render assigns again.
const live = useRef({ data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t })
live.current = { data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t }
const handlers = useMemo((): DiffFileHandlers => ({
toggle: (file) => {
const byDefault = live.current.expandedByDefault(file)
setChosen(value => ({ ...value, [file.path]: !(value[file.path] ?? byDefault) }))
},
reveal: (path) => { setRevealed(value => new Set([...value, path])) },
expand: (file, index) => {
const { data: owner, expand: open } = live.current
/* v8 ignore next -- sections render only while changes are loaded */
if (owner !== null) open(owner, file, index)
},
view: (file, viewed) => {
const { data: owner, markViewed: mark } = live.current
/* v8 ignore next -- sections render only while changes are loaded */
if (owner !== null) mark(owner, file, viewed)
},
actions: (file) => {
const { data: owner, fileActions: list } = live.current
/* v8 ignore next -- sections render only while changes are loaded */
return owner === null ? [] : list(owner, file)
},
section: (path, element) => {
if (element === null) sections.current.delete(path)
else sections.current.set(path, element)
},
reach,
}), [reach])
const reviewStarted = reviewState.comments.length > 0
const publishingBlocked = reviewState.uncertain !== null
// Comments on a commit range or on local changes go to the input only; a whole change request also takes GitHub comments.
const publishable = data?.kind === 'change' && data.commits === null
const fileReview = useMemo((): ReviewActions | undefined => shownSource === null ? undefined : {
source: shownSource,
addToInput: addReviewToInput,
records: queue, openLink, reviewStarted,
onQuoted: (selection) => { if (!queue.recordAgent(selection)) live.current.report('review.saveFailed') },
publishingBlocked,
...publishable ? {
queue: selection => queue.add(selection),
publish: selection => selection.source.kind === 'change'
? live.current.publishReview(selection.source.url, { kind: 'comment', headSha: selection.source.headSha, comment: selection.comment },
url => queue.published(selection, url))
: Promise.resolve(live.current.t('review.stale')),
} : {},
}, [shownSource, addReviewToInput, queue, openLink, reviewStarted, publishingBlocked, publishable])
let body: ReactNode
if (data === null) {
if (state === null && snapshot?.disconnected) {
@@ -784,7 +846,7 @@ export function ChangesTab({
body = <div className={css.files}>{orphanRecords}<div className={css.centered}>{t('lens.empty')}</div></div>
} else {
body = (
<div ref={scrollport} className={css.files} data-forge-changes-scroll="" role="region"
<div ref={attachScrollport} className={css.files} data-forge-changes-scroll="" role="region"
aria-label={t('diff.allFiles', { count: files.length })} aria-keyshortcuts="ArrowLeft ArrowRight V" tabIndex={0}
onKeyDown={navigateFiles} onScroll={(event) => {
if (jumpPosition.current?.top !== event.currentTarget.scrollTop) jumpPosition.current = null
@@ -796,38 +858,18 @@ export function ChangesTab({
key={path}
visible={tab.visible}
file={file}
review={{ source: reviewSourceOf(data),
addToInput: addReviewToInput,
records: queue, openLink, reviewStarted: reviewState.comments.length > 0,
onQuoted: (selection) => { if (!queue.recordAgent(selection)) report('review.saveFailed') },
publishingBlocked: reviewState.uncertain !== null,
...data.kind === 'local' || data.commits !== null ? {} : {
queue: selection => queue.add(selection),
publish: selection => selection.source.kind === 'change'
? publishReview(selection.source.url, { kind: 'comment', headSha: selection.source.headSha, comment: selection.comment },
url => queue.published(selection, url))
: Promise.resolve(t('review.stale')),
},
}}
review={fileReview}
expanded={isExpanded(file)}
onToggle={() => { setChosen(value => ({ ...value, [path]: !(value[path] ?? expandedByDefault(file)) })) }}
large={drawnLines(file) > LARGE_FILE_LINES}
revealed={revealed.has(path)}
onReveal={() => { setRevealed(value => new Set([...value, path])) }}
text={current.texts[path]}
open={current.open[path] ?? NO_GAPS}
onExpand={(index) => { expand(data, file, index) }}
split={split}
wrap={wrap}
badge={badgeOf(file, data, t)}
tooltip={pathTooltipOf(file, t)}
actions={fileActions(data, file)}
viewed={viewedStateOf(marks, path, file.digest)}
onViewed={(viewed) => { markViewed(data, file, viewed) }}
sectionRef={(element) => {
if (element === null) sections.current.delete(path)
else sections.current.set(path, element)
}}
handlers={handlers}
t={t}
/>
)
@@ -1,10 +1,12 @@
/**
* One file of the changes view: a sticky header with the path, badges, line
* counts, and file actions, then its hunks unified or side by side with the
* unchanged lines between them collapsed into rows that expand them.
* unchanged lines between them collapsed into rows that expand them. A section
* draws and highlights its lines once it comes within reach of the scrollport;
* it redraws only when its own props change.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import clsx from 'clsx'
import { flushSync } from 'react-dom'
import {
@@ -14,7 +16,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ForgeDiffFile } from '../types.ts'
import { segmentsOf, splitRows, type DiffRow, type SplitCell, type SplitRow } from './diff-rows.ts'
import { drawnLines, segmentsOf, splitRows, type DiffRow, type SplitCell, type SplitRow } from './diff-rows.ts'
import { codeSideAt, readCodeSelection } from './code-selection.ts'
import { reviewMatches, selectReviewLines, type ReviewSelection } from './review.ts'
import { ReviewThread, useFileReview } from './ReviewThread.tsx'
@@ -24,6 +26,7 @@ import reviewCss from './ReviewComposer.module.css'
import { FileLabel } from './FileLabel.tsx'
import type { ForgePanelKey, NS } from './locales.ts'
import type { ViewedState } from './viewed-files.ts'
import type { SectionReach } from './section-reach.ts'
import css from './ChangesTab.module.css'
type Translate = PropsLocale<typeof NS>['t']
@@ -39,30 +42,45 @@ export interface DiffFileAction {
/** New side of a file: its lines once read, `loading` while a read runs, or undefined before any. */
export type DiffFileText = readonly string[] | 'loading' | undefined
/**
* What every file section of one changes view calls. The view passes the same
* object on every render, so a section redraws only when its own values change.
*/
export interface DiffFileHandlers {
/** Expand or collapse a file. */
readonly toggle: (file: ForgeDiffFile) => void
/** Show a large file anyway. */
readonly reveal: (path: string) => void
/** Expand one gap of a file. */
readonly expand: (file: ForgeDiffFile, index: number) => void
/** Mark a file viewed, which also collapses it, or clear the mark. */
readonly view: (file: ForgeDiffFile, viewed: boolean) => void
/** Rows of a file's menu, read whenever the menu draws. */
readonly actions: (file: ForgeDiffFile) => readonly DiffFileAction[]
/** Receive a file's section element, which scrolling to the file targets, or null once it unmounts. */
readonly section: (path: string, element: HTMLElement | null) => void
/** Tells each expanded section when it comes within reach of the scrollport, which draws its lines. */
readonly reach: SectionReach
}
/** Props of one file section. */
export interface DiffFileProps {
/** Whether the owning tab is visible and may show temporary menus. */
readonly visible: boolean
/** Comment destinations and the source being reviewed. */
readonly review?: ReviewActions
readonly review?: ReviewActions | undefined
/** The file and its hunks. */
readonly file: ForgeDiffFile
/** Whether the section shows its lines. */
readonly expanded: boolean
/** Expand or collapse the section. */
readonly onToggle: () => void
/** Whether the file shows the large-file notice instead of its lines until revealed. */
readonly large: boolean
/** Whether the user revealed a large file. */
readonly revealed: boolean
/** Show a large file anyway. */
readonly onReveal: () => void
/** The file's new side, which supplies collapsed unchanged lines. */
readonly text: DiffFileText
/** Indexes of the expanded gaps. */
readonly open: ReadonlySet<number>
/** Expand one gap. */
readonly onExpand: (index: number) => void
/** Whether deletions and additions show in two columns; a file with only additions or only deletions keeps one. */
readonly split: boolean
/** Whether long lines wrap. */
@@ -71,14 +89,10 @@ export interface DiffFileProps {
readonly badge: string
/** Hover text of the path: both paths of a renamed file, else the path. */
readonly tooltip: string
/** Rows of the file's menu. */
readonly actions: readonly DiffFileAction[]
/** Whether the reader marked this change viewed, or marked an earlier change of the file. */
readonly viewed: ViewedState
/** Mark the file viewed, which also collapses it, or clear the mark. */
readonly onViewed: (viewed: boolean) => void
/** Receives the section element, which scrolling to the file targets. */
readonly sectionRef: (element: HTMLElement | null) => void
/** Actions shared by every section of the view. */
readonly handlers: DiffFileHandlers
/** Forge panel copy. */
readonly t: Translate
}
@@ -144,8 +158,8 @@ function pairKind(pair: SplitRow): 'context' | 'change' {
}
/** The hunks of one file with their collapsed and expanded unchanged lines. */
function Lines({ file, text, open, onExpand, split, wrap, review, visible, comments, t }: Pick<DiffFileProps,
'file' | 'text' | 'open' | 'onExpand' | 'split' | 'wrap' | 'review' | 'visible' | 't'> & { readonly comments: FileReviewState }): ReactNode {
function Lines({ file, text, open, handlers, split, wrap, review, visible, comments, t }: Pick<DiffFileProps,
'file' | 'text' | 'open' | 'handlers' | 'split' | 'wrap' | 'review' | 'visible' | 't'> & { readonly comments: FileReviewState }): ReactNode {
const lines = typeof text === 'object' ? text : undefined
const columns = split && file.additions > 0 && file.deletions > 0
const singleSide = file.status === 'added' ? 'R' : file.status === 'deleted' ? 'L' : null
@@ -318,7 +332,7 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme
for (const [at, segment] of prepared.entries()) {
if (segment.kind === 'gap') {
rows.push(<button key={`gap-${segment.index}`} type="button" className={css.gap} disabled={text === 'loading'}
data-forge-diff-gap={segment.index} onClick={() => { onExpand(segment.index) }}>
data-forge-diff-gap={segment.index} onClick={() => { handlers.expand(file, segment.index) }}>
<IconChevronsUpDownOutlineRegular size={12} />
{segment.count === null ? t('diff.unchangedRest') : t('diff.unchanged', { count: segment.count.toLocaleString('en-US') })}
</button>)
@@ -374,8 +388,13 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme
}}>{rows}</div>
}
/** Whether a file's body draws its hunks line by line rather than a note or a collapsed strip. */
function drawsLines(file: ForgeDiffFile, large: boolean, revealed: boolean): boolean {
return !file.binary && !file.oversized && file.hunks.length > 0 && (!large || revealed)
}
function Body(props: DiffFileProps): ReactNode {
const { file, large, revealed, onReveal, review, t } = props
const { file, large, revealed, handlers, review, t } = props
const comments = useFileReview(review, file.path)
const records = review === undefined ? null : comments.threads.map(thread => <ReviewThread key={thread.key} thread={thread}
actions={review} locked={comments.locked} outdated={!reviewMatches(thread.selection, review.source, file)} split={false} t={t} />)
@@ -385,7 +404,7 @@ function Body(props: DiffFileProps): ReactNode {
if (large && !revealed) {
// A large file starts as one collapsed strip, drawn and opened like the collapsed unchanged lines.
return (
<><button type="button" className={css.gap} onClick={onReveal}>
<><button type="button" className={css.gap} onClick={() => { handlers.reveal(file.path) }}>
<IconChevronsUpDownOutlineRegular size={12} />
{t('diff.largeCollapsed', { count: (file.additions + file.deletions).toLocaleString('en-US') })}
</button>{records}</>
@@ -395,9 +414,10 @@ function Body(props: DiffFileProps): ReactNode {
}
/** File actions live only while their owning tab is visible. */
function FileMenu({ name, actions, t }: {
function FileMenu({ name, file, handlers, t }: {
readonly name: string
readonly actions: readonly DiffFileAction[]
readonly file: ForgeDiffFile
readonly handlers: DiffFileHandlers
readonly t: Translate
}): ReactNode {
const [menuOpen, setMenuOpen] = useState(false)
@@ -422,7 +442,7 @@ function FileMenu({ name, actions, t }: {
</button>
)}
>
{actions.map(action => (
{handlers.actions(file).map(action => (
<MenuItemButton
key={action.label}
onSelect={() => {
@@ -438,13 +458,27 @@ function FileMenu({ name, actions, t }: {
}
/**
* Render one file of a diff.
* @param props - the file, its section and gap state, layout choices, menu actions, and copy.
* Render one file of a diff. Until the section first comes within reach of the scrollport, an expanded file that
* draws lines shows a box of about their height instead.
* @param props - the file, its section and gap state, layout choices, shared actions, and copy.
* @returns the file section.
*/
export function DiffFile(props: DiffFileProps): ReactNode {
const { file, expanded, onToggle, badge, tooltip, actions, viewed, onViewed, sectionRef, t } = props
function DiffFileSection(props: DiffFileProps): ReactNode {
const { file, expanded, large, revealed, badge, tooltip, viewed, handlers, t } = props
const name = file.path.slice(file.path.lastIndexOf('/') + 1)
const element = useRef<HTMLElement | null>(null)
const [reached, setReached] = useState(false)
const waiting = expanded && !reached && drawsLines(file, large, revealed)
useEffect(() => {
/* v8 ignore next -- React attaches the section before it runs effects */
if (!waiting || element.current === null) return
return handlers.reach.observe(element.current, () => { setReached(true) })
}, [waiting, handlers.reach])
const sectionRef = useCallback((node: HTMLElement | null) => {
element.current = node
handlers.section(file.path, node)
}, [handlers, file.path])
const placeholder: CSSProperties & { '--forge-rows': number } = { '--forge-rows': drawnLines(file) }
return (
<section ref={sectionRef} className={css.file} data-forge-diff-file={file.path} data-expanded={expanded ? '' : undefined}
data-viewed={viewed}>
@@ -454,7 +488,7 @@ export function DiffFile(props: DiffFileProps): ReactNode {
className={css.fileToggle}
aria-expanded={expanded}
aria-label={t(expanded ? 'change.collapse' : 'change.expand', { name })}
onClick={onToggle}
onClick={() => { handlers.toggle(file) }}
>
<IconChevronDownOutlineRegular size={14} className={css.chevron} />
</button>
@@ -472,12 +506,15 @@ export function DiffFile(props: DiffFileProps): ReactNode {
{!file.binary && <Counts file={file} t={t} />}
<label className={css.viewed} title={t('viewed.hint')}>
<input type="checkbox" checked={viewed === 'viewed'} aria-label={t('viewed.aria', { name })}
onChange={(event) => { onViewed(event.target.checked) }} />
onChange={(event) => { handlers.view(file, event.target.checked) }} />
<span className={css.viewedLabel} aria-hidden="true">{t('viewed.label')}</span>
</label>
{props.visible && <FileMenu name={name} actions={actions} t={t} />}
{props.visible && <FileMenu name={name} file={file} handlers={handlers} t={t} />}
</div>
{expanded && <Body {...props} />}
{expanded && (waiting ? <div className={css.unreached} style={placeholder} data-forge-diff-unreached="" /> : <Body {...props} />)}
</section>
)
}
/** One file of a diff, redrawn only when its props change. */
export const DiffFile = memo(DiffFileSection)
@@ -0,0 +1,61 @@
/**
* Reach of the changes view's file sections: a section's lines mount once the
* section first comes within a margin of the files scrollport, and stay
* mounted, so long diffs draw only what the reader approaches.
*/
/** Distance beyond the scrollport's edges within which a section counts as reached. */
const REACH_MARGIN = '1200px 0px'
const noop = (): void => {}
/**
* Watches the file sections of one changes view against its scrollport. Without
* `IntersectionObserver` every section is reached at once.
*/
export class SectionReach {
private observer: IntersectionObserver | undefined
private readonly waiting = new Map<Element, () => void>()
/**
* Measure sections against `root`, or stop measuring when it unmounts. Sections that are still waiting move to the new root.
* @param root - the files scrollport, or null.
*/
attach(root: HTMLElement | null): void {
this.observer?.disconnect()
this.observer = undefined
if (root === null || typeof IntersectionObserver === 'undefined') return
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const reached = this.waiting.get(entry.target)
/* v8 ignore next -- the observer reports only sections still waiting, which it stops watching on release */
if (reached === undefined) continue
this.waiting.delete(entry.target)
observer.unobserve(entry.target)
reached()
}
}, { root, rootMargin: REACH_MARGIN })
this.observer = observer
for (const element of this.waiting.keys()) observer.observe(element)
}
/**
* Call `reached` once when `element` comes within reach.
* @param element - a file section inside the scrollport.
* @param reached - runs once, from the observer's callback or at once without `IntersectionObserver`.
* @returns the disposer that stops watching before the section is reached.
*/
observe(element: Element, reached: () => void): () => void {
if (typeof IntersectionObserver === 'undefined') {
reached()
return noop
}
this.waiting.set(element, reached)
this.observer?.observe(element)
return () => {
this.waiting.delete(element)
this.observer?.unobserve(element)
}
}
}
@@ -418,9 +418,7 @@ export class ForgePanelController extends TypertRemoteService {
const version = JSON.stringify([headSha, baseBranch])
let read = this.patches.get(key, version)
if (read === undefined) {
const patch = await this.home.forge.diff(ref, headSha, signal)
if (typeof patch !== 'string') throw readError(patch)
read = { patch, attributes: await this.attributesAt(ref, headSha, signal) }
read = await this.patchAt(ref, headSha, async operation => await this.home.forge.diff(ref, headSha, operation), signal)
this.patches.set(key, version, read)
}
const files = this.layers.classify(parseDiff(read.patch), read.attributes)
@@ -683,13 +681,41 @@ export class ForgePanelController extends TypertRemoteService {
if (start === 'not-linear') throw new RemoteError('forgePanel/commits-not-linear', 'The chosen commits are not one run of first parents', {})
const parent = start.parent
if (parent === null) throw new RemoteError('forgePanel/diff-failed', 'The first chosen commit has no parent to compare with', {})
const patch = await this.home.forge.compare(ref, parent, commits.to, signal)
if (typeof patch !== 'string') throw readError(patch)
const files = this.layers.classify(parseDiff(patch), await this.attributesAt(ref, commits.to, signal))
const { patch, attributes } = await this.patchAt(
ref, commits.to, async operation => await this.home.forge.compare(ref, parent, commits.to, operation), signal,
)
const files = this.layers.classify(parseDiff(patch), attributes)
const fileUrls = Object.fromEntries(files.map(file => [file.path, this.home.forge.fileUrl(ref, commits.to, file.path)]))
return { headSha: commits.to, files, localAhead: null, fileUrls }
}
/**
* Read a patch that ends at `commit` together with the root `.gitattributes` at that commit. A failed patch read
* cancels the attributes read. Both reads end before the result: the patch's failure passes on first, and the
* attributes failure only after the patch was read, so the outcome does not depend on which read ends first.
* @param ref - change request whose repository holds the commit.
* @param commit - commit the patch ends at.
* @param readPatch - reads the patch under the given cancellation.
* @param signal - request cancellation.
* @returns the patch and the attributes text, empty when the commit has none.
* @throws the Remote error for a refused patch or attributes read, or `signal.reason` when it aborts.
*/
private async patchAt(
ref: ChangeRequestRef, commit: string, readPatch: (signal: AbortSignal) => Promise<string | ForgeFailure>, signal: AbortSignal,
): Promise<{ readonly patch: string; readonly attributes: string }> {
const unneeded = new AbortController()
const patch = readPatch(signal).then((result) => {
if (typeof result !== 'string') throw readError(result)
return result
})
const attributes = this.attributesAt(ref, commit, AbortSignal.any([signal, unneeded.signal]))
void patch.then(undefined, () => { unneeded.abort() })
const [text, rules] = await Promise.allSettled([patch, attributes])
if (text.status === 'rejected') throw text.reason
if (rules.status === 'rejected') throw rules.reason
return { patch: text.value, attributes: rules.value }
}
/** The root `.gitattributes` of a change request's repository at a commit, empty when the commit has none. */
private async attributesAt(ref: ChangeRequestRef, commit: string, signal: AbortSignal): Promise<string> {
const attributes = await this.home.forge.fileText(ref, commit, ATTRIBUTES_PATH, signal)
@@ -1,5 +1,6 @@
/** Live panel state of one followed Session, shared by all of its followers. */
import { isDeepStrictEqual } from 'node:util'
import type { Context } from '@deepseek-ai/cordis'
import type { ChangeRequestSnapshot, ForgeReadiness, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge'
import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session'
@@ -230,6 +231,8 @@ export class SessionPanel {
}
private publish(next: ForgePanelState): void {
// A reinspection after a tool result usually finds the same checkout; equal states reach no follower.
if (isDeepStrictEqual(next, this.current)) return
this.current = next
for (const notify of [...this.followers]) notify()
}
@@ -729,9 +729,10 @@ describe('ChangesTab base branch', () => {
})
it('records review comments under the compared ref and names the base the Host reports', async () => {
const renderDiff = DiffFileModule.DiffFile
// The memoized section draws through its inner component, which the spy observes.
const renderDiff = DiffFileModule.DiffFile.type
let source: ReviewActions['source'] | undefined
vi.spyOn(DiffFileModule, 'DiffFile').mockImplementation((props) => {
vi.spyOn(DiffFileModule.DiffFile, 'type').mockImplementation((props) => {
source = props.review?.source
return renderDiff(props)
})
@@ -805,6 +806,48 @@ describe('ChangesTab base branch', () => {
})
})
describe('ChangesTab drawing work', () => {
it('draws the lines of an expanded file once its section comes within reach of the scrollport', async () => {
const watched = new Map<Element, IntersectionObserverCallback>()
let root: Element | Document | null | undefined
vi.stubGlobal('IntersectionObserver', class {
constructor(readonly report: IntersectionObserverCallback, options?: IntersectionObserverInit) { root = options?.root }
observe(element: Element): void { watched.set(element, this.report) }
unobserve(element: Element): void { watched.delete(element) }
disconnect(): void { watched.clear() }
})
try {
const m = mount()
await settle()
const app = m.section('src/app.ts')
expect(root).toBe(m.view.container.querySelector('[data-forge-changes-scroll]'))
expect(app.querySelector('[data-forge-diff-unreached]')).not.toBeNull()
expect(rowTexts(app)).toEqual([])
act(() => {
// The double reports only the entries, which is all the view reads.
Reflect.apply(watched.get(app)!, undefined, [[{ target: app, isIntersecting: true }], undefined])
})
expect(app.querySelector('[data-forge-diff-unreached]')).toBeNull()
expect(rowTexts(app).length).toBeGreaterThan(0)
expect(watched.has(app)).toBe(false)
} finally {
vi.unstubAllGlobals()
}
})
it('leaves file sections undrawn while the reader filters the file list', async () => {
// The memoized section draws through its inner component, which the spy counts.
const draw = vi.spyOn(DiffFileModule.DiffFile, 'type')
const m = mount()
await settle()
const drawn = draw.mock.calls.length
fireEvent.change(m.view.getAllByRole('textbox', { name: 'Filter files…' })[0]!, { target: { value: 'app' } })
await settle()
expect(draw.mock.calls.length).toBe(drawn)
})
})
describe('ChangesTab layout', () => {
it('draws lines side by side, then in one column, and wraps them by choice', async () => {
const m = mount()
@@ -1339,9 +1382,10 @@ describe('line review actions', () => {
expect(tree.getByRole('button', { name: 'src/app.ts' })).toBeTruthy()
})
it('refuses a local reference passed to the PR publication callback', async () => {
const renderDiff = DiffFileModule.DiffFile
// The memoized section draws through its inner component, which the spy observes.
const renderDiff = DiffFileModule.DiffFile.type
let publish: ReviewActions['publish']
vi.spyOn(DiffFileModule, 'DiffFile').mockImplementation((props) => {
vi.spyOn(DiffFileModule.DiffFile, 'type').mockImplementation((props) => {
publish = props.review?.publish
return renderDiff(props)
})
@@ -306,6 +306,8 @@ describe('forgePanel.follow', () => {
expect(moved.readiness).toEqual({ state: 'unauthenticated', message: 'sign in' })
expect(opened.provider.probes).toEqual(['fake.test', 'other.test'])
// A new commit gives the next inspection a state of its own to publish.
await git(repo, 'commit', '--allow-empty', '-m', 'again')
shellTurn(opened.session, 2, [{ callId: 'again', command: 'ls', output: '' }])
await follower.next(state => state.checkout.state === 'ready')
expect(follower.states.at(-1)?.readiness).toEqual({ state: 'unauthenticated', message: 'sign in' })
@@ -726,6 +728,57 @@ describe('forgePanel change request diffs', () => {
.rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'down' })
})
it('reads the patch and the .gitattributes together, and reports the patch\'s failure before the attributes failure', async () => {
const { repo } = await project()
const opened = await open(repo)
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
await reader(opened).until(state => polled(state, pullUrl(7)))
const read = async (): Promise<unknown> => await opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), null, signal)
const cancelled: string[] = []
// The patch read ends only after the attributes read has failed, so the attributes failure always comes first.
let attributesFailed = Promise.withResolvers<undefined>()
const later = <T>(value: T) => async (): Promise<T> => {
await attributesFailed.promise
return value
}
// Failed reads are not kept, so each case reads again.
const diff = vi.spyOn(opened.provider, 'diff').mockImplementation(later<string | ForgeFailure>({ code: 'too-large', message: 'diff over 300 files' }))
const text = vi.spyOn(opened.provider, 'fileText').mockImplementation(async () => {
attributesFailed.resolve(undefined)
return { code: 'unavailable', message: 'attributes down' }
})
await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-too-large', message: 'diff over 300 files' })
attributesFailed = Promise.withResolvers()
diff.mockImplementation(later<string | ForgeFailure>({ code: 'stale', message: 'head moved' }))
await expect(read()).rejects.toMatchObject({ code: 'forgePanel/stale-head', message: 'head moved' })
attributesFailed = Promise.withResolvers()
diff.mockImplementation(later<string | ForgeFailure>(PATCH))
await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'attributes down' })
// A failed patch read cancels the attributes read it no longer needs.
diff.mockResolvedValue({ code: 'unavailable', message: 'patch down' })
text.mockImplementation(async (_ref, _commit, _path, operation) => {
await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) })
cancelled.push('attributes')
throw operation.reason
})
await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'patch down' })
expect(cancelled).toEqual(['attributes'])
// The patch read finishes only once the attributes read has started, so reading them one after the other never ends.
const attributesStarted = Promise.withResolvers<undefined>()
diff.mockImplementation(async () => {
await attributesStarted.promise
return PATCH
})
text.mockImplementation(async () => {
attributesStarted.resolve(undefined)
return 'src/** linguist-generated\n'
})
await expect(read()).resolves.toMatchObject({ files: [{ path: 'src/a.ts', layer: 'test' }] })
})
it('lists the commits of a change request and reads consecutive ones from the first one\'s parent', async () => {
const { repo } = await project()
const opened = await open(repo)
@@ -1726,13 +1779,12 @@ describe('forgePanel actions', () => {
expect(settled).toBe(false)
inspection.release()
await inspected
await follower.next(() => true)
const probe = Promise.withResolvers<ForgeReadiness>()
opened.provider.readinessImpl = () => probe.promise
settled = false
const probed = opened.ctx.forgePanel.refresh(opened.session, signal).then(() => { settled = true })
await follower.next(() => true)
await vi.waitFor(() => { expect(opened.provider.probes).toHaveLength(3) })
await settle()
expect(settled).toBe(false)
probe.resolve({ state: 'ready', message: null })
@@ -1740,6 +1792,24 @@ describe('forgePanel actions', () => {
expect(opened.provider.probes).toHaveLength(3)
})
it('publishes nothing when a reinspection and a sign-in probe find what the panel already shows', async () => {
const { repo } = await project()
const opened = await open(repo)
const follower = reader(opened)
await follower.until(state => ready(state) && state.readiness !== null)
await settle()
const published = follower.states.length
const inspections = opened.sandbox.calls.length
shellTurn(opened.session, 1, [{ callId: 'list', command: 'ls', output: '' }])
await opened.ctx.forgePanel.refresh(opened.session, signal)
await settle()
expect(opened.sandbox.calls.length).toBeGreaterThan(inspections)
expect(opened.provider.probes).toHaveLength(2)
expect(follower.states).toHaveLength(published)
})
it('refreshes a Session outside a checkout without polling change requests that no provider serves', async () => {
const opened = await open(await plain())
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: 'https://unserved.test/a/b/pull/3\n' }])
@@ -1749,8 +1819,10 @@ describe('forgePanel actions', () => {
await opened.ctx.forgePanel.refresh(opened.session, signal)
const refreshed = await follower.next(state => state.checkout.state === 'none')
expect(refreshed.changeRequests).toEqual([{ url: 'https://unserved.test/a/b/pull/3', origin: 'created', ref: null, update: null, snapshot: null }])
// The refresh found the same state, which the panel already published.
const refreshed = follower.states.at(-1)
expect(refreshed?.checkout.state).toBe('none')
expect(refreshed?.changeRequests).toEqual([{ url: 'https://unserved.test/a/b/pull/3', origin: 'created', ref: null, update: null, snapshot: null }])
expect(opened.sandbox.calls.length).toBeGreaterThan(inspections)
expect(opened.provider.fetches).toEqual([])
expect(opened.provider.probes).toEqual([])
@@ -6,6 +6,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { DiffFile, type DiffFileProps } from '../src/client/DiffFile.tsx'
import { en } from '../src/client/locales.ts'
import { ReviewQueue } from '../src/client/review-queue.ts'
import { SectionReach } from '../src/client/section-reach.ts'
import { selectReviewLines } from '../src/client/review.ts'
import type { ReviewActions } from '../src/client/ReviewComposer.tsx'
import type { ForgeDiffFile } from '../src/types.ts'
@@ -20,9 +21,12 @@ function mount(overrides: Partial<DiffFileProps> = {}) {
const addToInput = vi.fn().mockReturnValue(true)
const records = new ReviewQueue('diff-review')
const review: ReviewActions = { source, addToInput, records, onQuoted: (item) => { records.recordAgent(item) } }
const props: DiffFileProps = { file, visible: true, expanded: true, onToggle: vi.fn(), large: false, revealed: false, onReveal: vi.fn(),
text: undefined, open: new Set(), onExpand: vi.fn(), split: true, wrap: true, badge: '', tooltip: 'a.txt', actions: [],
viewed: 'unviewed', onViewed: vi.fn(), sectionRef: vi.fn(), review, t, ...overrides }
const handlers = {
toggle: vi.fn(), reveal: vi.fn(), expand: vi.fn(), view: vi.fn(), actions: () => [], section: vi.fn(), reach: new SectionReach(),
}
const props: DiffFileProps = { file, visible: true, expanded: true, large: false, revealed: false,
text: undefined, open: new Set(), split: true, wrap: true, badge: '', tooltip: 'a.txt',
viewed: 'unviewed', handlers, review, t, ...overrides }
const view = render(<DiffFile {...props} />)
const line = (name: string) => view.getByRole('button', { name: `Select line ${name}` })
const code = (side: 'L' | 'R', no: number): HTMLElement => {
@@ -248,3 +252,28 @@ it('places left and right records in unified rows and retains outdated records f
expect(m.container.querySelectorAll('[data-forge-review-thread]')).toHaveLength(3)
expect(within(m.container).getByRole('button', { name: /changed lines/ })).toBeTruthy()
})
it('draws a file\'s highlighted lines in the same render, so a changed file never shows its previous text', () => {
const code = (text: string): ForgeDiffFile => ({ ...file, path: 'a.ts', additions: 1, deletions: 0, status: 'added',
hunks: [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 1, lines: [`+${text}`] }] })
const m = mount({ file: code('const value = old()'), split: false })
const line = (): HTMLElement => m.code('R', 1)
expect(line().textContent).toBe('const value = old()')
expect(line().hasAttribute('data-diff-code')).toBe(true)
m.rerender(<DiffFile {...m.props} file={code('const value = renewed()')} split={false} />)
expect(line().textContent).toBe('const value = renewed()')
expect(line().hasAttribute('data-diff-code')).toBe(true)
})
it('keeps a text selection across a render that leaves the file unchanged', () => {
const m = mount({ file: { ...file, path: 'a.ts' } })
const start = m.code('R', 3)
const node = start.firstChild
act(() => { document.getSelection()!.setBaseAndExtent(start, 0, start, start.childNodes.length) })
const selected = document.getSelection()?.toString()
expect(selected).toBe('new first')
m.rerender(<DiffFile {...m.props} file={{ ...file, path: 'a.ts' }} wrap={false} />)
expect(m.code('R', 3).firstChild).toBe(node)
expect(document.getSelection()?.toString()).toBe(selected)
})
@@ -347,13 +347,13 @@ export class FakeProvider implements ForgeProvider {
return Promise.resolve(this.actionResult)
}
diff(ref: ChangeRequestRef, headSha: string): Promise<string | ForgeFailure> {
diff(ref: ChangeRequestRef, headSha: string, _signal: AbortSignal): Promise<string | ForgeFailure> {
this.diffs.push(ref.number)
this.diffHeads.push(headSha)
return Promise.resolve(this.patches.get(ref.number) ?? { code: 'not-found', message: `no diff of #${ref.number}` })
}
fileText(_ref: ChangeRequestRef, commit: string, path: string): Promise<string | ForgeFailure> {
fileText(_ref: ChangeRequestRef, commit: string, path: string, _signal: AbortSignal): Promise<string | ForgeFailure> {
return Promise.resolve(this.texts.get(`${commit}:${path}`) ?? { code: 'not-found', message: `no ${path} at ${commit}` })
}
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
/** File sections reach the scrollport once. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SectionReach } from '../src/client/section-reach.ts'
/** An IntersectionObserver double that records its options and the elements it watches. */
class FakeObserver {
static readonly made: FakeObserver[] = []
readonly watched = new Set<Element>()
disconnected = false
constructor(readonly report: IntersectionObserverCallback, readonly options: IntersectionObserverInit | undefined) {
FakeObserver.made.push(this)
}
observe(element: Element): void { this.watched.add(element) }
unobserve(element: Element): void { this.watched.delete(element) }
disconnect(): void {
this.disconnected = true
this.watched.clear()
}
/** Report each element as intersecting or not. */
fire(entries: readonly (readonly [Element, boolean])[]): void {
// The production callback reads only the entries, so the double passes itself as the observer.
Reflect.apply(this.report, undefined, [entries.map(([target, isIntersecting]) => ({ target, isIntersecting })), this])
}
}
afterEach(() => {
vi.unstubAllGlobals()
FakeObserver.made.length = 0
})
describe('SectionReach', () => {
it('reaches every section at once where the browser has no IntersectionObserver', () => {
vi.stubGlobal('IntersectionObserver', undefined)
const reach = new SectionReach()
reach.attach(document.createElement('div'))
const reached = vi.fn()
const stop = reach.observe(document.createElement('section'), reached)
expect(reached).toHaveBeenCalledOnce()
stop()
})
it('reaches a section once it intersects the scrollport margin and moves waiting sections to a new scrollport', () => {
vi.stubGlobal('IntersectionObserver', FakeObserver)
const reach = new SectionReach()
const near = document.createElement('section')
const far = document.createElement('section')
const gone = document.createElement('section')
const reachedNear = vi.fn()
const reachedFar = vi.fn()
reach.observe(near, reachedNear)
reach.observe(far, reachedFar)
const stopGone = reach.observe(gone, vi.fn())
expect(FakeObserver.made).toHaveLength(0)
const root = document.createElement('div')
reach.attach(root)
const first = FakeObserver.made[0]!
expect(first.options).toEqual({ root, rootMargin: '1200px 0px' })
expect([...first.watched]).toEqual([near, far, gone])
stopGone()
expect([...first.watched]).toEqual([near, far])
first.fire([[near, true], [far, false]])
expect(reachedNear).toHaveBeenCalledOnce()
expect(reachedFar).not.toHaveBeenCalled()
expect([...first.watched]).toEqual([far])
const other = document.createElement('div')
reach.attach(other)
expect(first.disconnected).toBe(true)
const second = FakeObserver.made[1]!
expect(second.options?.root).toBe(other)
expect([...second.watched]).toEqual([far])
second.fire([[far, true]])
expect(reachedFar).toHaveBeenCalledOnce()
reach.attach(null)
expect(second.disconnected).toBe(true)
expect(FakeObserver.made).toHaveLength(2)
})
})
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-forge-github/use-this-package:
en: 0f8041e0380036c9
zh: 63cbdc82c9f438d4
en: 937728d83a92e0f0
zh: ea0805e75f2f4da0
/deepseek-ai-dsh-experimental-forge-github/understand-the-implementation:
en: f98e77171cba809f
zh: 1b330cb342d69efe
@@ -30,8 +30,8 @@
en: 26b946af4b742e17
zh: 00b34f0721ac72f6
/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work:
en: 9f13ae6d928e4ef7
zh: 09a549b1655f4de3
en: 8290820d0ea4122e
zh: f400491b8cb02453
/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+2 -2
View File
@@ -46,7 +46,7 @@ Unfinished check runs and completed runs with stale, missing, or unrecognized co
A pull request is `mergeReady` when it is open, not a draft, GitHub reports its merge state as `CLEAN`, `HAS_HOOKS`, or `UNSTABLE`, and the signed-in user has write, maintain, or admin permission. `mergeMethods` lists the squash, merge-commit, and rebase methods the repository allows, the user's default merge method first. Marking ready runs `gh pr ready <number> --repo <host>/<owner>/<name>`. Merging calls `PUT repos/<owner>/<name>/pulls/<number>/merge` through `gh api` with the method and the expected head SHA, so GitHub refuses once new commits arrived, which is reported as `stale`. Unlike `gh pr merge`, it never enables auto-merge, never joins a merge queue, and never deletes the head branch. A refused action reports GitHub's message, and a diagnostic that mentions authentication becomes `unauthenticated`.
`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos/<owner>/<name>/contents/<path>?ref=<commit>` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://<host>/<owner>/<name>/blob/<commit>/<path>`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`.
`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. Each status snapshot carries `baseRefOid` as `baseSha`, and leaves it out when GitHub reports no full commit there; given it, `diff` starts this comparison while the GraphQL query confirms the head, and a stale head or another base commit cancels the early comparison and waits for it to end before `diff` returns. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos/<owner>/<name>/contents/<path>?ref=<commit>` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://<host>/<owner>/<name>/blob/<commit>/<path>`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`.
Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints.
@@ -100,7 +100,7 @@ None. Provider output reaches only host-side watchers.
- **Batch deadline** — continuation pages of a pull request with many review threads lengthen its host's polling batch; a batch that outlives `ctx.forge`'s `fetchTimeoutMs` reports `unavailable` for every pull request in it, not only for the one with many threads.
- **Rewritten base branch** — the diff starts from the merge base of the head and the base commit GitHub recorded at the pull request's last update. After the base branch is force-pushed, that merge base can differ from the one GitHub's pull request view uses, so the patch can include or omit commits until the pull request is updated.
- **No fork discovery** — head lookups exclude pull requests whose head is in another repository.
- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request. All count against the signed-in account’s rate limit.
- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request, and a second REST request when the base commit changed after the latest status. All count against the signed-in account’s rate limit.
- **No merge queue** — a base branch that requires a merge queue refuses a direct merge, and the provider never queues one.
<a id="dev-note"></a>
@@ -46,7 +46,7 @@ kind: "package-reference"
pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 `CLEAN`、`HAS_HOOKS` 或 `UNSTABLE`,且已登录用户具有 write、maintain 或 admin 权限时,`mergeReady` 为真。`mergeMethods` 列出仓库允许的压缩合并、合并提交与变基合并方式,用户默认的合并方式排在最前。标记为可评审运行 `gh pr ready <number> --repo <host>/<owner>/<name>`。合并经 `gh api` 调用 `PUT repos/<owner>/<name>/pulls/<number>/merge`,带上合并方式与预期的 head SHA,因此有新提交到达后 GitHub 会拒绝,报告为 `stale`。与 `gh pr merge` 不同,它从不启用自动合并、从不加入合并队列,也从不删除 head 分支。被拒绝的操作报告 GitHub 的原因,提到身份验证的诊断转为 `unauthenticated`。
`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos/<owner>/<name>/contents/<path>?ref=<commit>`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://<host>/<owner>/<name>/blob/<commit>/<path>`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。
`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。每个状态快照把 `baseRefOid` 作为 `baseSha` 携带(GitHub 返回的不是完整提交时省略);拿到它时,`diff` 在 GraphQL 查询确认 head 的同时就开始这次比较,head 已过时或基准提交不同时会取消提前开始的比较并等它结束,然后才返回。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos/<owner>/<name>/contents/<path>?ref=<commit>`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://<host>/<owner>/<name>/blob/<commit>/<path>`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。
提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。
@@ -100,7 +100,7 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为
- **批次时限**——评审讨论很多的 pull request 的续页会拉长其主机的轮询批次;批次超过 `ctx.forge` 的 `fetchTimeoutMs` 时,批次内每个 pull request 都报告 `unavailable`,而不只是讨论很多的那个。
- **基准分支被改写**——diff 从 head 与 GitHub 在 pull request 最后一次更新时记录的基准提交的合并基准开始。基准分支被强制推送后,该合并基准可能不同于 GitHub pull request 页面使用的合并基准,因此在 pull request 更新前,补丁可能多出或缺少提交。
- **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。
- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求。这些都计入已登录账号的速率配额。
- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求;最新状态之后基准提交有变化时再多一次 REST 请求。这些都计入已登录账号的速率配额。
- **不支持合并队列**——要求合并队列的基准分支会拒绝直接合并,提供方也不会把变更请求加入队列。
<a id="dev-note"></a>
@@ -32,7 +32,7 @@ const THREAD_FIELDS = 'pageInfo { hasNextPage endCursor } nodes { isResolved }'
function pullRequestFields(threads: number): string {
return `id number url title state isDraft updatedAt closedAt
author { login }
headRefName headRefOid baseRefName additions deletions mergeable mergeStateStatus reviewDecision
headRefName headRefOid baseRefName baseRefOid additions deletions mergeable mergeStateStatus reviewDecision
reviewThreads(first: ${threads}) { ${THREAD_FIELDS} }
commits(last: 1) { nodes { commit { statusCheckRollup { id state contexts(first: ${PAGE_SIZE}) {
${CHECK_FIELDS}
@@ -132,6 +132,8 @@ const pullRequestSchema = z.object({
headRefName: z.string(),
headRefOid: z.string(),
baseRefName: z.string(),
// Only the diff read's head start uses the base commit, so a missing or malformed one leaves the rest of the status intact.
baseRefOid: z.unknown().transform(value => typeof value === 'string' && isCommit(value) ? value : undefined),
additions: z.number().int().nonnegative(),
deletions: z.number().int().nonnegative(),
mergeable: z.string(),
@@ -466,6 +468,7 @@ function toSnapshot(
headBranch: node.headRefName,
headSha: node.headRefOid,
baseBranch: node.baseRefName,
...node.baseRefOid === undefined ? {} : { baseSha: node.baseRefOid },
additions: node.additions,
deletions: node.deletions,
mergeability: mergeability(node.mergeable),
@@ -159,24 +159,44 @@ export class GitHubProvider implements ForgeProvider {
* current head and the base commit GitHub recorded with it; a head other
* than `headSha` returns `stale`. Otherwise the compare endpoint reads the
* patch from the merge base of that base commit and `headSha`, as the pull
* request's own diff does, with both ends fixed.
* request's own diff does, with both ends fixed. Given the `base` the latest
* status recorded with `headSha`, the comparison from it runs while the
* GraphQL read confirms the head; a stale head or another base commit
* cancels that comparison and waits for it to end before the read returns.
*/
async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure> {
async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise<string | ForgeFailure> {
const { host, owner, name } = checkedRepository(ref)
const failure = this.hostFailure(host)
if (failure !== undefined) return failure
if (!isCommit(headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(headSha)}`)
const output = await this.read([
'api', 'graphql', '--hostname', host,
'-f', `query=${PULL_REQUEST_COMMITS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`,
], signal)
if (typeof output !== 'string') return output
const commits = mapPullRequestCommitsResponse(parseJson(output))
if (commits === undefined) return { code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' }
if (commits.head.toLowerCase() !== headSha.toLowerCase()) {
return { code: 'stale', message: `the pull request head moved from ${headSha} to ${commits.head}` }
if (base !== undefined && !isCommit(base)) throw new Error(`forge-github: invalid base commit ${JSON.stringify(base)}`)
const compare = async (from: string, operation: AbortSignal): Promise<string | ForgeFailure> =>
await readComparison(async (args, inner) => await this.read(args, inner), ref.repository, from, headSha, operation)
const speculation = new AbortController()
const early = base === undefined ? undefined : { base, patch: compare(base, AbortSignal.any([signal, speculation.signal])) }
// The early comparison is observed here, so a cancelled or unused one never rejects unhandled.
const settled = early?.patch.then(() => undefined, () => undefined)
const discard = async (): Promise<void> => {
speculation.abort()
await settled
}
try {
const output = await this.read([
'api', 'graphql', '--hostname', host,
'-f', `query=${PULL_REQUEST_COMMITS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`,
], signal)
if (typeof output !== 'string') return output
const commits = mapPullRequestCommitsResponse(parseJson(output))
if (commits === undefined) return { code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' }
if (commits.head.toLowerCase() !== headSha.toLowerCase()) {
return { code: 'stale', message: `the pull request head moved from ${headSha} to ${commits.head}` }
}
if (early?.base.toLowerCase() === commits.base.toLowerCase()) return await early.patch
await discard()
return await compare(commits.base, signal)
} finally {
await discard()
}
return await readComparison(async (args, operation) => await this.read(args, operation), ref.repository, commits.base, headSha, signal)
}
/** Read one file at a commit through the contents API in its raw form. */
@@ -54,6 +54,7 @@ export function pullRequestNode(number: number, overrides: Record<string, unknow
headRefName: `topic-${number}`,
headRefOid: 'f'.repeat(40),
baseRefName: 'master',
baseRefOid: 'b'.repeat(40),
additions: 128,
deletions: 14,
mergeable: 'MERGEABLE',
@@ -45,6 +45,13 @@ describe('snapshot query', () => {
})
describe('snapshot mapping', () => {
it.each([['missing', undefined], ['null', null], ['not a full commit', 'HEAD~1']])(
'keeps the status and leaves out the base commit when GitHub reports it %s', async (_case, baseRefOid) => {
const snapshot = await single(pullRequestNode(1, { baseRefOid }))
expect(snapshot).not.toHaveProperty('baseSha')
expect(snapshot.headSha).toBe('f'.repeat(40))
})
it('maps the pull request fields into a provider-neutral snapshot', async () => {
expect((await single(pullRequestNode(1, {}, [checkRun('lint', 'COMPLETED', 'SUCCESS')])))).toEqual({
ref: pull(1),
@@ -56,6 +63,7 @@ describe('snapshot mapping', () => {
headBranch: 'topic-1',
headSha: 'f'.repeat(40),
baseBranch: 'master',
baseSha: 'b'.repeat(40),
additions: 128,
deletions: 14,
mergeability: 'mergeable',
@@ -1,5 +1,5 @@
import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { GhOutputLimitError, GhTimeoutError, GhUnavailableError } from '../src/gh.ts'
import type { GhOutput, GhRunner } from '../src/gh.ts'
import { PULL_REQUEST_COMMITS_QUERY, THREADS_QUERY } from '../src/graphql.ts'
@@ -446,6 +446,55 @@ describe('GitHub provider reads', () => {
expect(gh.calls[1]?.at(-1)).toBe(`repos/octo/demo/compare/${base}...${head.toUpperCase()}`)
})
it('starts the comparison from the base the status recorded while it confirms the head', async () => {
const confirmed = Promise.withResolvers<GhOutput>()
const gh = fakeGh(args => args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`)
? confirmed.promise : output('diff --git a/x b/x\n'))
const read = provider(gh.run).diff(pull(7), head, signal, base)
await vi.waitFor(() => { expect(gh.calls).toHaveLength(2) })
expect(gh.calls.map(call => call.at(-1))).toContain(`repos/octo/demo/compare/${base}...${head}`)
confirmed.resolve(output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: base.toUpperCase() } } } }))
await expect(read).resolves.toBe('diff --git a/x b/x\n')
expect(gh.calls).toHaveLength(2)
})
it('cancels the early comparison and waits for it when the head moved, the base differs, or the head read fails', async () => {
const moved = 'e'.repeat(40)
for (const [answer, expected, compares] of [
[output({ data: { repository: { pullRequest: { headRefOid: pushed, baseRefOid: base } } } }),
{ code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` }, [base]],
[output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: moved } } } }), 'diff --git a/y b/y\n', [base, moved]],
[output('', 1, 'gh: Something went wrong (HTTP 502)'), { code: 'unavailable', message: 'gh: Something went wrong (HTTP 502)' }, [base]],
] as const) {
const calls: string[][] = []
const cancelled: string[] = []
const run: GhRunner = async (args, operation) => {
calls.push([...args])
if (args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`)) return answer
const range = String(args.at(-1))
if (range.includes(`${moved}...`)) return output('diff --git a/y b/y\n')
// The early comparison runs until the provider cancels it.
await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) })
cancelled.push(range)
throw operation.reason
}
await expect(provider(run).diff(pull(7), head, signal, base)).resolves.toEqual(expected)
expect(calls.map(call => String(call.at(-1))).filter(target => target.includes('/compare/'))
.map(target => target.split('/compare/')[1]?.split('...')[0])).toEqual(compares)
expect(cancelled).toEqual([`repos/octo/demo/compare/${base}...${head}`])
}
})
it('refuses a base that is not a full commit object name before starting gh', async () => {
const gh = pinned()
await expect(provider(gh.run).diff(pull(7), head, signal, 'HEAD~1')).rejects.toThrow('forge-github: invalid base commit "HEAD~1"')
expect(gh.calls).toEqual([])
})
it('reports a head that moved past the requested commit as stale without reading the diff', async () => {
const gh = pinned({ head: pushed, base })
@@ -80,6 +80,7 @@ const node = (number) => ({
headRefName: number === 7 ? 'feat' : 'other',
headRefOid: 'a'.repeat(40),
baseRefName: 'main',
baseRefOid: 'e'.repeat(40),
additions: 1,
deletions: 0,
mergeable: 'MERGEABLE',
+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-forge/use-this-package:
en: 66ce801db0de565f
zh: 884973fad2ad2540
en: 4b29b898061bcc4e
zh: fd37c2a8b06e7aaf
/deepseek-ai-dsh-experimental-forge/understand-the-implementation:
en: e193cb547c47069b
zh: faa517522402ea41
+1 -1
View File
@@ -36,7 +36,7 @@ Mount the registry with at least one provider package, such as the GitHub provid
`markReady(ref, signal)` marks a draft ready for review, and `merge(ref, { method, headSha }, signal)` merges a change request only while its head is still `headSha`. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to undefined once the host accepted the change or to a `ForgeActionFailure`: `unauthenticated`, `stale` when the head moved past `headSha`, or `failed` with the host's reason. After either settles, a watched change request is polled at once. Snapshots carry `mergeReady`, whether the host would merge the change request now for the signed-in user, and `mergeMethods`, the methods the repository accepts with that user's usual one first.
`diff(ref, headSha, signal)` reads the change request's unified patch at head commit `headSha` as the host computes it, with `a/` and `b/` path prefixes, and `fileText(ref, commit, path, signal)` reads one file of its repository at a commit. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to the text or to a `ForgeFailure`, which is `too-large` when the host or the provider refuses an answer over its size limit. A provider returns a patch only when it describes `headSha`; once the head is another commit, `diff` resolves to `stale`. The first stale read at a head commit polls a watched change request at once so its next status names the current head; later stale reads at that commit wait for the regular poll, which bounds reads while the host's answers briefly disagree. Snapshots carry `unresolvedThreads` with `threadsTruncated`, which marks the count as a lower bound when the provider counted only some review threads. `fileUrl(ref, commit, path)` returns the web page of that file at that commit.
`diff(ref, headSha, signal)` reads the change request's unified patch at head commit `headSha` as the host computes it, with `a/` and `b/` path prefixes, and `fileText(ref, commit, path, signal)` reads one file of its repository at a commit. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to the text or to a `ForgeFailure`, which is `too-large` when the host or the provider refuses an answer over its size limit. A provider returns a patch only when it describes `headSha`; once the head is another commit, `diff` resolves to `stale`. The first stale read at a head commit polls a watched change request at once so its next status names the current head; later stale reads at that commit wait for the regular poll, which bounds reads while the host's answers briefly disagree. When the latest status of a watched change request names `headSha` and carries `baseSha`, the base commit the host recorded with that head, `diff` passes it to the provider, which may start reading from it before it confirms the head. Snapshots carry `unresolvedThreads` with `threadsTruncated`, which marks the count as a lower bound when the provider counted only some review threads. `fileUrl(ref, commit, path)` returns the web page of that file at that commit.
Providers may implement `commits` and `compare` together. `commits(ref, signal)` lists a change request's commits oldest first, each with its full object name, first parent, title, author, and author time, at one head commit and with a `truncated` flag; `compare(ref, base, head, signal)` reads the unified patch from commit `base` to commit `head`. The registry returns undefined from `commits` and an `unavailable` failure from `compare` when the provider lacks either method, so a consumer can offer commit views only where both exist.
+1 -1
View File
@@ -36,7 +36,7 @@ kind: "package-reference"
`markReady(ref, signal)` 把草稿标记为可评审;`merge(ref, { method, headSha }, signal)` 只在 head 仍是 `headSha` 时合并变更请求。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错;主机接受时 resolve 为 undefined,否则为 `ForgeActionFailure`:`unauthenticated`、head 已越过 `headSha` 时的 `stale`,或带主机原因的 `failed`。两者结束后,被监听的变更请求会立即重新轮询。快照带有 `mergeReady`(主机现在是否会为已登录用户合并该变更请求)与 `mergeMethods`(仓库接受的合并方式,该用户常用的方式排在最前)。
`diff(ref, headSha, signal)` 读取托管平台计算出的变更请求在 head 提交 `headSha` 处的统一补丁,路径带 `a/` 与 `b/` 前缀;`fileText(ref, commit, path, signal)` 读取其仓库中某个提交下的一个文件。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错,resolve 为文本或 `ForgeFailure`;托管平台或提供方因结果超过其大小上限而拒绝时,失败为 `too-large`。提供方只在补丁描述 `headSha` 时返回补丁;head 已是其他提交时,`diff` resolve 为 `stale`。同一 head 提交的第一次 stale 读取会让被 watch 的变更请求立即重新轮询,使下一次状态给出当前 head;之后在该提交上的 stale 读取等待常规轮询,从而在托管平台的应答短暂不一致时限制读取次数。快照携带 `unresolvedThreads` 与 `threadsTruncated`;提供方只统计了部分评审讨论时,后者表示该计数只是下限。`fileUrl(ref, commit, path)` 返回该文件在该提交下的网页地址。
`diff(ref, headSha, signal)` 读取托管平台计算出的变更请求在 head 提交 `headSha` 处的统一补丁,路径带 `a/` 与 `b/` 前缀;`fileText(ref, commit, path, signal)` 读取其仓库中某个提交下的一个文件。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错,resolve 为文本或 `ForgeFailure`;托管平台或提供方因结果超过其大小上限而拒绝时,失败为 `too-large`。提供方只在补丁描述 `headSha` 时返回补丁;head 已是其他提交时,`diff` resolve 为 `stale`。同一 head 提交的第一次 stale 读取会让被 watch 的变更请求立即重新轮询,使下一次状态给出当前 head;之后在该提交上的 stale 读取等待常规轮询,从而在托管平台的应答短暂不一致时限制读取次数。被 watch 的变更请求的最新状态给出 `headSha` 并携带 `baseSha`(托管平台随该 head 记录的基准提交)时,`diff` 把它传给提供方,提供方可以在确认 head 之前就从它开始读取。快照携带 `unresolvedThreads` 与 `threadsTruncated`;提供方只统计了部分评审讨论时,后者表示该计数只是下限。`fileUrl(ref, commit, path)` 返回该文件在该提交下的网页地址。
提供方可以同时实现 `commits` 与 `compare`。`commits(ref, signal)` 在一个 head 提交上按从旧到新列出变更请求的提交,每个提交带完整对象名、第一个父提交、标题、作者与作者时间,并带 `truncated` 标志;`compare(ref, base, head, signal)` 读取从提交 `base` 到提交 `head` 的统一补丁。提供方缺少其中任一方法时,注册表的 `commits` 返回 undefined,`compare` 返回 `unavailable` 失败,因此使用方只在两者都存在时提供按提交查看。
+5 -2
View File
@@ -242,7 +242,8 @@ export class ForgeService extends Service {
* computes them. The first `stale` failure at a head commit polls a watched
* change request again at once, so its next status names the current head;
* later stale reads at the same commit wait for the regular poll, which
* bounds the reads while the host's answers disagree.
* bounds the reads while the host's answers disagree. When the latest status of a watched change request names
* `headSha`, its `baseSha` goes to the provider, which may then read the patch while it confirms the head.
* @param ref - change request served by a registered provider.
* @param headSha - head commit the patch must describe, usually the head of the latest status.
* @param signal - cancellation of the read.
@@ -250,8 +251,10 @@ export class ForgeService extends Service {
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure> {
const latest = this.watches.get(changeRequestKey(ref))?.latest
const base = latest !== undefined && 'snapshot' in latest && latest.snapshot.headSha === headSha ? latest.snapshot.baseSha : undefined
const result = await this.invoke(this.registration(ref.repository.forge), signal,
(provider, operation) => provider.diff(ref, headSha, operation))
(provider, operation) => provider.diff(ref, headSha, operation, base))
const watch = this.watches.get(changeRequestKey(ref))
if (typeof result !== 'string' && result.code === 'stale' && watch !== undefined && watch.staleHead !== headSha) {
watch.staleHead = headSha
+8 -2
View File
@@ -79,6 +79,11 @@ export interface ChangeRequestSnapshot {
readonly headSha: string
/** Branch the change request merges into. */
readonly baseBranch: string
/**
* Base commit the host recorded with `headSha`, whose merge base with the head starts the change request's diff;
* omitted when the provider does not report it.
*/
readonly baseSha?: string
/** Lines the change request adds. */
readonly additions: number
/** Lines the change request deletes. */
@@ -244,9 +249,10 @@ export interface ForgeProvider {
/**
* Read the changes of a change request at head commit `headSha` as the host computes them, as one unified
* patch with `a/` and `b/` path prefixes. A patch is returned only when it describes `headSha`; once the
* head is another commit, the read resolves to a `stale` failure.
* head is another commit, the read resolves to a `stale` failure. `base`, when given, is the `baseSha` the
* latest status recorded with `headSha`; a provider may start reading from it before it confirms the head.
*/
diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure>
diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise<string | ForgeFailure>
/** Read the text of `path` at `commit` in the change request's repository. */
fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>
/** Build the web page of `path` at `commit` in the change request's repository. */
@@ -111,7 +111,7 @@ export class FakeProvider implements ForgeProvider {
return this.actionResult
}
async diff(target: ChangeRequestRef, headSha: string): Promise<string | ForgeFailure> {
async diff(target: ChangeRequestRef, headSha: string, _signal: AbortSignal, _base?: string): Promise<string | ForgeFailure> {
this.actions.push(`diff ${target.number} ${headSha}`)
return this.readResult
}
@@ -223,6 +223,22 @@ describe('forge watch', () => {
expect(provider.fetches).toHaveLength(3)
})
it('passes the base commit of the latest status to a diff read at its head only', async () => {
const provider = new FakeProvider()
provider.results.set(key, snapshot(ref(1), { baseSha: 'e'.repeat(40) }))
const context = await setup(provider)
collect(context)
await vi.advanceTimersByTimeAsync(0)
const diff = vi.spyOn(provider, 'diff')
const signal = new AbortController().signal
await context.forge.diff(ref(1), 'a'.repeat(40), signal)
await context.forge.diff(ref(1), 'f'.repeat(40), signal)
await context.forge.diff(ref(2), 'a'.repeat(40), signal)
expect(diff.mock.calls.map(call => call[3])).toEqual(['e'.repeat(40), undefined, undefined])
})
it('polls a watched change request at once after a diff read finds a newer head, and only then', async () => {
const provider = new FakeProvider()
provider.results.set(key, snapshot(ref(1)))
@@ -1271,7 +1271,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure>',
description: 'Read the changes of a change request at one head commit as its host computes them. The first `stale` failure at a head commit polls a watched change request again at once, so its next status names the current head; later stale reads at the same commit wait for the regular poll, which bounds the reads while the host\'s answers disagree.',
description: 'Read the changes of a change request at one head commit as its host computes them. The first `stale` failure at a head commit polls a watched change request again at once, so its next status names the current head; later stale reads at the same commit wait for the regular poll, which bounds the reads while the host\'s answers disagree. When the latest status of a watched change request names `headSha`, its `baseSha` goes to the provider, which may then read the patch while it confirms the head.',
parameters: [{ name: 'ref', description: 'change request served by a registered provider.' }, { name: 'headSha', description: 'head commit the patch must describe, usually the head of the latest status.' }, { name: 'signal', description: 'cancellation of the read.' }],
returns: 'one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it at `headSha`.',
throws: ['when no provider serves the change request\'s forge, or with `signal.reason` when it aborts.'],
@@ -6027,7 +6027,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ChangeRequestSnapshot',
declaration: 'export interface ChangeRequestSnapshot {\n readonly ref: ChangeRequestRef;\n readonly url: string;\n readonly title: string;\n readonly state: ChangeRequestState;\n readonly draft: boolean;\n readonly author: string | null;\n readonly headBranch: string;\n readonly headSha: string;\n readonly baseBranch: string;\n readonly additions: number;\n readonly deletions: number;\n readonly mergeability: Mergeability;\n readonly mergeReady: boolean;\n readonly mergeMethods: readonly MergeMethod[];\n readonly reviewDecision: ReviewDecision;\n readonly checks: readonly ChangeRequestCheck[];\n readonly checksRollup: ChecksRollup;\n readonly checksTruncated: boolean;\n readonly unresolvedThreads: number;\n readonly threadsTruncated: boolean;\n readonly updatedAt: string;\n readonly closedAt: string | null;\n}',
declaration: 'export interface ChangeRequestSnapshot {\n readonly ref: ChangeRequestRef;\n readonly url: string;\n readonly title: string;\n readonly state: ChangeRequestState;\n readonly draft: boolean;\n readonly author: string | null;\n readonly headBranch: string;\n readonly headSha: string;\n readonly baseBranch: string;\n readonly baseSha?: string;\n readonly additions: number;\n readonly deletions: number;\n readonly mergeability: Mergeability;\n readonly mergeReady: boolean;\n readonly mergeMethods: readonly MergeMethod[];\n readonly reviewDecision: ReviewDecision;\n readonly checks: readonly ChangeRequestCheck[];\n readonly checksRollup: ChecksRollup;\n readonly checksTruncated: boolean;\n readonly unresolvedThreads: number;\n readonly threadsTruncated: boolean;\n readonly updatedAt: string;\n readonly closedAt: string | null;\n}',
},
{
name: 'ChangeRequestState',
@@ -6731,7 +6731,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ForgeProvider',
declaration: 'export interface ForgeProvider {\n readonly forge: string;\n submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise<ForgeReviewResult>;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise<ReadonlyMap<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>>;\n fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise<ChangeRequestSnapshot | ForgeFailure>;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]>;\n readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness>;\n markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>;\n merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>;\n diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure>;\n fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>;\n fileUrl(ref: ChangeRequestRef, commit: string, path: string): string;\n commits?(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeCommitList | ForgeFailure>;\n compare?(ref: ChangeRequestRef, base: string, head: string, s /* …truncated — full shape in source */',
declaration: 'export interface ForgeProvider {\n readonly forge: string;\n submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise<ForgeReviewResult>;\n repositoryFromRemote(url: string): ForgeRepository | undefined;\n changeRequestFromUrl(url: string): ChangeRequestRef | undefined;\n changeRequestUrl(ref: ChangeRequestRef): string;\n fetch(refs: readonly ChangeRequestRef[], signal: AbortSignal): Promise<ReadonlyMap<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>>;\n fetchChecks?(ref: ChangeRequestRef, signal: AbortSignal): Promise<ChangeRequestSnapshot | ForgeFailure>;\n findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]>;\n readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness>;\n markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>;\n merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>;\n diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise<string | ForgeFailure>;\n fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>;\n fileUrl(ref: ChangeRequestRef, commit: string, path: string): string;\n commits?(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeCommitList | ForgeFailure>;\n compare?(ref: ChangeRequestRef, base: string, /* …truncated — full shape in source */',
},
{
name: 'ForgeReadiness',