perf(forge-panel): highlight only after scrolling settles

Highlighting waits until the page is idle and the scrollport has been still
for 200 ms, so a slow grammar's first tokenization never interrupts a scroll.
Adds the Agent Note recording the changes view decisions.
This commit is contained in:
Yichen Jiang committed 2026-11-07 16:42:40 +08:00
1 parent d4a2df736b
commit 6f088b13e7
11 files changed
+174 -13

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: 8a0a9ef23a83ce2b
zh: 840d67e79ae39ff1
/agent-note-forge-changes-view-responsiveness/alternatives-considered:
en: 9d12ccf1d236676d
zh: 0ea63c5c16c62de7
/agent-note-forge-changes-view-responsiveness/consequences:
en: 4087fff405624d95
zh: 2f0e577eea4daf40
@@ -0,0 +1,33 @@
# 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 reaches only the menus and the composer through a context. 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. A jump keeps its target aligned for one second while nearby sections draw.
**Idle-time highlighting.** Lines draw as plain text and take their tokens once the page is idle and the scrollport has been still for 200 ms, so tokenization never interrupts a scroll in progress. A pass highlights 100-line fragments while each idle period lasts and runs again only when the file's segments change or its own language's grammar loads; the shared highlighter's identity, which changes on every grammar load, no longer triggers one.
**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.
## 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.
**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, highlighting restarts at 100-line fragment boundaries, so a multi-line comment or string crossing one can be mis-colored, and the first use of a slow grammar, such as C++, still produces a 400–550 ms long task while scrolling. A diff read costs a second compare request when the base commit changed after the latest status.
@@ -0,0 +1,33 @@
# 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` 的值在多次渲染之间保持同一标识:一个处理函数对象、一个评论操作对象,以及每个文件的字符串和布尔值。标签页可见性通过 context 只传给菜单和评论编辑器。展开的文件在它的段第一次进入滚动区域 1200 px 范围之前,按每个绘制行 22 px 占位,之后绘制各行并一直保留。跳转后的一秒内,目标文件在周围文件段绘制时保持对齐。
**空闲时高亮。** 各行先以纯文本绘制,在页面空闲且滚动区域静止 200 ms 后得到 token,因此分词不会打断正在进行的滚动。一次高亮在每个空闲时段内处理多个 100 行的片段,只有文件的分段变化或该语言自己的语法加载完成时才重新运行;共享高亮器的标识在任何语法加载时都会变化,不再触发重新高亮。
**减少串行读取。** 与已发布状态相同的面板状态不会通知任何关注方。`changeRequestDiff` 同时读取补丁和 `.gitattributes`。快照把基准提交作为 `baseSha` 携带;`ForgeService.diff` 把它传给提供方,forge-github 在 GraphQL 读取确认 head 的同时就开始 compare 读取。
## Alternatives considered
**只用 `content-visibility: auto`** 减少的样式工作与限定规则相当,但打开时仍要对每个展开文件做 React 渲染和 Shiki 分词,约 3 秒脚本时间,而且每个折叠和展开的段都需要估计占位尺寸。
**同时卸载远处文件段的完整虚拟化** 可以在长距离滚动后限制 DOM 规模,但文件段持有评论草稿和文本选区,卸载会丢失它们。
**在 Web Worker 中高亮** 能把分词完全移出主线程,包括重型语法的首次使用,但 Shiki 由 `ui-primitives` 中的共享高亮器持有;迁移它是影响所有使用方的另一项改动。
**由提供方私有缓存基准提交** 可以省去可选的 `baseSha` 字段,但会引入保留状态,而且没有一个知道变更请求何时不再被 watch 的所有者。
## Consequences
打开 #7985 的主线程工作从 5.2 秒降到 0.5 秒,跳转文件、筛选、轮询和 Agent 回合不再产生长任务。代价是:浏览器的页内查找看不到读者尚未接近的文件中的行;高亮在 100 行片段边界处重新开始,跨越边界的多行注释或字符串可能着色错误;慢语法(例如 C++)首次使用时,滚动中仍会出现 400–550 ms 的长任务。最新状态之后基准提交有变化时,一次 diff 读取会多一次 compare 请求。
@@ -18,8 +18,8 @@
en: c7e1b7d87ccccd2e
zh: 42d864b918a6175c
/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation:
en: 0afedb76112dfcb8
zh: 52d5ddc7a4f9d31c
en: d0756f4b236e1e6f
zh: 9a02f6fa80118c5a
/deepseek-ai-dsh-experimental-client-ui-forge/further-exploration:
en: fa4d8c579ffc49ff
zh: 5856ed7469e4585b
@@ -73,7 +73,7 @@ Each inspection also decides whether the worktree is missing: the Session's dire
`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 passes each file section values that keep their identity across its renders, so a render redraws only the sections whose values changed. An expanded file draws its lines 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; the lines are highlighted with the shared code highlighter when the page is next idle, again only when the file's segments change or its language's grammar finishes loading; 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, so a render redraws only the sections whose values changed. An expanded file draws its lines 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; the lines are highlighted with the shared code highlighter in 100-line fragments when the page is idle and the scrollport has been still for 200 ms, again only when the file's segments change or its language's grammar finishes loading; a chip title shows its name with the changes icon.
| Source | Responsibility |
|---|---|
@@ -73,7 +73,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi
`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 读取,并且传给每个文件段的值在多次渲染之间保持同一标识,所以一次渲染只重绘值有变化的文件段。展开的文件在它的段第一次进入滚动区域 1200px 范围内时才绘制各行,此后一直保留;在此之前按每个绘制行 22px 占位。各行在页面下次空闲时用共享的代码高亮器高亮,之后只在文件的分段变化或该语言的语法加载完成时重新高亮;标签页标题显示变更图标与视图名称。
浏览器入口先挂载 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 占位。各行在页面空闲且滚动区域静止 200 ms 后,用共享的代码高亮器按 100 行一片高亮,之后只在文件的分段变化或该语言的语法加载完成时重新高亮;标签页标题显示变更图标与视图名称。
| 源码 | 职责 |
|---|---|
@@ -611,7 +611,7 @@ export function ChangesTab({
const release = (): void => {
cancelAnimationFrame(frame)
for (const type of READER_SCROLL) port.removeEventListener(type, release)
if (releasePin.current === release) releasePin.current = null
releasePin.current = null
}
const keep = (now: number): void => {
if (now > until) {
@@ -880,6 +880,7 @@ export function ChangesTab({
<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) => {
reach.scrolled()
if (jumpPosition.current?.top !== event.currentTarget.scrollTop) jumpPosition.current = null
}}>
{files.map((file) => {
@@ -160,12 +160,13 @@ function addChunkSpans(spans: FileSpans, chunk: HighlightChunk, highlighter: Cod
}
/**
* Highlight a file's drawn lines in idle time, once its language's grammar is ready: each idle period highlights
* fragments while it lasts, at least one, and the lines take their tokens when the last fragment is done. Until
* then, the lines keep the tokens of the previous pass, by line number, and new lines draw as plain text. A grammar
* that loads for another language starts no pass.
* Highlight a file's drawn lines in idle time while the scrollport is still, once its language's grammar is ready:
* each idle period highlights fragments while it lasts, at least one, and the lines take their tokens when the last
* fragment is done. Until then, the lines keep the tokens of the previous pass, by line number, and new lines draw as
* plain text. A grammar that loads for another language starts no pass.
*/
function useFileSpans(path: string, segments: readonly DiffSegment[]): FileSpans | undefined {
const reach = useContext(SectionReachContext)
const highlighter = useCodeHighlighter(languageForPath(path))
// The highlighter changes whenever any grammar loads; the empty fragment tells whether this language's is ready.
const ready = highlighter('') !== undefined
@@ -188,11 +189,13 @@ function useFileSpans(path: string, segments: readonly DiffSegment[]): FileSpans
next++
if ((deadline?.timeRemaining() ?? 0) <= 0) break
}
cancel = whenIdle(step)
cancel = later(step)
}
let cancel = whenIdle(step)
// Outside a changes view nothing scrolls the sections, so idle time alone gates the pass.
const later = reach === null ? whenIdle : (task: (deadline: IdleDeadline | undefined) => void) => reach.whenSettled(task)
let cancel = later(step)
return () => { cancel() }
}, [segments, ready])
}, [segments, ready, reach])
return spans
}
@@ -9,6 +9,9 @@ import { createContext } from 'react'
/** Distance beyond the scrollport's edges within which a section counts as reached. */
const REACH_MARGIN = '1200px 0px'
/** Time, in milliseconds, the scrollport stays still before deferred highlighting runs. */
const SCROLL_QUIET_MS = 200
const noop = (): void => {}
/**
@@ -18,6 +21,33 @@ const noop = (): void => {}
export class SectionReach {
private observer: IntersectionObserver | undefined
private readonly waiting = new Map<Element, () => void>()
private scrolledAt = Number.NEGATIVE_INFINITY
/** Record that the scrollport scrolled, which holds back work started through {@link whenSettled}. */
scrolled(): void {
this.scrolledAt = performance.now()
}
/**
* Run `task` once the page is idle and the scrollport has been still for {@link SCROLL_QUIET_MS}, so work that
* can wait never interrupts a scroll in progress.
* @param task - deferrable work; it receives the idle period, or undefined without one.
* @returns the canceller, which has no effect after `task` ran.
*/
whenSettled(task: (deadline: IdleDeadline | undefined) => void): () => void {
let cancel = noop
const attempt = (deadline: IdleDeadline | undefined): void => {
const wait = this.scrolledAt + SCROLL_QUIET_MS - performance.now()
if (wait <= 0) {
task(deadline)
return
}
const id = setTimeout(() => { cancel = whenIdle(attempt) }, wait)
cancel = () => { clearTimeout(id) }
}
cancel = whenIdle(attempt)
return () => { cancel() }
}
/**
* Measure sections against `root`, or stop measuring when it unmounts. Sections that are still waiting move to the new root.
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
/** Diff gestures, browser clipboard output, and retained comments at their captured lines. */
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { DiffFile, type DiffFileProps } from '../src/client/DiffFile.tsx'
@@ -249,3 +249,9 @@ 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('highlights a section drawn outside a changes view once the page is idle', async () => {
const m = mount({ file: { ...file, path: 'a.ts' } })
expect(m.container.querySelector('[data-diff-code]')).toBeNull()
await waitFor(() => { expect(m.container.querySelectorAll('[data-diff-code]').length).toBeGreaterThan(0) })
})
@@ -81,6 +81,42 @@ describe('SectionReach', () => {
})
})
describe('SectionReach.whenSettled', () => {
it('waits for the scrollport to stay still before running deferred work, and can be cancelled while it waits', () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] })
try {
vi.stubGlobal('requestIdleCallback', undefined)
const reach = new SectionReach()
const settled = vi.fn()
reach.whenSettled(settled)
vi.advanceTimersByTime(0)
expect(settled).toHaveBeenCalledOnce()
reach.scrolled()
const held = vi.fn()
reach.whenSettled(held)
vi.advanceTimersByTime(0)
expect(held).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
reach.scrolled()
vi.advanceTimersByTime(100)
expect(held).not.toHaveBeenCalled()
vi.advanceTimersByTime(200)
expect(held).toHaveBeenCalledOnce()
reach.scrolled()
const cancelled = vi.fn()
const stop = reach.whenSettled(cancelled)
vi.advanceTimersByTime(0)
stop()
vi.advanceTimersByTime(500)
expect(cancelled).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
describe('whenIdle', () => {
it('waits for an idle period, at most the timeout, and can be cancelled', () => {
const request = vi.fn<(task: () => void, options: { timeout: number }) => number>(() => 7)