跳转到内容

DiffView 差异视图

一份改动的逐行呈现:并排或单栏、双侧行号、变更类型的读屏文字,以及远离变更处的折叠。

用法

两个入口归一到同一个模型:这里用新旧两版全文计算,着色在建模时一次算好

src/clamp.ts
Removedexport function clamp(n: number, min: number) {
Removed return Math.max(n, min)
Addedexport function clamp(n: number, min: number, max: number) {
Added return Math.min(Math.max(n, min), max)
Unchanged}

组件结构

加粗的是必需部件。

data-scope="diff-view"root · header · summary · viewport · body · row · line-number · line-content · change-label · inline-change · token · gap · gap-cell · gap-trigger · empty · truncation

示例

并排与折叠

并排两列都发出格子,空的一侧照发;远离变更的连续上下文折为一格,点击即展开

src/pipeline.ts+2−2
Unchangedconst step0 = pipeline.at(0)Unchangedconst step0 = pipeline.at(0)
Removedconst step1 = pipeline.at(1)Removed
AddedAddedconst step1 = pipeline.head()
Unchangedconst step2 = pipeline.at(2)Unchangedconst step2 = pipeline.at(2)
Unchangedconst step3 = pipeline.at(3)Unchangedconst step3 = pipeline.at(3)
Unchangedconst step4 = pipeline.at(4)Unchangedconst step4 = pipeline.at(4)
Unchangedconst step19 = pipeline.at(19)Unchangedconst step19 = pipeline.at(19)
Unchangedconst step20 = pipeline.at(20)Unchangedconst step20 = pipeline.at(20)
Unchangedconst step21 = pipeline.at(21)Unchangedconst step21 = pipeline.at(21)
Removedconst step22 = pipeline.at(22)Removed
AddedAddedconst step22 = pipeline.tail()
Unchangedconst step23 = pipeline.at(23)Unchangedconst step23 = pipeline.at(23)

长行换行与词级差异

开启 wrap 使长行原地折行;配对的删改行之间再比较一次词,只有真正改动的片段上底色

src/client.ts+2−2
Removedconst endpoint = "https://api.example.com/v1/workspaces/{id}/documents?include=revisions&limit=50"
Removedconst timeout = 3000
Addedconst endpoint = "https://api.example.com/v2/workspaces/{id}/documents?include=revisions,authors&limit=100"
Addedconst timeout = 8000
Unchangedexport const client = createClient({ endpoint, timeout })

超长差异的截断提示

超过 maxLines 的部分被截去,提示条向读者说明截去了多少行

src/items.ts
Unchangedconst item0 = 0
Unchangedconst item1 = 1
Removedconst item2 = 2
Addedconst item2 = 200
Unchangedconst item3 = 3
Unchangedconst item4 = 4
Unchangedconst item5 = 5
48 more lines were cut off and are not shown

尺寸

size 改变字号、行高与行号槽的宽度,三档并列对照

src/clamp.ts · sm
Removedexport function clamp(n: number, min: number) {
Removed return Math.max(n, min)
Addedexport function clamp(n: number, min: number, max: number) {
Added return Math.min(Math.max(n, min), max)
Unchanged}
src/clamp.ts · md
Removedexport function clamp(n: number, min: number) {
Removed return Math.max(n, min)
Addedexport function clamp(n: number, min: number, max: number) {
Added return Math.min(Math.max(n, min), max)
Unchanged}
src/clamp.ts · lg
Removedexport function clamp(n: number, min: number) {
Removed return Math.max(n, min)
Addedexport function clamp(n: number, min: number, max: number) {
Added return Math.min(Math.max(n, min), max)
Unchanged}

设计指引

何时使用

  • 展示 AI 提议的代码改动,或两版文本的对比。
  • 已有统一格式的补丁,或有新旧两版全文。

何时不用

  • 只展示一段代码时,使用代码视图
  • 展示 AI 提议的数据编辑并逐条取舍时,使用带多选的表格

特性

  • 两个入口归一到同一个模型:computeTextDiff(before, after) 用两版全文计算,parseUnifiedPatch(patch) 解析补丁;组件只接受模型。
  • 自定义渲染器可调用 diffViewSides(view) 取得列序:单栏为旧侧,分栏按旧侧、新侧排列。
  • 着色在建模时一次计算,不在连接层执行:computeTextDiff 持有完整文本,整体切分一次再按行取用,跨行的块注释与多行字符串才不会着错色。parseUnifiedPatch 拿不到完整文件,因此一律不着色。
  • 词级差异:配对的一条删除行与一条新增行之间再比较一次词,只有实际变动的片段加底色;整行改写与超长行不比较。两个入口都产出,wordDiff: false 关闭。
  • contextLines 把 hunk 内远离变更的连续上下文折成一格,点击展开。展开集合可受控,便于“全部展开”等操作统一持有。
  • wrap 让长行原地折行,容器不再横向滚动;窄栏与并排视图下尤其有用。
  • 头部自带增删统计位 summary,增删各一个,数字取自模型,着色跟随变更类型。
  • maxLines 是必需的上限:AI 可能输出超大文件,新旧两侧各自超出时从尾部截断。截断行数由模型带出,truncation 提示条向读者说明。
  • 行号与列号一律从模型计算,不从 DOM 反推。

组合

  • 单栏与并排的切换使用切换按钮组;增删统计已有成品位(只需要数字时可用 diffStats(model))。
  • 放入工具调用的详情区,展示本次调用的改动。
  • 需要对 AI 提议的编辑逐条取舍并应用时,使用表格的选择机制承载行级取舍,单元格内放复选框,页脚的计数与“应用”使用按钮。差异视图本身只读,不接这套交互。

最佳实践

  • 并排视图给足宽度:两列各自还要横向滚动;窄栏下单栏更易读,或开启 wrap 让长行折行。
  • 折叠阈值取三到五行:过少时需要频繁展开,过多时折叠失去意义。

反模式

  • 截断后不渲染 truncation:截断的差异看起来仍像完整差异,评审者会误以为已经看完。
  • 对补丁计算出的差异着色:文本不完整,跨行的记号必然切错。
  • 用颜色作为变更类型的唯一线索:色觉障碍与高对比度模式下会失效。

API 参考

产物

自定义元素<xh-diff-view>
Vue 组件XhDiffViewBody XhDiffViewEmpty XhDiffViewHeader XhDiffViewRoot XhDiffViewSummary XhDiffViewTruncation XhDiffViewViewport
组合式函数useDiffView
状态机diffViewMachine
皮肤@xihan-ui/styles/diff-view.css

Props

属性类型必填说明
modelDiffModel差异模型,唯一入口。补丁与新旧两版文本都先归一到它。
viewDiffViewMode
contextLinesnumber变更两侧各显示的上下文行数,其余折叠;未提供或非有限值时不折叠。
expandedValuereadonly string[]展开的折叠格 id 集合,提供即受控。
defaultExpandedValuereadonly string[]
wrapboolean长行原地折行,不再横向滚动;默认关闭。
sizeSize
translationsPartial<DiffViewTranslations>
onExpandedValueChange(details: DiffViewExpandedValueChangeDetails) => void

事件

自定义元素将载荷放在 detail;Vue 使用同名 emit。

事件载荷说明
expanded-value-changeDiffViewExpandedValueChangeDetails展开集合变化;detail 为 { value: string[] }

插槽

仅列出带载荷的插槽。

Vue 组件插槽载荷说明
XhDiffViewRootdefaultDiffViewRootSlotProps
XhDiffViewSummarydefault{ count: number }
XhDiffViewTruncationdefault{ count: number }

状态

以下名称仅用于内部状态机。

状态idle

事件GAP.EXPAND · GAP.COLLAPSE · CONTROLLED.EXPANDED.SET · PRESS.START · PRESS.END

判据isExpandedControlled

connect API

getXxxProps() 返回对应部件的宿主属性。

成员类型说明
viewDiffViewMode
rowsreadonly DiffViewRow[]折叠后的可见行序,含折叠的格。
expandedValuestring[]
stats{ added: number, removed: number }增删的行数。
truncatedboolean模型被上限截断过。
truncatedLinesnumber被上限截断、未进入模型的源文本行数;未截断时为 0。
truncationTextstring截断提示条的文字,已代入行数;未截断时为空串。
isEmptyboolean没有任何变更。
setExpandedValue(next: string[]) => void
toggleGap(id: string) => void
getRootProps() => T['element']
getHeaderProps() => T['element']
getSummaryProps(props: { change: DiffChange }) => T['element']头部右侧的增删统计位,增删各一个。
getViewportProps() => T['element']
getBodyProps() => T['element']
getRowProps(props: DiffViewRowProps) => T['element']
getLineNumberProps(props: DiffViewCellProps) => T['element']
getLineContentProps(props: DiffViewCellProps) => T['element']
getChangeLabelProps(props: { change: DiffChange }) => T['element']
getInlineChangeProps(props: DiffViewInlineChangeProps) => T['element']
getTokenProps(token: CodeToken) => T['element']
getGapProps(props: DiffViewGapProps) => T['element']
getGapCellProps() => T['element']
getGapTriggerProps(props: DiffViewGapProps) => T['button']
getEmptyProps() => T['element']
getTruncationProps() => T['element']截断提示条;未截断时带 hidden。
changeLabel(change: DiffChange) => string变更类型对应的读屏文字,写入视觉隐藏的格。
cellText(props: DiffViewCellProps) => string | undefined该行在该侧的文本;split 下空侧为 undefined。
cellNumber(props: DiffViewCellProps) => number | undefined该行在该侧的行号;不存在时为 undefined。
cellTokens(props: DiffViewCellProps) => readonly CodeToken[]该行在该侧的着色片段;不着色或空侧时为空数组。
cellSegments(props: DiffViewCellProps) => readonly DiffViewSegment[]该行在该侧的词级片段,着色记号已按片段边界切分。 未计算词级差异时为空数组,此时按 cellTokens / cellText 铺设。

无障碍

键盘

规格出处:W3C APG

按键生效条件行为
Tab差异视图在 Tab 序列中滚动容器自身可聚焦,随后方向键的横纵滚动交给浏览器,组件不接管
Enter / Space焦点在展开按钮上展开该处折起来的上下文行;组件只接 click,按键走原生 button 的默认行为
Enter / Space按住展开按钮按住期间该格的 gap-trigger 投影 data-pressed,与指针 :active 同一副按压面(disclosure trigger 只换面不缩放);抬起、失焦或该格展开撤下

ARIA

以下属性由 connect 生成。

部件属性
bodyaria-colcount2 | 1
bodyaria-labelundefined | translations?.diff
bodyaria-labelledbyheader 部件的 id | undefined
bodyaria-rowcountrows.length
bodyrole'table'
rowaria-rowindexrowIndex
rowrole'row'
line-numberaria-hidden'true'
line-contentaria-colindex2 | 1
line-contentrole'cell'
gaprole'row'
gap-cellaria-colindex1
gap-cellrole'cell'
gap-triggeraria-expanded'true' | 'false'
gap-triggeraria-labelexpandGapLabel(hiddenCountOf(gapId))
  • 表格语义:role=tablerole=rowrole=cell,带 aria-rowcount / aria-rowindex / aria-colcount / aria-colindex。列数只计算实际暴露的内容列,行号不算列。
  • 每一行都带一段视觉隐藏的变更类型文字,变更不只靠颜色传达。
  • 变更行还有一条非颜色线索:新增绘制实心色条,删除绘制同宽的斜纹条,灰度与高对比度下也可区分。
  • 行号对读屏隐藏,由皮肤用 attr() 绘制,复制差异不会带上行号。
  • 不采用表格的行级 roving:只读差异不是网格,吞掉方向键的焦点组会抢走页面滚动,读屏本身也有表格浏览模式。这是显式决定。

样式参考

皮肤

@xihan-ui/styles/diff-view.css 使用 [data-scope="diff-view"][data-part="root"] 部件选择器,位于 xihan.componentsxihan.motion 层。覆盖样式使用 xihan.overrides

forced-colors: active 下另有一套规则:颜色交给系统,边框与状态标记改用系统色关键字。

数据属性

connect 生成;条件不成立时不输出无值属性。

部件属性
rootdata-sizeprops.size
rootdata-truncated''(条件成立时才出现)
rootdata-viewprops.view
rootdata-wrap''(条件成立时才出现)
summarydata-changechange
rowdata-changelineAt(rowIndex)?.change
rowdata-revealed''(条件成立时才出现)
line-numberdata-changelineAt(rowIndex)?.change
line-numberdata-line-numbercellNumber({ rowIndex, side })?.toString()
line-numberdata-sideside
line-contentdata-changelineAt(rowIndex)?.change
line-contentdata-empty''(条件成立时才出现)
line-contentdata-sideside
change-labeldata-changechange
inline-changedata-changelineAt(rowIndex)?.change | undefined
tokendata-kindtoken.kind
gapdata-expanded''(条件成立时才出现)
gapdata-valuehunkIndex:0
gap-triggerdata-pressed''(条件成立时才出现)
gap-triggerdata-valuehunkIndex:0
gap-triggerdata-xh-action-control''
gap-triggerdata-xh-action-display'always'
gap-triggerdata-xh-action-profile'disclosure-trigger'
gap-triggerdata-xh-action-sizeprops.size
gap-triggerdata-xh-action-variant'ghost'

CSS 变量

本组件公开覆盖槽由独立皮肤的实际消费位生成;默认来源、作用部件和状态均与 CSS 同源。

变量部件CSS 属性状态默认来源说明
--xh-diff-view-added-bgrowbackgroundchange=added--xh-diff-added-bgdiff-view 的 row 部件 background 覆盖槽。
--xh-diff-view-added-fginline-change
line-content
line-number
row
summary
background
box-shadow
color
change=added--xh-diff-added-fgdiff-view 的 inline-change、line-content、line-number、row、summary 部件 background、box-shadow、color 覆盖槽。
--xh-diff-view-bgrootbackgrounddefault--xh-bg-surfacediff-view 的 root 部件 background 覆盖槽。
--xh-diff-view-borderrootborderdefault--xh-border-defaultdiff-view 的 root 部件 border 覆盖槽。
--xh-diff-view-change-barrowbackground
box-shadow
change=added
change=removed
--xh-stroke-thickdiff-view 的 row 部件 background、box-shadow 覆盖槽。
--xh-diff-view-comment-fgtokencolorkind=comment--xh-fg-muteddiff-view 的 token 部件 color 覆盖槽。
--xh-diff-view-dividerheader
line-number
root
border-block-end
border-inline-end
border-inline-start
@media (min-width: 1024px)
default
side=new
view=split
--xh-border-subtlediff-view 的 header、line-number、root 部件 border-block-end、border-inline-end、border-inline-start 覆盖槽。
--xh-diff-view-empty-bgline-contentbackgroundempty--xh-bg-subtlediff-view 的 line-content 部件 background 覆盖槽。
--xh-diff-view-empty-fgemptycolordefault--xh-fg-muteddiff-view 的 empty 部件 color 覆盖槽。
--xh-diff-view-fontbody
header
font-familydefault--xh-font-family-monodiff-view 的 body、header 部件 font-family 覆盖槽。
--xh-diff-view-font-sizebody
gap-trigger
font-sizedefault--xh-_diff-view-font-sizediff-view 的 body、gap-trigger 部件 font-size 覆盖槽。
--xh-diff-view-gap-bggapbackgrounddefault--xh-bg-subtlediff-view 的 gap 部件 background 覆盖槽。
--xh-diff-view-gap-bg-hovergap-triggerbackground-colordisabled
hover
loading
not([data-disabled])
not([data-loading])
--xh-_action-variant-bg-hoverdiff-view 的 gap-trigger 部件 background-color 覆盖槽。
--xh-diff-view-gap-fggap-triggercolordefault--xh-fg-muteddiff-view 的 gap-trigger 部件 color 覆盖槽。
--xh-diff-view-gutterline-numberinline-sizedefault4chdiff-view 的 line-number 部件 inline-size 覆盖槽。
--xh-diff-view-header-fgheadercolordefault--xh-fg-muteddiff-view 的 header 部件 color 覆盖槽。
--xh-diff-view-header-font-sizeempty
header
truncation
font-sizedefault--xh-text-secondary-sizediff-view 的 empty、header、truncation 部件 font-size 覆盖槽。
--xh-diff-view-header-gapheadergapdefault--xh-space-2diff-view 的 header 部件 gap 覆盖槽。
--xh-diff-view-icon-sizeroot--xh-icon-sizedefault
size=lg
size=sm
--xh-glyph-size-lg
--xh-glyph-size-md
--xh-glyph-size-sm
diff-view 的 root 部件 --xh-icon-size 覆盖槽。
--xh-diff-view-inline-change-radiusinline-changeborder-radiuschange--xh-shape-insetdiff-view 的 inline-change 部件 border-radius 覆盖槽。
--xh-diff-view-keyword-fgtokencolorkind=keyword--xh-syntax-keyworddiff-view 的 token 部件 color 覆盖槽。
--xh-diff-view-keyword-weighttokenfont-weightkind=keyword--xh-font-weight-semibolddiff-view 的 token 部件 font-weight 覆盖槽。
--xh-diff-view-line-heightbody
gap
gap-trigger
row
block-size
line-height
min-block-size
default
xh-action-profile=disclosure-trigger
--xh-text-code-leadingdiff-view 的 body、gap、gap-trigger、row 部件 block-size、line-height、min-block-size 覆盖槽。
--xh-diff-view-max-hviewportmax-block-sizedefault--xh-viewport-max-hdiff-view 的 viewport 部件 max-block-size 覆盖槽。
--xh-diff-view-number-fgline-numbercolordefault--xh-fg-subtlediff-view 的 line-number 部件 color 覆盖槽。
--xh-diff-view-number-token-fgtokencolorkind=number--xh-syntax-numberdiff-view 的 token 部件 color 覆盖槽。
--xh-diff-view-punctuation-fgtokencolorkind=punctuation--xh-fg-subtlediff-view 的 token 部件 color 覆盖槽。
--xh-diff-view-pxempty
gap-trigger
header
line-content
line-number
truncation
padding-inline
padding-inline-end
default--xh-_diff-view-pxdiff-view 的 empty、gap-trigger、header、line-content、line-number、truncation 部件 padding-inline、padding-inline-end 覆盖槽。
--xh-diff-view-pyempty
header
truncation
padding-blockdefault--xh-_diff-view-pydiff-view 的 empty、header、truncation 部件 padding-block 覆盖槽。
--xh-diff-view-radiusrootborder-radiusdefault--xh-shape-surfacediff-view 的 root 部件 border-radius 覆盖槽。
--xh-diff-view-removed-bgrowbackgroundchange=removed--xh-diff-removed-bgdiff-view 的 row 部件 background 覆盖槽。
--xh-diff-view-removed-fginline-change
line-content
line-number
row
summary
background
color
change=removed--xh-diff-removed-fgdiff-view 的 inline-change、line-content、line-number、row、summary 部件 background、color 覆盖槽。
--xh-diff-view-shadowrootbox-shadowdefaultnonediff-view 的 root 部件 box-shadow 覆盖槽。
--xh-diff-view-string-fgtokencolorkind=string--xh-syntax-stringdiff-view 的 token 部件 color 覆盖槽。
--xh-diff-view-truncation-bgtruncationbackgrounddefault--xh-fg-warningdiff-view 的 truncation 部件 background 覆盖槽。
--xh-diff-view-truncation-bordertruncationborder-block-startdefault--xh-border-subtlediff-view 的 truncation 部件 border-block-start 覆盖槽。
--xh-diff-view-truncation-fgtruncationcolordefault--xh-fg-warningdiff-view 的 truncation 部件 color 覆盖槽。
--xh-diff-view-truncation-gaptruncationgapdefault--xh-space-2diff-view 的 truncation 部件 gap 覆盖槽。

动效

关键帧 xh-diff-view-reveal 随皮肤自带,不引用别处文件里的名字。时长与缓动读动效令牌,改令牌即改全局节奏。

系统开启减弱动效时由令牌层统一收敛,皮肤不另作判断。

响应式

皮肤按视口分档:min-width: 1024px

RTL

皮肤用逻辑属性排布(inline-start 一族),dir="rtl" 下自动镜像。

Released under The MIT License