Merge pull request #4780 from deepseek-harness/ihsiang/changed-files-hover-preview

feat(web): preview changed-file diffs on hover
This commit is contained in:
CreatixChu authored and GitHub committed 2026-09-21 15:36:12 +08:00
commit 683625b793
22 files changed
+835 -345

No files matched your search

+92
View File
@@ -32,6 +32,28 @@ async function seedRepository(cwd: string): Promise<void> {
git('commit', '-q', '-m', 'seed')
}
/** Sample the real opacity transition at its midpoint without depending on wall-clock scheduling. */
function hoverOpacityTransition(page: Page, closing: boolean): Promise<{ duration: number; opacity: number }> {
return page.evaluate(closing => new Promise((resolve) => {
const observer = new MutationObserver(() => {
const card = document.querySelector('[data-changes-hover-preview]')?.parentElement
if (card === undefined || card === null || card.hasAttribute('data-closing') !== closing) return
const initialOpacity = Number(getComputedStyle(card).opacity)
const animation = card.getAnimations().find(animation => animation instanceof CSSTransition && animation.transitionProperty === 'opacity')
observer.disconnect()
if (animation === undefined) { resolve({ duration: 0, opacity: initialOpacity }); return }
animation.pause()
animation.currentTime = 50
const opacity = Number(getComputedStyle(card).opacity)
const duration = Number(animation.effect!.getTiming().duration)
animation.finish()
resolve({ duration, opacity })
})
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-closing'] })
if (closing) window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}), closing)
}
describe('web e2e: a git workspace turn ends with its changed files', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -180,6 +202,76 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
expect(tripwire.warnings).toEqual([])
})
it('previews a single column with a scrollable path and no file notes or hunk headers', async () => {
const card = page.locator('[data-changed-files]')
const row = card.getByRole('button', { name: '查看 notes.txt 的改动' })
const preview = page.locator('[data-changes-hover-preview]')
const entrance = hoverOpacityTransition(page, false)
await row.hover()
expect(await entrance).toEqual({ duration: 100, opacity: 0.5 })
await preview.locator('[data-review-view="unified"]').waitFor({ state: 'visible' })
expect(await preview.locator('[data-diff-side]').count()).toBe(0)
expect(await preview.locator('[data-diff-hunk-header]').isVisible()).toBe(false)
const geometry = await preview.evaluate((element) => {
const popup = element.parentElement!.getBoundingClientRect()
const card = document.querySelector('[data-changed-files]')!.getBoundingClientRect()
return { inset: popup.left - card.left, width: popup.width, cardWidth: card.width, height: popup.height }
})
expect(geometry.inset).toBe(24)
expect(geometry.width).toBe(geometry.cardWidth - 48)
expect(geometry.height).toBeLessThanOrEqual(420)
const path = preview.locator('[data-changes-preview-path]')
expect(await row.evaluate(element => document.getElementById(element.getAttribute('aria-describedby')!)?.textContent)).toBe(await path.textContent())
expect(await path.getAttribute('tabindex')).toBeNull()
const pathStyle = await path.evaluate((element) => {
const style = getComputedStyle(element)
return { family: style.fontFamily, overflow: style.overflowX, whiteSpace: style.whiteSpace, ellipsis: style.textOverflow }
})
expect(pathStyle.family).toMatch(/mono/i)
expect(pathStyle).toMatchObject({ overflow: 'auto', whiteSpace: 'nowrap', ellipsis: 'clip' })
await path.evaluate((element) => { element.style.maxWidth = '160px'; element.scrollLeft = 50 })
expect(await path.evaluate(element => element.scrollLeft)).toBe(50)
await path.evaluate((element) => { element.style.removeProperty('max-width') })
await compareOrRefreshGolden(join(DIR, 'hover.expected.md'), await preview.locator('[data-review-view]').ariaSnapshot(), MODE)
await preview.hover()
await preview.locator('[data-review-view]').evaluate((element) => {
const line = element.querySelector('[data-diff-line]')!
for (let index = 0; index < 40; index++) element.appendChild(line.cloneNode(true))
})
await expect.poll(() => preview.evaluate(element => element.parentElement!.getBoundingClientRect().height)).toBe(420)
const scroll = preview.locator('[data-review-view]')
await scroll.evaluate((element) => { element.scrollTop = 80 })
await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBe(80)
await row.click()
await preview.waitFor({ state: 'detached' })
const review = page.locator('[data-changes-review]')
await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' })
await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' })
await card.getByRole('button', { name: '查看 src/util.ts 的改动' }).hover()
await preview.locator('[data-diff-code]').first().waitFor({ state: 'visible' })
expect(await preview.locator('[data-review-view="unified"]').count()).toBe(1)
expect(await preview.locator('[data-diff-note]').isVisible()).toBe(false)
// Empty comparisons keep their status; only metadata accompanying code is hidden.
await preview.locator('[data-diff-note]').evaluate((element) => { element.setAttribute('data-diff-note', 'empty') })
expect(await preview.locator('[data-diff-note]').isVisible()).toBe(true)
await preview.locator('[data-diff-note]').evaluate((element) => { element.setAttribute('data-diff-note', 'metadata') })
expect(await preview.locator('[data-diff-hunk-header]').isVisible()).toBe(false)
await expect.poll(() => preview.evaluate(element => Number(getComputedStyle(element.parentElement!).opacity))).toBe(1)
expect(await hoverOpacityTransition(page, true)).toEqual({ duration: 100, opacity: 0.5 })
await preview.waitFor({ state: 'detached' })
for (const key of ['Enter', 'Space']) {
await page.mouse.move(0, 0)
await row.focus()
await row.hover()
await preview.locator('[data-review-view="unified"]').waitFor({ state: 'visible' })
await row.press(key)
await preview.waitFor({ state: 'detached' })
await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' })
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it('reviews a shell-appended file from the snapshots and an ignored file from its captured copies in one tab', async () => {
const card = page.locator('[data-changed-files]')
const column = page.locator('[data-rightbar-col]')
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
README.md: ce006783310c257957bc8cfddcfd18226972d18f
README.zh.md: 82239ba169f4ada188e8a5ba8e1889c61b336f96
README.md: 7bf55c474250b8e389a1767092be0f82b4073222
README.zh.md: d8b1fc368487d96091b400c6034d159989352192
+1 -1
View File
@@ -38,7 +38,7 @@ The `present` tool row retains its document icon and recorded result across prog
Developer tools must be enabled to show this card or request its summary. Turning the preference off removes the card immediately while explicit delivery cards, inline file links, and recorded workspace changes remain available.
The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count and summed added and deleted lines; hovering or focusing it replaces the counts with “Preview in sidebar”. Each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Four rows show before a fold; the control reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file.
The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count and summed added and deleted lines; hovering or focusing it replaces the counts with “Preview in sidebar”. Each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Four rows show before a fold; the control reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. Hovering a file row for 500ms opens a scrollable, single-column preview of the comparison, independent of the Sidebar’s view. Its full path uses muted monospace text and scrolls horizontally when needed. File-status notes accompanying code and hunk headers are hidden in the preview; comparisons without hunks retain their status text. Each row exposes the full path as its accessible description. The preview is 48px narrower than the card, inset 24px on each side, and at most 420px high, further constrained by the viewport. The whole preview fades in and out over 100ms. Moving into the preview keeps it open; leaving it, pressing Escape, or activating the row by pointer or keyboard dismisses it. Comparisons load only when the preview opens and share the review tab’s cache and retry states.
### The review tab
+1 -1
View File
@@ -38,7 +38,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明
只有开启开发者工具才展示此卡片并请求其摘要。关闭该偏好会立即隐藏卡片,同时保留显式交付卡片、行内文件链接和已记录的工作区改动。
卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计;鼠标悬停或键盘聚焦时,统计信息替换为“在侧边栏预览”。每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示四行;控件展开全部记录文件,展开后从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。
卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计;鼠标悬停或键盘聚焦时,统计信息替换为“在侧边栏预览”。每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示四行;控件展开全部记录文件,展开后从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。 在文件行上悬停 500ms 后,会出现可滚动的单列改动浮层,不受 Sidebar 视图设置影响。顶部完整路径使用较浅的等宽字体,超出宽度时可横向滚动。浮层不显示代码旁的文件状态说明和 hunk 头;没有 hunk 的对比仍显示状态文字。每行通过无障碍描述提供完整路径。浮层比卡片窄 48px,左右各内缩 24px,高度最多 420px,并受视口可用空间限制。整个浮层的淡入和淡出各持续 100ms。鼠标移入浮层后保持打开;移出、按 Escape 或通过鼠标和键盘激活文件行会关闭浮层。对比只在浮层打开时加载,与 review tab 共用缓存及重试状态。
### review tab
@@ -29,3 +29,8 @@ button.header:disabled { cursor: progress; }
.toggle:hover { background: var(--dsw-alias-interactive-bg-hover); }
.toggle svg { flex: none; width: 14px; height: 14px; }
@media (pointer: coarse) { .row, .toggle { min-height: 44px; } }
.preview { max-height: 100%; overflow: hidden; }
.previewPath { flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code); font-size: 12px; line-height: 20px; white-space: nowrap; }
.preview [data-diff-note='metadata'], .preview [data-diff-hunk-header] { display: none; }
@@ -1,13 +1,19 @@
/** The changed-files card: a header and per-file rows that open the turn's review, and a four-row fold. */
import { useState } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChangesSummary } from '../changes.ts'
import { HoverCard, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { changesDiffUrl, type ChangesSummary } from '../changes.ts'
import type { DeliverablesInjected } from './Deliverables.tsx'
import { FileDiff } from './FileDiff.tsx'
import diffCss from './FileDiff.module.css'
import { IconCodeBracketsOutline16 } from './icons.tsx'
import type { NS } from './locales.ts'
import css from './ChangedFiles.module.css'
type PreviewProps = Pick<InjectFace<DeliverablesInjected>, 'useChangesDiff' | 'loadChangesDiff'>
& Pick<SessionStandardProps, 'sessionId'>
/** Rows shown before the fold; the design's summary height for a closing message. */
const COLLAPSED_ROWS = 4
@@ -23,21 +29,24 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop
/**
* Render one turn's changed files. The header opens the turn's review in the
* right Sidebar on its first file; each row opens it on that row's file.
* right Sidebar on its first file; each row previews its comparison after a
* 500ms hover and opens the review on click.
* @param props - the recorded summary, the review opener, and localized copy.
* @returns the card.
*/
export function ChangedFiles({ changes, cwd, openReview, t }: {
export function ChangedFiles({ changes, cwd, openReview, t, sessionId, useChangesDiff, loadChangesDiff }: {
/** The served summary with the sequence of the event that announced it. */
changes: Pick<ChangesSummary, 'files' | 'total' | 'added' | 'deleted'> & { seq: number }
cwd: string | undefined
/** Open the turn's review on the file at an original summary index. */
openReview: (index: number) => void
} & PropsLocale<typeof NS>) {
} & PropsLocale<typeof NS> & PreviewProps) {
const cardRef = useRef<HTMLDivElement>(null)
const pathDescriptionId = useId()
const [expanded, setExpanded] = useState(false)
const foldable = changes.files.length > COLLAPSED_ROWS
const rows = foldable && !expanded ? changes.files.slice(0, COLLAPSED_ROWS) : changes.files
return <div className={css.card} data-changed-files>
return <div ref={cardRef} className={css.card} data-changed-files>
<button type="button" className={css.header} aria-label={t('changes.openReview')} onClick={() => { openReview(0) }}>
<span className={css.tile}>
<span className={css.tileMark}><IconCodeBracketsOutline16 size={10} /></span>
@@ -53,16 +62,21 @@ export function ChangedFiles({ changes, cwd, openReview, t }: {
<ul className={css.list}>
{rows.map((file, index) => (
<li key={file.display}>
<button type="button" className={css.row} title={resolveWorkspacePath(cwd, file.path)}
aria-label={t('changes.viewDiff', { name: file.display })}
onClick={() => { openReview(index) }}>
<span className={css.path}>{file.display}</span>
<span className={css.counts}>
{file.binary === true ? t('changes.binary')
: file.oversized === true ? t('changes.oversized')
: <Counts t={t} added={file.added} deleted={file.deleted} />}
</span>
</button>
<HoverCard variant="preview" widthAnchorRef={cardRef} openDelayMs={500}
content={<ChangedFilePreview sessionId={sessionId} seq={changes.seq} index={index}
display={resolveWorkspacePath(cwd, file.path)} useChangesDiff={useChangesDiff} loadChangesDiff={loadChangesDiff} t={t} />}
anchor={<button type="button" className={css.row}
aria-label={t('changes.viewDiff', { name: file.display })}
aria-describedby={`${pathDescriptionId}-${index}`}
onClick={() => { openReview(index) }}>
<span className={css.path}>{file.display}</span>
<span className={css.counts}>
{file.binary === true ? t('changes.binary')
: file.oversized === true ? t('changes.oversized')
: <Counts t={t} added={file.added} deleted={file.deleted} />}
</span>
</button>} />
<span id={`${pathDescriptionId}-${index}`} hidden>{resolveWorkspacePath(cwd, file.path)}</span>
</li>
))}
</ul>
@@ -75,3 +89,19 @@ export function ChangedFiles({ changes, cwd, openReview, t }: {
</button>}
</div>
}
/** Mounted only while its hover card is open, so passing over a row does not read a comparison. */
function ChangedFilePreview({ sessionId, seq, index, display, useChangesDiff, loadChangesDiff, t }: PreviewProps & {
seq: number
index: number
display: string
} & PropsLocale<typeof NS>) {
const state = useChangesDiff(value => value[changesDiffUrl(sessionId, seq, index)])
useEffect(() => {
if (state === undefined) void loadChangesDiff(sessionId, seq, index)
}, [state, sessionId, seq, index, loadChangesDiff])
return <div className={`${diffCss.root} ${css.preview}`} data-changes-hover-preview>
<div className={diffCss.header}><span className={css.previewPath} data-changes-preview-path>{display}</span></div>
<FileDiff state={state} split={false} wrap={false} t={t} retry={() => { void loadChangesDiff(sessionId, seq, index) }} />
</div>
}
@@ -5,6 +5,7 @@ import { Button, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } fr
import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { PresentedOpenController } from './present-open.ts'
import type { ChangesDiffStore } from './changes-diff.ts'
import type { ChangesSummaryStore } from './changes-summary.ts'
import { ChangedFiles } from './ChangedFiles.tsx'
import { changesForClosing, presentedForClosing, type ChangesTurnData, type PresentedPath } from './turn-deliverables.ts'
@@ -21,12 +22,14 @@ const COLLAPSED_PRESENTED_COUNT = 4
/** Summary reads, native-open callbacks, and shared gesture status supplied by the plugin. */
export interface DeliverablesInjected {
hooks: {
changesDiff: ObservableSnapshot<ReturnType<ChangesDiffStore['state']['getSnapshot']>>
showCodeDiff: ObservableSnapshot<boolean>
presentedOpen: ObservableSnapshot<ReturnType<PresentedOpenController['state']['getSnapshot']>>
presentedHost: ObservableSnapshot<ReturnType<PresentedOpenController['host']['getSnapshot']>>
changesSummary: ObservableSnapshot<ReturnType<ChangesSummaryStore['state']['getSnapshot']>>
}
reloadPresentedHost: PresentedOpenController['loadHost']
loadChangesDiff: ChangesDiffStore['load']
loadChangesSummary: ChangesSummaryStore['load']
openPresented: PresentedOpenController['open']
openChanged: PresentedOpenController['openChanged']
@@ -64,7 +67,7 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail
*/
export function Deliverables({
matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost,
useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot,
useChangesDiff, loadChangesDiff, useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot,
}: Pick<TurnTailOwnerProps, 'openFile'> & {
matched: DeliverablesMatch
} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions'> & InjectFace<DeliverablesInjected> & PropsRenderSlots<'deliverables.file.actions'>) {
@@ -90,6 +93,7 @@ export function Deliverables({
}, [host, reloadPresentedHost])
return <>
{changes !== null && <ChangedFiles changes={changes} cwd={cwd} t={t}
sessionId={sessionId} useChangesDiff={useChangesDiff} loadChangesDiff={loadChangesDiff}
openReview={(index) => { openChangesReview({ sessionId, seq: changes.seq, turn: changes.turn }, index) }} />}
{matched.presented.length > 0 && <div
className={css.root}
@@ -0,0 +1,28 @@
/** Shared comparison layout, header, states, and numbered diff lines for previews and review tabs. */
.root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; min-height: 0; color: var(--dsw-alias-label-primary); }
:global(body[data-ds-dark-theme]) .root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
.header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); }
.status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); }
.body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); }
.columns { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; }
.column { min-width: 0; overflow-x: scroll; }
.column + .column { border-left: 0.5px solid var(--dsw-alias-border-l3); }
.sideLine { display: grid; grid-template-columns: 3.5em max-content; box-sizing: border-box; min-width: 100%; width: max-content; min-height: 22px; line-height: 22px; white-space: pre; }
.note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); }
.hunk { margin-bottom: 8px; }
.hunkHeader { padding: 4px 16px; color: var(--dsw-alias-label-tertiary); white-space: pre; }
.line { display: grid; grid-template-columns: 3.5em 3.5em 1.2em minmax(0, 1fr); box-sizing: border-box; min-height: 22px; line-height: 22px; white-space: pre; }
.splitLine { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 22px; line-height: 22px; white-space: pre; }
.cell { display: grid; grid-template-columns: 3.5em minmax(0, 1fr); box-sizing: border-box; min-width: 0; }
.cell + .cell { border-left: 0.5px solid var(--dsw-alias-border-l3); }
.number { padding-right: 8px; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; }
.sign { text-align: center; user-select: none; }
.text { padding-right: 16px; }
.body[data-review-wrap] .line, .body[data-review-wrap] .splitLine { white-space: pre-wrap; }
.body[data-review-wrap] .text { overflow-wrap: anywhere; }
.add { background: var(--diff-add-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary); }
.add .sign, .add .text { color: var(--dsw-alias-state-success-primary); }
.del { background: var(--diff-del-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary); }
.del .sign, .del .text { color: var(--dsw-alias-state-error-primary); }
.context .text { color: var(--dsw-alias-label-secondary); }
.empty { background: var(--diff-empty-fill); }
@@ -0,0 +1,253 @@
/** Shared file comparison for the turn-tail hover preview and Sidebar review. */
import { useMemo, useRef } from 'react'
import type { ReactNode, UIEvent } from 'react'
import { Button, languageForPath, useCodeHighlighter } from '@deepseek-ai/dsh-client-ui-primitives'
import type { CodeHighlighter, HighlightSpan } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types'
import type { ChangesDiff } from '../changes.ts'
import type { ChangesDiffState } from './changes-diff.ts'
import type { NS } from './locales.ts'
import css from './FileDiff.module.css'
/** Maximum rendered lines per comparison. */
export const MAX_RENDERED_LINES = 5000
/** One drawn line of a hunk with its line numbers on each side. */
export interface DiffRow {
kind: 'add' | 'del' | 'context'
old: number | undefined
new: number | undefined
text: string
}
/** One side-by-side row: the old side, the new side, or both. */
export interface SplitRow {
left?: { no: number; text: string; kind: 'del' | 'context' }
right?: { no: number; text: string; kind: 'add' | 'context' }
}
interface HunkHighlights {
old: ReadonlyMap<number, readonly HighlightSpan[]> | undefined
new: ReadonlyMap<number, readonly HighlightSpan[]> | undefined
}
function highlightedSide(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): HunkHighlights['old'] {
const source = rows.flatMap((row) => {
const no = row[side]
return no === undefined ? [] : [{ no, text: row.text }]
})
if (source.length === 0) return new Map()
const highlighted = highlighter(source.map(line => line.text).join('\n'))
if (highlighted === undefined) return undefined
return new Map(source.map((line, index) => {
// Shiki omits one terminal empty token row; retain an aligned empty run for that source line.
return [line.no, highlighted[index] ?? []]
}))
}
function hunkHighlights(hunk: WorkspaceDiffHunk, highlighter: CodeHighlighter): HunkHighlights {
const rows = hunkRows(hunk)
return { old: highlightedSide(rows, 'old', highlighter), new: highlightedSide(rows, 'new', highlighter) }
}
function DiffText({ text, spans }: { text: string; spans: readonly HighlightSpan[] | undefined }): ReactNode {
return <span className={css.text} data-diff-code={spans === undefined ? undefined : ''}>
{spans === undefined ? text : spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)}
</span>
}
/**
* Number a hunk's lines: context lines count on both sides, deletions on the
* old side, additions on the new side.
* @param hunk - a served hunk.
* @returns the rows in order.
*/
export function hunkRows(hunk: WorkspaceDiffHunk): DiffRow[] {
let oldNo = hunk.oldStart
let newNo = hunk.newStart
return hunk.lines.map((line) => {
const text = line.slice(1)
switch (line[0]) {
case '+': return { kind: 'add', old: undefined, new: newNo++, text }
case '-': return { kind: 'del', old: oldNo++, new: undefined, text }
default: return { kind: 'context', old: oldNo++, new: newNo++, text }
}
})
}
/**
* Pair a hunk's lines for the side-by-side view: each run of deletions is
* aligned with the run of additions that follows it, row by row, and context
* lines sit on both sides.
* @param hunk - a served hunk.
* @returns the rows in order.
*/
export function splitRows(hunk: WorkspaceDiffHunk): SplitRow[] {
const rows: SplitRow[] = []
let dels: NonNullable<SplitRow['left']>[] = []
let adds: NonNullable<SplitRow['right']>[] = []
const flush = (): void => {
for (let at = 0; at < Math.max(dels.length, adds.length); at += 1) {
const left = dels[at]
const right = adds[at]
rows.push({ ...left === undefined ? {} : { left }, ...right === undefined ? {} : { right } })
}
dels = []
adds = []
}
for (const row of hunkRows(hunk)) {
if (row.kind === 'del') dels.push({ no: row.old as number, text: row.text, kind: 'del' })
else if (row.kind === 'add') adds.push({ no: row.new as number, text: row.text, kind: 'add' })
else {
flush()
rows.push({ left: { no: row.old as number, text: row.text, kind: 'context' }, right: { no: row.new as number, text: row.text, kind: 'context' } })
}
}
flush()
return rows
}
/**
* The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total.
* @param hunks - served hunks.
* @returns the hunks with the last one shortened as needed, and whether anything was cut.
*/
export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } {
let budget = MAX_RENDERED_LINES
const kept: WorkspaceDiffHunk[] = []
for (const hunk of hunks) {
if (budget === 0) return { hunks: kept, truncated: true }
kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) })
budget -= Math.min(budget, hunk.lines.length)
}
return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) }
}
/** The one-line fact about a text comparison worth stating above its hunks, if any. */
function noteOf(diff: Extract<ChangesDiff, { kind: 'text' }>): 'diff.created' | 'diff.deleted' | 'diff.unchanged' | undefined {
if (!diff.before) return 'diff.created'
if (!diff.after) return 'diff.deleted'
if (diff.hunks.length === 0) return 'diff.unchanged'
return undefined
}
/**
* Render a file comparison with the same states and highlighting in previews and review tabs.
* @param props - comparison state, layout choices, retry action, and localized copy.
* @returns the comparison or its loading, unavailable, or error state.
*/
export function FileDiff({ state, split, wrap, retry, t }: {
state: ChangesDiffState | undefined
split: boolean
wrap: boolean
retry: () => void
} & PropsLocale<typeof NS>): ReactNode {
if (state === undefined || state === 'loading') return <p className={css.status} role="status">{t('diff.loading')}</p>
if (state === 'missing') return <p className={css.status}>{t('diff.missing')}</p>
if (state === 'error') {
return <div className={css.status}><span>{t('diff.error')}</span><Button size="sm" onClick={retry}>{t('presented.retry')}</Button></div>
}
if (state.kind === 'binary') return <p className={css.status}>{t('diff.binary')}</p>
if (state.kind === 'oversized') return <p className={css.status}>{t('diff.oversized')}</p>
return <TextDiff diff={state} split={split} wrap={wrap} t={t} />
}
/** The kind a paired row carries: a deletion or addition on either side, otherwise context. */
function splitRowKind(row: SplitRow): DiffRow['kind'] {
return row.left?.kind === 'del' ? 'del' : row.right?.kind === 'add' ? 'add' : 'context'
}
function hunkHeader(hunk: WorkspaceDiffHunk): string {
return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`
}
/**
* The side-by-side view without wrapping: two columns that clip their long
* lines and scroll together on both axes, so a long line on one side never
* runs under the other and both sides show the same rows and columns of text.
* Every line is one fixed-height row, which keeps the sides aligned.
*/
function SplitColumns({ hunks, highlights }: { hunks: readonly WorkspaceDiffHunk[]; highlights: readonly HunkHighlights[] }): ReactNode {
const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks])
const columns = useRef<Record<'left' | 'right', HTMLDivElement | null>>({ left: null, right: null })
const offsets = useRef({ left: { scrollLeft: 0, scrollTop: 0 }, right: { scrollLeft: 0, scrollTop: 0 } })
const follow = (side: 'left' | 'right') => (event: UIEvent<HTMLDivElement>): void => {
const peer = side === 'left' ? 'right' : 'left'
const other = columns.current[peer]
/* v8 ignore next -- Both column refs are attached before browser scroll events can run. */
if (other === null) return
for (const axis of ['scrollLeft', 'scrollTop'] as const) {
const value = event.currentTarget[axis]
if (offsets.current[side][axis] === value) continue
offsets.current[side][axis] = value
other[axis] = value
// Record the browser-clamped offset so its scroll event cannot pull the source back.
offsets.current[peer][axis] = other[axis]
}
}
return (
<div className={css.columns}>
{(['left', 'right'] as const).map(side => (
<div key={side} className={css.column} data-diff-side={side}
ref={(element) => { columns.current[side] = element }} onScroll={follow(side)}>
{paired.map((hunk, position) => (
<section key={position} className={css.hunk}>
<div className={css.hunkHeader} data-diff-hunk-header>{hunk.header}</div>
{hunk.rows.map((row, at) => {
const cell = row[side]
const spans = cell === undefined ? undefined : highlights[position]?.[side === 'left' ? 'old' : 'new']?.get(cell.no)
return (
<div key={at} className={`${css.sideLine} ${cell === undefined ? css.empty : css[cell.kind]}`} data-diff-line={splitRowKind(row)}>
<span className={css.number}>{cell?.no ?? ''}</span>
<DiffText text={cell?.text ?? ''} spans={spans} />
</div>
)
})}
</section>
))}
</div>
))}
</div>
)
}
/** The hunks of a text comparison with their line numbers, unified or side by side. */
function TextDiff({ diff, split, wrap, t }: { diff: Extract<ChangesDiff, { kind: 'text' }>; split: boolean; wrap: boolean } & PropsLocale<typeof NS>): ReactNode {
const note = noteOf(diff)
const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks])
const highlighter = useCodeHighlighter(languageForPath(diff.path))
const highlights = useMemo(() => hunks.map(hunk => hunkHighlights(hunk, highlighter)), [hunks, highlighter])
return (
<div className={css.body} data-review-view={split ? 'split' : 'unified'} data-review-wrap={wrap || undefined}>
{note !== undefined && <p className={css.note} data-diff-note={hunks.length === 0 ? 'empty' : 'metadata'}>{t(note)}</p>}
{diff.coarse && <p className={css.note} data-diff-coarse>{t('diff.coarse')}</p>}
{truncated && <p className={css.note} data-diff-truncated>{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}</p>}
{split && !wrap ? <SplitColumns hunks={hunks} highlights={highlights} /> : hunks.map((hunk, position) => {
const highlighted = highlights[position]
return <section key={position} className={css.hunk}>
<div className={css.hunkHeader} data-diff-hunk-header>{hunkHeader(hunk)}</div>
{split ? splitRows(hunk).map((row, at) => (
<div key={at} className={css.splitLine} data-diff-line={splitRowKind(row)}>
<span className={`${css.cell} ${row.left === undefined ? css.empty : css[row.left.kind]}`}>
<span className={css.number}>{row.left?.no ?? ''}</span>
<DiffText text={row.left?.text ?? ''} spans={row.left === undefined ? undefined : highlighted?.old?.get(row.left.no)} />
</span>
<span className={`${css.cell} ${row.right === undefined ? css.empty : css[row.right.kind]}`}>
<span className={css.number}>{row.right?.no ?? ''}</span>
<DiffText text={row.right?.text ?? ''} spans={row.right === undefined ? undefined : highlighted?.new?.get(row.right.no)} />
</span>
</div>
)) : hunkRows(hunk).map((row, at) => (
<div key={at} className={`${css.line} ${css[row.kind]}`} data-diff-line={row.kind}>
<span className={css.number}>{row.old ?? ''}</span>
<span className={css.number}>{row.new ?? ''}</span>
<span className={css.sign}>{row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '}</span>
<DiffText text={row.text} spans={row.kind === 'add' ? highlighted?.new?.get(row.new as number) : highlighted?.old?.get(row.old as number)} />
</div>
))}
</section>
})}
</div>
)
}
@@ -1,7 +1,5 @@
/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns clip their lines and scroll together on both axes; wrapped ones share rows so the sides stay aligned. */
.root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); }
:global(body[data-ds-dark-theme]) .root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
.header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); }
/** Review tab sizing, file selector, and file action controls. */
.root { height: 100%; }
.selector { flex: 0 1 auto; min-width: 0; }
.selectorLabel { flex: 1 1 auto; min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.selectorButton { display: inline-flex; gap: 4px; align-items: center; box-sizing: border-box; max-width: 100%; height: 28px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; line-height: 20px; }
@@ -22,27 +20,3 @@
.tool[aria-pressed='true'] .compareIcon { transform: rotate(90deg); }
.tool:disabled { cursor: progress; }
.tool[data-error] { color: var(--dsw-alias-state-error-primary); }
.status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); }
.body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); }
.columns { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; }
.column { min-width: 0; overflow-x: scroll; }
.column + .column { border-left: 0.5px solid var(--dsw-alias-border-l3); }
.sideLine { display: grid; grid-template-columns: 3.5em max-content; box-sizing: border-box; min-width: 100%; width: max-content; min-height: 22px; line-height: 22px; white-space: pre; }
.note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); }
.hunk { margin-bottom: 8px; }
.hunkHeader { padding: 4px 16px; color: var(--dsw-alias-label-tertiary); white-space: pre; }
.line { display: grid; grid-template-columns: 3.5em 3.5em 1.2em minmax(0, 1fr); box-sizing: border-box; min-height: 22px; line-height: 22px; white-space: pre; }
.splitLine { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 22px; line-height: 22px; white-space: pre; }
.cell { display: grid; grid-template-columns: 3.5em minmax(0, 1fr); box-sizing: border-box; min-width: 0; }
.cell + .cell { border-left: 0.5px solid var(--dsw-alias-border-l3); }
.number { padding-right: 8px; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; }
.sign { text-align: center; user-select: none; }
.text { padding-right: 16px; }
.body[data-review-wrap] .line, .body[data-review-wrap] .splitLine { white-space: pre-wrap; }
.body[data-review-wrap] .text { overflow-wrap: anywhere; }
.add { background: var(--diff-add-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary); }
.add .sign, .add .text { color: var(--dsw-alias-state-success-primary); }
.del { background: var(--diff-del-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary); }
.del .sign, .del .text { color: var(--dsw-alias-state-error-primary); }
.context .text { color: var(--dsw-alias-label-secondary); }
.empty { background: var(--diff-empty-fill); }
@@ -3,30 +3,27 @@
* selected file's turn-start and turn-end comparison drawn unified or side by
* side, wrapped or scrolling, and controls to open the file itself.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode, UIEvent } from 'react'
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import {
Button, IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular,
IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular,
IconNowrapFillRegular, IconWrapFillRegular, Menu, Tooltip,
languageForPath, useCodeHighlighter,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { CodeHighlighter, HighlightSpan } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
import type { WorkspaceChangedFile, WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types'
import { changedFileUrl, changesDiffUrl, changesSummaryUrl, parseChangesReviewAddress, type ChangesDiff } from '../changes.ts'
import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types'
import { changedFileUrl, changesDiffUrl, changesSummaryUrl, parseChangesReviewAddress } from '../changes.ts'
import type { ChangesDiffStore } from './changes-diff.ts'
import type { ChangesSummaryStore } from './changes-summary.ts'
import type { PresentedOpenController } from './present-open.ts'
import type { ChangesReviewParams } from './review-definition.ts'
import type { createReviewStore } from './review-store.ts'
import type { NS } from './locales.ts'
import { FileDiff } from './FileDiff.tsx'
import diffCss from './FileDiff.module.css'
import css from './ReviewTab.module.css'
/** Lines drawn before the tab stops; a coarse comparison of a file near the byte cap would otherwise draw every line. */
export const MAX_RENDERED_LINES = 5000
const GROUPED = new Intl.NumberFormat('en-US')
/** Summary and comparison reads, desktop metadata, and the native open supplied by the plugin. */
@@ -47,125 +44,6 @@ export interface ReviewInjected {
export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore<ReturnType<typeof createReviewStore>>
& InjectFace<ReviewInjected> & PropsLocale<typeof NS> & PropsRenderSlots<'deliverables.review.file.actions'>
/** One drawn line of a hunk with its line numbers on each side. */
export interface DiffRow {
kind: 'add' | 'del' | 'context'
old: number | undefined
new: number | undefined
text: string
}
/** One side-by-side row: the old side, the new side, or both. */
export interface SplitRow {
left?: { no: number; text: string; kind: 'del' | 'context' }
right?: { no: number; text: string; kind: 'add' | 'context' }
}
interface HunkHighlights {
old: ReadonlyMap<number, readonly HighlightSpan[]> | undefined
new: ReadonlyMap<number, readonly HighlightSpan[]> | undefined
}
function highlightedSide(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): HunkHighlights['old'] {
const source = rows.flatMap((row) => {
const no = row[side]
return no === undefined ? [] : [{ no, text: row.text }]
})
if (source.length === 0) return new Map()
const highlighted = highlighter(source.map(line => line.text).join('\n'))
if (highlighted === undefined) return undefined
return new Map(source.map((line, index) => {
// Shiki omits one terminal empty token row; retain an aligned empty run for that source line.
return [line.no, highlighted[index] ?? []]
}))
}
function hunkHighlights(hunk: WorkspaceDiffHunk, highlighter: CodeHighlighter): HunkHighlights {
const rows = hunkRows(hunk)
return { old: highlightedSide(rows, 'old', highlighter), new: highlightedSide(rows, 'new', highlighter) }
}
function DiffText({ text, spans }: { text: string; spans: readonly HighlightSpan[] | undefined }): ReactNode {
return <span className={css.text} data-diff-code={spans === undefined ? undefined : ''}>
{spans === undefined ? text : spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)}
</span>
}
/**
* Number a hunk's lines: context lines count on both sides, deletions on the
* old side, additions on the new side.
* @param hunk - a served hunk.
* @returns the rows in order.
*/
export function hunkRows(hunk: WorkspaceDiffHunk): DiffRow[] {
let oldNo = hunk.oldStart
let newNo = hunk.newStart
return hunk.lines.map((line) => {
const text = line.slice(1)
switch (line[0]) {
case '+': return { kind: 'add', old: undefined, new: newNo++, text }
case '-': return { kind: 'del', old: oldNo++, new: undefined, text }
default: return { kind: 'context', old: oldNo++, new: newNo++, text }
}
})
}
/**
* Pair a hunk's lines for the side-by-side view: each run of deletions is
* aligned with the run of additions that follows it, row by row, and context
* lines sit on both sides.
* @param hunk - a served hunk.
* @returns the rows in order.
*/
export function splitRows(hunk: WorkspaceDiffHunk): SplitRow[] {
const rows: SplitRow[] = []
let dels: NonNullable<SplitRow['left']>[] = []
let adds: NonNullable<SplitRow['right']>[] = []
const flush = (): void => {
for (let at = 0; at < Math.max(dels.length, adds.length); at += 1) {
const left = dels[at]
const right = adds[at]
rows.push({ ...left === undefined ? {} : { left }, ...right === undefined ? {} : { right } })
}
dels = []
adds = []
}
for (const row of hunkRows(hunk)) {
if (row.kind === 'del') dels.push({ no: row.old as number, text: row.text, kind: 'del' })
else if (row.kind === 'add') adds.push({ no: row.new as number, text: row.text, kind: 'add' })
else {
flush()
rows.push({ left: { no: row.old as number, text: row.text, kind: 'context' }, right: { no: row.new as number, text: row.text, kind: 'context' } })
}
}
flush()
return rows
}
/**
* The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total.
* @param hunks - served hunks.
* @returns the hunks with the last one shortened as needed, and whether anything was cut.
*/
export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } {
let budget = MAX_RENDERED_LINES
const kept: WorkspaceDiffHunk[] = []
for (const hunk of hunks) {
if (budget === 0) return { hunks: kept, truncated: true }
kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) })
budget -= Math.min(budget, hunk.lines.length)
}
return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) }
}
/** The one-line fact about a text comparison worth stating above its hunks, if any. */
function noteOf(diff: Extract<ChangesDiff, { kind: 'text' }>): 'diff.created' | 'diff.deleted' | 'diff.unchanged' | undefined {
if (!diff.before) return 'diff.created'
if (!diff.after) return 'diff.deleted'
if (diff.hunks.length === 0) return 'diff.unchanged'
return undefined
}
/** The file index a navigation names, when it names one. */
function navigatedIndex(params: unknown): number | undefined {
const index = (params as ChangesReviewParams | undefined)?.index
@@ -231,8 +109,8 @@ export function ReviewTab({
const native = host !== null && host !== 'error' && host.available && phase !== 'nativeUnavailable'
const summaryState = summary === undefined || summary === 'loading' ? 'loading' : summary === 'missing' ? 'missing' : 'ready'
return (
<div className={css.root} data-changes-review data-review-state={summaryState}>
<div className={css.header}>
<div className={`${diffCss.root} ${css.root}`} data-changes-review data-review-state={summaryState}>
<div className={diffCss.header}>
{file === undefined
? <span className={css.selectorLabel}>{t('review.title', { turn: String(coordinates.turn) })}</span>
: <Menu className={css.selector} open={menuOpen} autoFocus portal align="start" dense onClose={() => { setMenuOpen(false) }}
@@ -273,126 +151,10 @@ export function ReviewTab({
})}
</span>
</div>
{summaryState === 'loading' && <p className={css.status} role="status">{t('diff.loading')}</p>}
{summaryState === 'missing' && <p className={css.status}>{t('diff.missing')}</p>}
{file !== undefined && <FileBody state={diffState} split={split} wrap={wrap} t={t}
{summaryState === 'loading' && <p className={diffCss.status} role="status">{t('diff.loading')}</p>}
{summaryState === 'missing' && <p className={diffCss.status}>{t('diff.missing')}</p>}
{file !== undefined && <FileDiff state={diffState} split={split} wrap={wrap} t={t}
retry={() => { void loadChangesDiff(sessionId, seq, index) }} />}
</div>
)
}
/** The selected file's comparison, or the state that stands in for it. */
function FileBody({ state, split, wrap, retry, t }: {
state: ChangesDiffStore['state'] extends { getSnapshot(): infer S } ? (S extends Record<string, infer V> ? V : never) : never
split: boolean
wrap: boolean
retry: () => void
} & PropsLocale<typeof NS>): ReactNode {
if (state === undefined || state === 'loading') return <p className={css.status} role="status">{t('diff.loading')}</p>
if (state === 'missing') return <p className={css.status}>{t('diff.missing')}</p>
if (state === 'error') {
return <div className={css.status}><span>{t('diff.error')}</span><Button size="sm" onClick={retry}>{t('presented.retry')}</Button></div>
}
if (state.kind === 'binary') return <p className={css.status}>{t('diff.binary')}</p>
if (state.kind === 'oversized') return <p className={css.status}>{t('diff.oversized')}</p>
return <TextDiff diff={state} split={split} wrap={wrap} t={t} />
}
/** The kind a paired row carries: a deletion or addition on either side, otherwise context. */
function splitRowKind(row: SplitRow): DiffRow['kind'] {
return row.left?.kind === 'del' ? 'del' : row.right?.kind === 'add' ? 'add' : 'context'
}
function hunkHeader(hunk: WorkspaceDiffHunk): string {
return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`
}
/**
* The side-by-side view without wrapping: two columns that clip their long
* lines and scroll together on both axes, so a long line on one side never
* runs under the other and both sides show the same rows and columns of text.
* Every line is one fixed-height row, which keeps the sides aligned.
*/
function SplitColumns({ hunks, highlights }: { hunks: readonly WorkspaceDiffHunk[]; highlights: readonly HunkHighlights[] }): ReactNode {
const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks])
const columns = useRef<Record<'left' | 'right', HTMLDivElement | null>>({ left: null, right: null })
const offsets = useRef({ left: { scrollLeft: 0, scrollTop: 0 }, right: { scrollLeft: 0, scrollTop: 0 } })
const follow = (side: 'left' | 'right') => (event: UIEvent<HTMLDivElement>): void => {
const peer = side === 'left' ? 'right' : 'left'
const other = columns.current[peer]
/* v8 ignore next -- Both column refs are attached before browser scroll events can run. */
if (other === null) return
for (const axis of ['scrollLeft', 'scrollTop'] as const) {
const value = event.currentTarget[axis]
if (offsets.current[side][axis] === value) continue
offsets.current[side][axis] = value
other[axis] = value
// Record the browser-clamped offset so its scroll event cannot pull the source back.
offsets.current[peer][axis] = other[axis]
}
}
return (
<div className={css.columns}>
{(['left', 'right'] as const).map(side => (
<div key={side} className={css.column} data-diff-side={side}
ref={(element) => { columns.current[side] = element }} onScroll={follow(side)}>
{paired.map((hunk, position) => (
<section key={position} className={css.hunk}>
<div className={css.hunkHeader}>{hunk.header}</div>
{hunk.rows.map((row, at) => {
const cell = row[side]
const spans = cell === undefined ? undefined : highlights[position]?.[side === 'left' ? 'old' : 'new']?.get(cell.no)
return (
<div key={at} className={`${css.sideLine} ${cell === undefined ? css.empty : css[cell.kind]}`} data-diff-line={splitRowKind(row)}>
<span className={css.number}>{cell?.no ?? ''}</span>
<DiffText text={cell?.text ?? ''} spans={spans} />
</div>
)
})}
</section>
))}
</div>
))}
</div>
)
}
/** The hunks of a text comparison with their line numbers, unified or side by side. */
function TextDiff({ diff, split, wrap, t }: { diff: Extract<ChangesDiff, { kind: 'text' }>; split: boolean; wrap: boolean } & PropsLocale<typeof NS>): ReactNode {
const note = noteOf(diff)
const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks])
const highlighter = useCodeHighlighter(languageForPath(diff.path))
const highlights = useMemo(() => hunks.map(hunk => hunkHighlights(hunk, highlighter)), [hunks, highlighter])
return (
<div className={css.body} data-review-view={split ? 'split' : 'unified'} data-review-wrap={wrap || undefined}>
{note !== undefined && <p className={css.note}>{t(note)}</p>}
{diff.coarse && <p className={css.note} data-diff-coarse>{t('diff.coarse')}</p>}
{truncated && <p className={css.note} data-diff-truncated>{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}</p>}
{split && !wrap ? <SplitColumns hunks={hunks} highlights={highlights} /> : hunks.map((hunk, position) => {
const highlighted = highlights[position]
return <section key={position} className={css.hunk}>
<div className={css.hunkHeader}>{hunkHeader(hunk)}</div>
{split ? splitRows(hunk).map((row, at) => (
<div key={at} className={css.splitLine} data-diff-line={splitRowKind(row)}>
<span className={`${css.cell} ${row.left === undefined ? css.empty : css[row.left.kind]}`}>
<span className={css.number}>{row.left?.no ?? ''}</span>
<DiffText text={row.left?.text ?? ''} spans={row.left === undefined ? undefined : highlighted?.old?.get(row.left.no)} />
</span>
<span className={`${css.cell} ${row.right === undefined ? css.empty : css[row.right.kind]}`}>
<span className={css.number}>{row.right?.no ?? ''}</span>
<DiffText text={row.right?.text ?? ''} spans={row.right === undefined ? undefined : highlighted?.new?.get(row.right.no)} />
</span>
</div>
)) : hunkRows(hunk).map((row, at) => (
<div key={at} className={`${css.line} ${css[row.kind]}`} data-diff-line={row.kind}>
<span className={css.number}>{row.old ?? ''}</span>
<span className={css.number}>{row.new ?? ''}</span>
<span className={css.sign}>{row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '}</span>
<DiffText text={row.text} spans={row.kind === 'add' ? highlighted?.new?.get(row.new as number) : highlighted?.old?.get(row.old as number)} />
</div>
))}
</section>
})}
</div>
)
}
@@ -66,8 +66,9 @@ export function apply(ctx: ClientContext): void {
locale: NS,
children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } },
inject: (): DeliverablesInjected => ({
hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state,
hooks: { changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state,
showCodeDiff: ctx.settingsScope.developerTools.enabled },
loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index),
reloadPresentedHost: () => opener.loadHost(),
loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq),
openPresented: (sessionId, seq, index, action, application) => opener.open(sessionId, seq, index, action, application),
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
/** Hover dwell, lazy comparison reads, and Sidebar navigation from turn-tail rows. */
import { useSyncExternalStore } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { ChangedFiles } from '../src/client/ChangedFiles.tsx'
import { ChangesDiffStore } from '../src/client/changes-diff.ts'
import { changesDiffUrl } from '../src/changes.ts'
import { en } from '../src/client/locales.ts'
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { cleanup(); vi.useRealTimers() })
const sessionId = SessionId('hover-session')
const files = ['main.ts', 'second.ts'].map(path => ({ path, display: path, added: 1, deleted: 1 }))
function mount() {
const diffs = new ChangesDiffStore()
const loadChangesDiff = vi.fn<ChangesDiffStore['load']>().mockResolvedValue(undefined)
const openReview = vi.fn()
const subscribe = diffs.state.subscribe.bind(diffs.state)
const getSnapshot = diffs.state.getSnapshot.bind(diffs.state)
const view = render(<ChangedFiles changes={{ seq: 5, files, total: 2, added: 2, deleted: 2 }}
cwd="/workspace" sessionId={sessionId} t={makeTranslate(en)} openReview={openReview}
loadChangesDiff={loadChangesDiff}
useChangesDiff={select => select(useSyncExternalStore(subscribe, getSnapshot))} />)
return { view, diffs, loadChangesDiff, openReview, row: screen.getByRole('button', { name: 'View changes to main.ts' }) }
}
it('reads only after 500ms and renders the selected comparison in a single column', () => {
const { row, diffs, loadChangesDiff, openReview } = mount()
fireEvent.pointerEnter(row)
act(() => { vi.advanceTimersByTime(499) })
expect(loadChangesDiff).not.toHaveBeenCalled()
expect(document.querySelector('[data-changes-hover-preview]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
expect(loadChangesDiff).toHaveBeenCalledExactlyOnceWith(sessionId, 5, 0)
expect(screen.getByText('Reading changes…')).toBeTruthy()
act(() => { diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: {
kind: 'text', path: 'main.ts', display: 'main.ts', before: true, after: true, coarse: false,
hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-const value = 1', '+const value = 2'] }],
} }) })
const preview = document.querySelector('[data-changes-hover-preview]')!
expect(preview.querySelector('[data-review-view="unified"]')).not.toBeNull()
expect(preview.querySelector('[data-diff-side]')).toBeNull()
expect(preview.querySelector('[data-diff-line="del"]')?.textContent).toContain('const value = 1')
expect(preview.querySelector('[data-diff-line="add"]')?.textContent).toContain('const value = 2')
expect(preview.textContent).toContain('/workspace/main.ts')
expect(screen.getByRole('button', { name: 'View changes to main.ts', description: '/workspace/main.ts' })).toBe(row)
expect(preview.querySelector('[data-changes-preview-path]')?.getAttribute('tabindex')).toBeNull()
fireEvent.pointerLeave(row)
fireEvent.pointerEnter(preview)
act(() => { vi.advanceTimersByTime(500) })
expect(document.querySelector('[data-changes-hover-preview]')).toBe(preview)
fireEvent.pointerDown(row)
fireEvent.click(row)
expect(preview.parentElement?.hasAttribute('data-closing')).toBe(true)
act(() => { vi.advanceTimersByTime(99) })
expect(document.querySelector('[data-changes-hover-preview]')).toBe(preview)
act(() => { vi.advanceTimersByTime(1) })
expect(document.querySelector('[data-changes-hover-preview]')).toBeNull()
expect(openReview).toHaveBeenCalledExactlyOnceWith(0)
})
it('keeps a status note for a comparison with no hunks', () => {
const { row, diffs } = mount()
diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: {
kind: 'text', path: 'main.ts', display: 'main.ts', before: true, after: true, coarse: false, hunks: [],
} })
fireEvent.pointerEnter(row)
act(() => { vi.advanceTimersByTime(500) })
expect(document.querySelector('[data-changes-hover-preview] [data-diff-note="empty"]')?.textContent).toBe('Both sides hold the same lines')
})
it('cancels a short hover and the pending dwell when unmounted', () => {
const { row, view, loadChangesDiff } = mount()
fireEvent.pointerEnter(row)
act(() => { vi.advanceTimersByTime(499) })
fireEvent.pointerLeave(row)
act(() => { vi.advanceTimersByTime(500) })
expect(loadChangesDiff).not.toHaveBeenCalled()
fireEvent.pointerEnter(row)
view.unmount()
act(() => { vi.advanceTimersByTime(500) })
expect(loadChangesDiff).not.toHaveBeenCalled()
})
it('previews each row independently, retries failed reads, and closes on Escape', () => {
const { row, diffs, loadChangesDiff } = mount()
diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: 'error', [changesDiffUrl(sessionId, 5, 1)]: 'missing' })
fireEvent.pointerEnter(row)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(loadChangesDiff).toHaveBeenCalledExactlyOnceWith(sessionId, 5, 0)
fireEvent.pointerLeave(row)
fireEvent.pointerEnter(screen.getByRole('button', { name: 'View changes to second.ts' }))
act(() => { vi.advanceTimersByTime(200) })
act(() => { vi.advanceTimersByTime(300) })
expect(document.querySelectorAll('[data-changes-hover-preview]')).toHaveLength(1)
expect(document.querySelector('[data-changes-preview-path]')?.textContent).toBe('/workspace/second.ts')
fireEvent.keyDown(screen.getByRole('button', { name: 'View changes to second.ts' }), { key: 'Escape' })
act(() => { vi.advanceTimersByTime(100) })
expect(document.querySelector('[data-changes-hover-preview]')).toBeNull()
})
@@ -26,6 +26,7 @@ import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-clie
import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { Deliverables, DeliverablesTail, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
import type { ReviewInjected } from '../src/client/ReviewTab.tsx'
import { ChangesDiffStore } from '../src/client/changes-diff.ts'
import { ChangesSummaryStore } from '../src/client/changes-summary.ts'
import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts'
import { PresentedOpenController } from '../src/client/present-open.ts'
@@ -40,10 +41,13 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
function openProps(controller = new PresentedOpenController(), summaries = new ChangesSummaryStore()) {
controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
const diffs = new ChangesDiffStore()
const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {} }
return {
SessionProvider: ({ children }: { children?: import('react').ReactNode }) => <>{children}</>,
renderSlot: renderFileActions,
useChangesDiff: <T,>(select: (state: ReturnType<typeof diffs.state.getSnapshot>) => T): T => select(diffs.state.getSnapshot()),
loadChangesDiff: vi.fn((...args: Parameters<ChangesDiffStore['load']>) => diffs.load(...args)),
useShowCodeDiff: <T,>(select: (value: boolean) => T): T => select(true),
useSessions: <T,>(select: (state: SessionListState) => T): T => select(sessions),
reloadPresentedHost: vi.fn(() => controller.loadHost()),
@@ -598,9 +602,9 @@ describe('ChangedFiles card', () => {
expect(within(card).getByText('-326')).toBeTruthy()
expect(within(card).getByText('Preview in sidebar')).toBeTruthy()
expect(within(card).getAllByRole('listitem')).toHaveLength(4)
expect(within(card).getByText('config/design-token')).toBeTruthy()
expect(within(within(card).getByRole('button', { name: 'View changes to config/design-token' })).getByText('config/design-token')).toBeTruthy()
expect(within(card).getByText('+42')).toBeTruthy()
expect(within(card).getByText('src/index.ts')).toBeTruthy()
expect(within(within(card).getByRole('button', { name: 'View changes to src/index.ts' })).getByText('src/index.ts')).toBeTruthy()
expect(within(card).queryByText('~/.zshrc')).toBeNull()
expect(within(card).getByRole('button', { name: 'Review this turn’s changes in the sidebar' })
.querySelector('svg')?.getAttribute('width')).toBe('10')
@@ -616,7 +620,8 @@ describe('ChangedFiles card', () => {
fireEvent.click(expand)
expect(within(card).getAllByRole('listitem')).toHaveLength(5)
expect(within(card).getByText('binary')).toBeTruthy()
expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBe('/home/u/.zshrc')
expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBeNull()
expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc', description: '/home/u/.zshrc' })).toBeTruthy()
fireEvent.click(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }))
expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 4)
const collapse = within(card).getByRole('button', { name: 'Collapse changed files' })
@@ -644,6 +649,19 @@ describe('ChangedFiles card', () => {
expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件')
})
it('preserves expanded rows when the served announcement changes', () => {
const summaries = servedStore()
summaries.state.set({ ...summaries.state.getSnapshot(), [changesSummaryUrl(SessionId('child-session'), 6)]: served })
const { props, openFile, view } = renderCard(new PresentedOpenController(), en, { changes, presented: [] }, summaries)
const card = view.container.querySelector('[data-changed-files]')
fireEvent.click(view.getByRole('button', { name: 'Show all 5 changed files' }))
view.rerender(<Deliverables {...props} matched={{ changes: { seq: 6 }, presented: [] }} openFile={openFile}
sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
expect(view.container.querySelector('[data-changed-files]')).toBe(card)
expect(view.getAllByRole('listitem')).toHaveLength(5)
expect(view.getByRole('button', { name: 'Collapse changed files' }).getAttribute('aria-expanded')).toBe('true')
})
it('keeps every count in place whatever the native-open gestures of the review tab are doing', () => {
const controller = new PresentedOpenController()
controller.state.set({
@@ -790,7 +808,12 @@ describe('plugin registration', () => {
await tabFace.loadChangesSummary(SessionId('child-session'), 6)
expect(tabFace.hooks.changesSummary.getSnapshot()['api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 })
fetcher.mockResolvedValueOnce(Response.json({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }))
await face.loadChangesDiff(SessionId('child-session'), 5, 1)
expect(face.hooks.changesDiff).toBe(tabFace.hooks.changesDiff)
expect(face.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })
const readsAfterHover = fetcher.mock.calls.length
await tabFace.loadChangesDiff(SessionId('child-session'), 5, 1)
expect(fetcher).toHaveBeenCalledTimes(readsAfterHover)
expect(tabFace.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })
expect(tabFace.hooks.presentedHost).toBe(face.hooks.presentedHost)
fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' }))
@@ -18,8 +18,9 @@ import { ChangesDiffStore } from '../src/client/changes-diff.ts'
import { ChangesSummaryStore } from '../src/client/changes-summary.ts'
import { PresentedOpenController } from '../src/client/present-open.ts'
import {
hunkRows, MAX_RENDERED_LINES, renderedHunks, ReviewTab, splitRows, type ReviewInjected, type ReviewTabProps,
ReviewTab, type ReviewInjected, type ReviewTabProps,
} from '../src/client/ReviewTab.tsx'
import { hunkRows, MAX_RENDERED_LINES, renderedHunks, splitRows } from '../src/client/FileDiff.tsx'
import { changesReviewDefinition } from '../src/client/review-definition.ts'
import { createReviewStore } from '../src/client/review-store.ts'
import { en, zh } from '../src/client/locales.ts'
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 292f1cc817d30837e36ced9adeee3d226852f4a5
README.zh.md: c61ecb0cba1237d8fbf0e4d22333f5e21724c9fa
README.md: 1c1b1312bb0b12d504f11ea2c82414a719b08e51
README.zh.md: 82581fb267da82945182a4c662d070b235ddc88a
+1 -1
View File
@@ -74,7 +74,7 @@ Writing your own component in your own package is fine when the need is genuinel
### Controls and icons
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a cLine truncated
### Rendering agent output
+1 -1
View File
@@ -74,7 +74,7 @@ kind: "package-library"
### 控件与图标
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
### 渲染 agent 输出
@@ -52,3 +52,30 @@
clip: rect(0 0 0 0);
white-space: nowrap;
}
.preview {
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
border: 0;
border-radius: 12px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
box-shadow: var(--dsw-elevation-panel);
opacity: 1;
transition: opacity var(--dsh-hover-preview-fade) linear;
}
@starting-style {
.preview { opacity: 0; }
}
.preview[data-closing] {
opacity: 0;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.preview { transition: none; }
}
+92 -34
View File
@@ -1,17 +1,29 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react'
import clsx from 'clsx'
import { createPortal } from 'react-dom'
import { writeClipboard } from './clipboard.ts'
import { usePointerGrace } from './pointer-grace.ts'
import { overlayTopMargin } from './overlay-top-margin.ts'
import css from './HoverCard.module.css'
/** Preview opacity transition and retained lifetime during dismissal. */
const PREVIEW_FADE_MS = 100
const PREVIEW_MAX_HEIGHT = 420
const PREVIEW_INSET = 24
const ANCHOR_GAP = 8
const VIEWPORT_MARGIN = 8
/**
* Render an anchor with a hover-triggered preview card.
* @param props.anchor - the hover target (rendered in place inside a wrapper span).
* @param props.content - card content; the pointer may rest on it, so it is
* readable and selectable, but it carries no dismissal affordance of its own.
* @param props.openDelayMs - hover dwell before the card shows (default 500).
* @param props.disabled - suppress opening; turning true closes an open card.
* @param props.variant - compact card beside the anchor, or a preview above/below it
* with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions.
* @param props.widthAnchorRef - optional element whose width and horizontal position size the preview.
* @param props.disabled - suppress opening; turning true dismisses an open card.
* @param props.copyText - optional primary value copied by activation and
* included in the card's accessible name.
* @param props.copyLabel - localized accessible activation-label prefix.
@@ -20,16 +32,19 @@ import css from './HoverCard.module.css'
*/
export function HoverCard({
anchor, content, openDelayMs = 500, disabled = false,
copyText, copyLabel, copiedLabel,
copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef,
}: {
anchor: ReactNode
content: ReactNode
openDelayMs?: number
disabled?: boolean
copyText?: string | undefined
copyLabel: string
copiedLabel: string
}) {
variant?: 'compact' | 'preview'
widthAnchorRef?: RefObject<HTMLElement | null>
} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | {
copyText?: undefined
copyLabel?: string
copiedLabel?: string
})) {
const rootRef = useRef<HTMLSpanElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -38,8 +53,11 @@ export function HoverCard({
const copyEpochRef = useRef(0)
const copyingRef = useRef(false)
const mountedRef = useRef(true)
const [open, setOpen] = useState(false)
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed')
const open = phase !== 'closed'
const closing = phase === 'closing'
const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null)
const positioned = pos !== null
const [copied, setCopied] = useState(false)
const clearCopied = useCallback(() => {
@@ -54,8 +72,8 @@ export function HoverCard({
const close = useCallback(() => {
copyEpochRef.current += 1
clearCopied()
setOpen(false)
}, [clearCopied])
setPhase(current => variant === 'preview' && current !== 'closed' ? 'closing' : 'closed')
}, [clearCopied, variant])
const { arm: armClose, cancel: cancelClose } = usePointerGrace(close)
@@ -66,7 +84,13 @@ export function HoverCard({
}
}
// Owner disabling mid-hover (menu opened, drag started) closes immediately.
useEffect(() => {
if (!closing) return
const timer = setTimeout(() => { setPhase('closed') }, PREVIEW_FADE_MS)
return () => { clearTimeout(timer) }
}, [closing])
// Owner disabling mid-hover (menu opened, drag started) starts dismissal.
useEffect(() => {
if (!disabled) return
clearTimer()
@@ -87,6 +111,17 @@ export function HoverCard({
}
}, [])
useEffect(() => {
if (!open || variant !== 'preview') return
const dismiss = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return
cancelClose()
close()
}
window.addEventListener('keydown', dismiss)
return () => { window.removeEventListener('keydown', dismiss) }
}, [open, variant, cancelClose, close])
// Fixed-position from the anchor rect before paint; track the anchor while
// open (capture-phase scroll catches nested panes), as in Menu portal mode.
useLayoutEffect(() => {
@@ -97,29 +132,50 @@ export function HoverCard({
if (wrapper === null) return
const r = wrapper.getBoundingClientRect()
const h = cardRef.current?.offsetHeight ?? 0
const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top
setPos({ left: r.right + 8, top })
if (variant === 'preview') {
const bounds = widthAnchorRef?.current?.getBoundingClientRect() ?? r
const width = Math.max(0, Math.min(bounds.width - PREVIEW_INSET * 2, window.innerWidth - VIEWPORT_MARGIN * 2))
const topMargin = overlayTopMargin(VIEWPORT_MARGIN)
const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP)
const above = Math.max(0, r.top - ANCHOR_GAP - topMargin)
const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN)
const onTop = above >= Math.min(PREVIEW_MAX_HEIGHT, below)
const maxHeight = Math.min(PREVIEW_MAX_HEIGHT, onTop ? above : below)
setPos({
left: Math.max(VIEWPORT_MARGIN, Math.min(bounds.left + PREVIEW_INSET, window.innerWidth - width - VIEWPORT_MARGIN)),
top: onTop ? Math.max(topMargin, r.top - Math.min(h, maxHeight) - ANCHOR_GAP) : belowTop,
width, maxHeight,
})
return
}
const top = r.top + h > window.innerHeight - VIEWPORT_MARGIN ? window.innerHeight - h - VIEWPORT_MARGIN : r.top
setPos({ left: r.right + ANCHOR_GAP, top })
}
place()
const observer = variant === 'preview' && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null
for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) {
if (element !== null && element !== undefined) observer?.observe(element)
}
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
return () => {
observer?.disconnect()
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open])
}, [open, variant, widthAnchorRef, positioned])
// The first placement ran before the card mounted (height read 0): once the
// card's real height is measurable, correct the bottom-edge clamp. The
// correction converges — a clamped top satisfies the guard, so it runs once.
useLayoutEffect(() => {
if (!open || pos === null) return
if (!open || pos === null || variant === 'preview') return
/* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */
const h = cardRef.current?.offsetHeight ?? 0
if (pos.top + h > window.innerHeight - 8) {
setPos({ left: pos.left, top: window.innerHeight - h - 8 })
if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) {
setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN })
}
}, [open, pos])
}, [open, pos, variant])
const copy = async (text: string): Promise<void> => {
if (copied || copyingRef.current) return
@@ -136,11 +192,22 @@ export function HoverCard({
}
const copyable = copyText !== undefined
const dismissFromAnchor = (event: SyntheticEvent<HTMLSpanElement>): void => {
// Portal content is a React child, but its clicks and text selection do not activate the anchor.
if (cardRef.current?.contains(event.target as Node)) return
clearTimer()
cancelClose()
close()
}
const card = open && pos !== null && (
<div
ref={cardRef}
className={`${css.card}${copyable ? ` ${css.copyable}` : ''}${copied ? ` ${css.feedback}` : ''}`}
style={{ ...pos, minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined }}
className={clsx(css.card, variant === 'preview' && css.preview, copyable && css.copyable, copied && css.feedback)}
data-closing={closing || undefined}
style={{
...pos, minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined,
'--dsh-hover-preview-fade': `${PREVIEW_FADE_MS}ms`,
} as CSSProperties}
role={copyable ? 'button' : undefined}
tabIndex={copyable ? 0 : undefined}
aria-label={copyable ? `${copyLabel}: ${copyText}` : undefined}
@@ -176,9 +243,9 @@ export function HoverCard({
// Coming back inside during the grace (the gap, or the card itself)
// keeps the current card rather than restarting the dwell.
cancelClose()
if (open) return
if (open) { setPhase('open'); return }
clearTimer()
timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs)
timerRef.current = setTimeout(() => { setPhase('open') }, openDelayMs)
}}
onPointerLeave={() => {
clearTimer()
@@ -186,17 +253,8 @@ export function HoverCard({
// open, matching Menu's shape.
if (open) armClose()
}}
// A press inside the anchor (row click, menu trigger) dismisses the
// card immediately, without waiting for the owner to flip `disabled`.
// Capture presses reach this handler from the card too — it is a React
// child of the wrapper — but a press there starts a selection, so the
// card must stay mounted under it (and the browser's click with it).
onPointerDownCapture={(e) => {
if (cardRef.current?.contains(e.target as Node)) return
clearTimer()
cancelClose()
close()
}}
onPointerDownCapture={dismissFromAnchor}
onClickCapture={dismissFromAnchor}
>
{anchor}
{open && copyable && <span className={css.status} role="status">{copied ? copiedLabel : ''}</span>}
@@ -6,7 +6,7 @@ import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
afterEach(cleanup)
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() })
/** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */
function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void {
@@ -18,6 +18,8 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number
}
function mount(props: {
widthAnchorRef?: { current: HTMLElement | null }
variant?: 'compact' | 'preview'
openDelayMs?: number
disabled?: boolean
copyText?: string
@@ -65,6 +67,90 @@ describe('HoverCard', () => {
expect(card.style.top).toBe('40px')
})
it('insets a preview by 24px on each side and follows anchor resizing', () => {
const { wrapper } = mount({ variant: 'preview' })
wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 600, height: 32 })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByText('card body').parentElement as HTMLElement
expect(card.style.width).toBe('552px')
expect(card.style.left).toBe('124px')
expect(card.style.maxHeight).toBe('420px')
wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 400, height: 32 })
fireEvent(window, new Event('resize'))
expect(card.style.width).toBe('352px')
fireEvent.keyDown(wrapper, { key: 'Escape' })
act(() => { vi.advanceTimersByTime(100) })
expect(screen.queryByText('card body')).toBeNull()
})
it('places previews below rows near the top and dismisses without anchor focus', () => {
const { wrapper } = mount({ variant: 'preview' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByText('card body').parentElement as HTMLElement
expect(card.style.top).toBe('82px')
expect(card.style.maxHeight).toBe('420px')
fireEvent.keyDown(document.body, { key: 'a' })
expect(screen.queryByText('card body')).not.toBeNull()
fireEvent.keyDown(document.body, { key: 'Escape' })
act(() => { vi.advanceTimersByTime(100) })
expect(screen.queryByText('card body')).toBeNull()
})
it('observes preview and container sizes until dismissal', () => {
let resize = () => {}
const observe = vi.fn()
const disconnect = vi.fn()
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) { resize = callback }
observe = observe
disconnect = disconnect
})
const container = document.createElement('div')
container.getBoundingClientRect = () => DOMRect.fromRect({ x: 10, width: 700 })
const { wrapper, view } = mount({ variant: 'preview', widthAnchorRef: { current: container } })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByText('card body').parentElement as HTMLElement
expect(observe).toHaveBeenCalledWith(card)
expect(observe).toHaveBeenCalledWith(container)
expect(card.style.width).toBe('652px')
expect(card.style.left).toBe('34px')
container.getBoundingClientRect = () => DOMRect.fromRect({ x: 10, width: 500 })
act(() => { resize() })
expect(card.style.width).toBe('452px')
const previousDisconnects = disconnect.mock.calls.length
view.unmount()
expect(disconnect.mock.calls.length).toBe(previousDisconnects + 1)
})
it('retains a dismissing preview for 100ms and cancels dismissal on re-entry', () => {
const { wrapper, view } = mount({ variant: 'preview' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByText('card body').parentElement as HTMLElement
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
expect(card.hasAttribute('data-closing')).toBe(true)
act(() => { vi.advanceTimersByTime(99) })
expect(screen.queryByText('card body')).not.toBeNull()
fireEvent.pointerEnter(wrapper)
expect(card.hasAttribute('data-closing')).toBe(false)
act(() => { vi.advanceTimersByTime(100) })
expect(screen.queryByText('card body')).not.toBeNull()
fireEvent.keyDown(document.body, { key: 'Escape' })
act(() => { vi.advanceTimersByTime(100) })
expect(screen.queryByText('card body')).toBeNull()
fireEvent.pointerDown(wrapper)
expect(vi.getTimerCount()).toBe(0)
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.keyDown(document.body, { key: 'Escape' })
view.unmount()
expect(vi.getTimerCount()).toBe(0)
})
it('honors a custom openDelayMs', () => {
const { wrapper } = mount({ openDelayMs: 50 })
fireEvent.pointerEnter(wrapper)
@@ -72,6 +158,45 @@ describe('HoverCard', () => {
expect(screen.getByText('card body')).toBeTruthy()
})
it('dismisses on keyboard clicks and cancels a pending hover', () => {
const { anchor, wrapper } = mount({ variant: 'preview' })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(499) })
fireEvent.click(anchor, { detail: 0 })
act(() => { vi.advanceTimersByTime(1) })
expect(screen.queryByText('card body')).toBeNull()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
fireEvent.click(screen.getByText('card body'))
expect(screen.getByText('card body').parentElement?.hasAttribute('data-closing')).toBe(false)
fireEvent.click(anchor, { detail: 0 })
expect(screen.getByText('card body').parentElement?.hasAttribute('data-closing')).toBe(true)
act(() => { vi.advanceTimersByTime(100) })
expect(screen.queryByText('card body')).toBeNull()
expect(vi.getTimerCount()).toBe(0)
})
it('keeps previews below the desktop frame clearance', () => {
const prior = document.documentElement.style.getPropertyValue('--dsh-frame-top-clearance')
const priority = document.documentElement.style.getPropertyPriority('--dsh-frame-top-clearance')
document.documentElement.style.setProperty('--dsh-frame-top-clearance', '120px')
try {
const { wrapper } = mount({ variant: 'preview' })
wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 600, height: 32 })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByText('card body').parentElement as HTMLElement
Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 420 })
wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 450, width: 600, height: 32 })
fireEvent(window, new Event('resize'))
expect(card.style.top).toBe('120px')
expect(card.style.maxHeight).toBe('322px')
} finally {
if (prior === '') document.documentElement.style.removeProperty('--dsh-frame-top-clearance')
else document.documentElement.style.setProperty('--dsh-frame-top-clearance', prior, priority)
}
})
it('pointerleave before the delay cancels the pending open', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
@@ -0,0 +1 @@
- text: 1 1 start 2 + done