✨ feat(vscode): 代码阅读预览标出新增 / 修改 / 删除的卡片(左侧改动色条、新增淡绿底挂小标、删掉的块原位收成一行可展开),筛选栏第二行写对比基准和改动统计,新文件顶部横幅

This commit is contained in:
cruldra committed 2026-09-30 08:41:55 +08:00
1 parent 2a5806c6b5
commit 3b7ce69842
10 files changed
+491 -20

No files matched your search

+18 -4
View File
@@ -1,15 +1,17 @@
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import type { BlockTreeState } from './BlockTree' import type { BlockTreeState } from './BlockTree'
import type { NameMap, VersionNames } from './displayNames' import type { NameMap, VersionNames } from './displayNames'
import type { DocumentMessage, Excluded, Reveal } from './types' import type { DocumentDiff, DocumentMessage, Excluded, Reveal } from './types'
import type { OccurrencePage } from './useOccurrences' import type { OccurrencePage } from './useOccurrences'
import type { ViewState } from './viewState' import type { ViewState } from './viewState'
import { TriangleAlert } from 'lucide-react' import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BlockLevel } from './BlockTree' import { BlockLevel } from './BlockTree'
import { DiffBar, NewFileBanner } from './DiffBar'
import { diffTree, openedByDiff } from './diffView'
import { acceptNames, DisplayNameContext, documentNames } from './displayNames' import { acceptNames, DisplayNameContext, documentNames } from './displayNames'
import { coveredByEditing, startEditing } from './editing' import { coveredByEditing, startEditing } from './editing'
import { applyFilter, keptWhileEditing } from './filter' import { applyFilter, excludedBy, keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar' import { FilterBar } from './FilterBar'
import { useDefinitionLinks } from './useDefinitionLinks' import { useDefinitionLinks } from './useDefinitionLinks'
import { HighlightContext, useOccurrences } from './useOccurrences' import { HighlightContext, useOccurrences } from './useOccurrences'
@@ -43,9 +45,15 @@ export function App(): ReactElement | null {
const [keep, setKeep] = useState<ReadonlySet<string>>(NONE) const [keep, setKeep] = useState<ReadonlySet<string>>(NONE)
/** 页面上这个文档版本的中文名:文档带来的,扩展端查到了再换 */ /** 页面上这个文档版本的中文名:文档带来的,扩展端查到了再换 */
const [names, setNames] = useState<VersionNames | undefined>() const [names, setNames] = useState<VersionNames | undefined>()
/** 这份文件相比对比基准改了什么:侧栏开着「未提交的更改」或审查工单时扩展端算好了补来,不比时是 null */
const [diff, setDiff] = useState<DocumentDiff | null>(null)
/** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */ /** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */
const editing = useRef(view.editing) const editing = useRef(view.editing)
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds]) // 解析失败的文档没有块,不标差异;删掉的块按块筛选同样藏
const changes = useMemo(() => doc && doc.error === undefined
? diffTree(doc.blocks, diff, doc.version, removed => !excludedBy(removed.block, excluded))
: undefined, [doc, diff, excluded])
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds, openedByDiff(changes)), [doc, view.folds, changes])
const filter = useMemo(() => applyFilter(doc?.blocks ?? [], excluded, keep), [doc, excluded, keep]) const filter = useMemo(() => applyFilter(doc?.blocks ?? [], excluded, keep), [doc, excluded, keep])
const covered = useMemo(() => coveredByEditing(doc?.blocks ?? [], new Set(view.editing)), [doc, view.editing]) const covered = useMemo(() => coveredByEditing(doc?.blocks ?? [], new Set(view.editing)), [doc, view.editing])
/** 记下这几块、属性行、子行折叠还是展开,从此不再按默认 */ /** 记下这几块、属性行、子行折叠还是展开,从此不再按默认 */
@@ -88,6 +96,9 @@ export function App(): ReactElement | null {
case 'filter': case 'filter':
setExcluded(msg.excluded) setExcluded(msg.excluded)
setKeep(NONE) setKeep(NONE)
return
case 'diff':
setDiff(msg.diff)
} }
}) })
postMessage({ type: 'ready' }) postMessage({ type: 'ready' })
@@ -121,7 +132,8 @@ export function App(): ReactElement | null {
}, },
onToggleFold: id => setFolded([id], !collapsed.has(id)), onToggleFold: id => setFolded([id], !collapsed.has(id)),
...(qa.state.available && { qa }), ...(qa.state.available && { qa }),
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, qa]) ...(changes && { diff: changes }),
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, qa, changes])
if (!doc || !tree) if (!doc || !tree)
return null return null
@@ -138,8 +150,10 @@ export function App(): ReactElement | null {
translators={doc.translators} translators={doc.translators}
translatorId={doc.translatorId} translatorId={doc.translatorId}
onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })} onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })}
below={diff && doc.error === undefined && <DiffBar diff={diff} />}
/> />
<div className="mx-auto box-content max-w-[880px] px-6 py-5"> <div className="mx-auto box-content max-w-[880px] px-6 py-5">
{diff?.newFile !== undefined && doc.error === undefined && <NewFileBanner detail={diff.newFile} />}
{doc.error !== undefined && ( {doc.error !== undefined && (
<div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2"> <div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2">
<TriangleAlert size={16} strokeWidth={1.8} className="mt-px shrink-0 text-[var(--vscode-errorForeground)]" /> <TriangleAlert size={16} strokeWidth={1.8} className="mt-px shrink-0 text-[var(--vscode-errorForeground)]" />
+86 -10
View File
@@ -1,12 +1,14 @@
import type { CSSProperties, ReactElement } from 'react' import type { CSSProperties, ReactElement, ReactNode } from 'react'
import type { DiffTree, PlacedRemoved } from './diffView'
import type { QaState } from './qa' import type { QaState } from './qa'
import type { PendingImage } from './qaImages' import type { PendingImage } from './qaImages'
import type { BlockView } from './types' import type { BlockView } from './types'
import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react' import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react'
import { Fragment } from 'react' import { Fragment, useState } from 'react'
import { AskThread } from './AskThread' import { AskThread } from './AskThread'
import { BlockTools } from './BlockTools' import { BlockTools } from './BlockTools'
import { CodeEditor } from './CodeEditor' import { CodeEditor } from './CodeEditor'
import { removedTitle } from './diffView'
import { KindIcon } from './kindIcons' import { KindIcon } from './kindIcons'
import { PropertyGrid, PropertyTable } from './PropertyGrid' import { PropertyGrid, PropertyTable } from './PropertyGrid'
import { asksOf, historyOf } from './qa' import { asksOf, historyOf } from './qa'
@@ -33,6 +35,8 @@ export interface BlockTreeState {
onToggleFold: (id: string) => void onToggleFold: (id: string) => void
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */ /** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
qa?: QaTree qa?: QaTree
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
diff?: DiffTree
} }
/** 问 AI 的状态和动作,块按自己的记号(anchor)取 */ /** 问 AI 的状态和动作,块按自己的记号(anchor)取 */
@@ -48,9 +52,23 @@ export interface QaTree {
onSelectProfile: (id: string) => void onSelectProfile: (id: string) => void
} }
/** 块下面有没有画出来的子块:子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */ /** 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */
function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean { function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean {
return block.children.some(child => shown(child, tree)) return block.children.some(child => shown(child, tree)) || (!tree.covered.has(block.id) && tree.diff?.removed.has(block.id) === true)
}
const CHANGE_NAMES = { add: '新增', mod: '修改', del: '删除' } as const
/** 标差异时卡片左边外侧的改动色条(没变的留一条透明的,同一层照样对齐);不标差异时原样。 */
function ChangeRow({ tree, change, children }: { tree: BlockTreeState, change: keyof typeof CHANGE_NAMES | undefined, children: ReactNode }): ReactElement {
if (!tree.diff)
return <>{children}</>
return (
<div className="cr-change-row flex gap-[5px]">
<span className="cr-change-bar" data-change={change} data-tooltip={change && CHANGE_NAMES[change]} />
<div className="min-w-0 flex-1">{children}</div>
</div>
)
} }
/** 块画不画:被筛掉的、被正在编辑的祖先盖住的都不画。 */ /** 块画不画:被筛掉的、被正在编辑的祖先盖住的都不画。 */
@@ -74,21 +92,35 @@ function kindProps(block: BlockView | undefined): { 'data-kind'?: string, 'data-
/** /**
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。 * 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。
* owner 是这一层所属的块(顶层没有),悬停它时竖线换成它的种类色。 * owner 是这一层所属的块(顶层没有),悬停它时竖线换成它的种类色。
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard)。
*/ */
export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState }): ReactElement { export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState }): ReactElement {
const removed = tree.diff?.removed.get(owner?.id ?? '') ?? []
// 删掉的块排在它原来前面那块后面;那块被筛掉了也照排,不跟着消失
const removedAfter = (id: string | undefined): ReactElement[] => removed
.filter(item => item.after === id)
.map(item => <RemovedCard key={item.id} placed={item} tree={tree} />)
return ( return (
<div className="cr-level flex gap-2" {...kindProps(owner)}> <div className="cr-level flex gap-2" {...kindProps(owner)}>
<div className="flex w-3 shrink-0 justify-center"> <div className="flex w-3 shrink-0 justify-center">
<div className="cr-guide w-px" /> <div className="cr-guide w-px" />
</div> </div>
<div className="flex min-w-0 flex-1 flex-col gap-2"> <div className="flex min-w-0 flex-1 flex-col gap-2">
{blocks.filter(block => shown(block, tree)).map(block => ( {removedAfter(undefined)}
{blocks.map(block => (
<Fragment key={block.id}> <Fragment key={block.id}>
<BlockCard block={block} tree={tree} /> {shown(block, tree) && (
{tree.qa && <BlockQa key={block.anchor} anchor={block.anchor} qa={tree.qa} />} <>
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && ( <ChangeRow tree={tree} change={tree.diff?.changes.get(block.id)?.change}>
<BlockLevel blocks={block.children} owner={block} tree={tree} /> <BlockCard block={block} tree={tree} />
</ChangeRow>
{tree.qa && <BlockQa key={block.anchor} anchor={block.anchor} qa={tree.qa} />}
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
<BlockLevel blocks={block.children} owner={block} tree={tree} />
)}
</>
)} )}
{removedAfter(block.id)}
</Fragment> </Fragment>
))} ))}
</div> </div>
@@ -139,14 +171,17 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
const collapsed = tree.collapsed.has(block.id) const collapsed = tree.collapsed.has(block.id)
const hasChildren = hasShownChildren(block, tree) const hasChildren = hasShownChildren(block, tree)
const Chevron = collapsed ? ChevronRight : ChevronDown const Chevron = collapsed ? ChevronRight : ChevronDown
const change = tree.diff?.changes.get(block.id)
return ( return (
<div <div
className="cr-block flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2" className="cr-block relative flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2"
{...kindProps(block)} {...kindProps(block)}
data-id={block.id} data-id={block.id}
data-line={block.startLine} data-line={block.startLine}
data-editing={editing ? '' : undefined} data-editing={editing ? '' : undefined}
data-change={change?.change}
> >
{change?.outermost && <span className="cr-added-badge">新增</span>}
{hasChildren {hasChildren
? ( ? (
<button <button
@@ -189,3 +224,44 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
</div> </div>
) )
} }
/**
* 删掉的一块,放在原来的位置:红色虚线边、半透明,没有编辑、问 AI 按钮。默认收成一行「已删除 · 种类 名字」,
* 点箭头或这一行展开,下面是旧内容(整张划掉,名字不能跳定义);子块不画。
*/
function RemovedCard({ placed, tree }: { placed: PlacedRemoved, tree: BlockTreeState }): ReactElement {
const [open, setOpen] = useState(false)
const { block, kindName } = placed.removed
const title = removedTitle(block)
const Chevron = open ? ChevronDown : ChevronRight
const toggle = (): void => setOpen(value => !value)
return (
<ChangeRow tree={tree} change="del">
<div className="cr-block cr-removed flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2" {...kindProps(block)} data-id={placed.id}>
<button
type="button"
className="flex h-5 w-3.5 shrink-0 items-center justify-center rounded-sm text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)]"
aria-label={open ? '收起删掉的内容' : '展开删掉的内容'}
aria-expanded={open}
onClick={toggle}
>
<Chevron size={14} strokeWidth={1.8} />
</button>
<KindIcon block={block} name={kindName} />
<div className="min-w-0 flex-1">
<div className="cr-removed-head cursor-pointer truncate text-[12.5px] leading-5" onClick={toggle}>
<span className="cr-removed-label">已删除 · </span>
{kindName}
{title && <span className="cr-removed-title">{` ${title}`}</span>}
</div>
{open && (
<div className="cr-removed-old mt-1">
{/* 旧块的 id 是旧版本里的位置,换成页面上认删掉的块用的 id,属性行的 id 才不和现在的块撞上 */}
<BlockPreview block={{ ...block, id: placed.id }} tree={tree} />
</div>
)}
</div>
</div>
</ChangeRow>
)
}
@@ -0,0 +1,48 @@
import type { ReactElement } from 'react'
import type { DocumentDiff } from './types'
import { FilePlus, GitCompare } from 'lucide-react'
import { Fragment } from 'react'
import { statsParts } from './diffView'
/**
* 筛选栏第二行(标差异时才有):对比基准(「对比:上一次提交」),改动统计(「新增 2 · 修改 3 · 删除 1」,各按改动色,
* 没有的那种不写,都没有写「没有改动」)。基准里没有这个文件时不写统计,看横幅。
*/
export function DiffBar({ diff }: { diff: DocumentDiff }): ReactElement {
const parts = statsParts(diff.stats)
return (
<div className="mx-auto box-content flex h-[30px] max-w-[880px] items-center gap-2 px-6 text-xs whitespace-nowrap text-[var(--vscode-descriptionForeground)]">
<GitCompare size={14} strokeWidth={1.8} className="shrink-0" aria-hidden />
<span className="min-w-0 truncate">
对比:
<span className="text-[var(--vscode-foreground)]">{diff.base}</span>
</span>
{diff.newFile === undefined && (
<>
<span className="opacity-60" aria-hidden>·</span>
<span className="flex items-center gap-1.5">
{parts.length === 0
? '没有改动'
: parts.map((part, index) => (
<Fragment key={part.change}>
{index > 0 && <span aria-hidden>·</span>}
<span className="cr-diff-stat" data-change={part.change}>{part.text}</span>
</Fragment>
))}
</span>
</>
)}
</div>
)
}
/** 基准里没有这个文件(新文件、未跟踪的):预览顶部一条横幅,卡片不再逐张标绿。 */
export function NewFileBanner({ detail }: { detail: string }): ReactElement {
return (
<div className="cr-new-file mb-3 flex items-center gap-2 rounded-md px-3 py-[7px]" role="status">
<FilePlus size={16} strokeWidth={1.8} className="shrink-0" aria-hidden />
<span className="shrink-0 text-[12.5px] leading-[18px] font-medium text-[var(--vscode-foreground)]">新文件,全部是新增</span>
<span className="min-w-0 text-xs leading-[18px] text-[var(--vscode-descriptionForeground)]">{detail}</span>
</div>
)
}
@@ -1,4 +1,4 @@
import type { FocusEvent, KeyboardEvent, ReactElement } from 'react' import type { FocusEvent, KeyboardEvent, ReactElement, ReactNode } from 'react'
import type { Facet, FilterView } from './filter' import type { Facet, FilterView } from './filter'
import type { Highlight } from './occurrences' import type { Highlight } from './occurrences'
import type { Excluded, FileAttribute, TranslatorOption } from './types' import type { Excluded, FileAttribute, TranslatorOption } from './types'
@@ -18,13 +18,15 @@ export interface FilterBarProps {
translators: TranslatorOption[] translators: TranslatorOption[]
translatorId: string translatorId: string
onSelectTranslator: (id: string) => void onSelectTranslator: (id: string) => void
/** 栏里接在这一行下面的(标差异时的第二行,见 DiffBar) */
below?: ReactNode
} }
/** /**
* 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · 2/5」,再是共 N 块 / 已隐藏 N 块 · 清除), * 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · 2/5」,再是共 N 块 / 已隐藏 N 块 · 清除),
* 多于一种翻译时最右边是翻译下拉框。内容和代码块同宽对齐,滚动时块从它下面过去。 * 多于一种翻译时最右边是翻译下拉框;标差异时下面再接一行(below)。内容和代码块同宽对齐,滚动时块从它下面过去。
*/ */
export function FilterBar({ attributes, excluded, view, highlight, onChange, translators, translatorId, onSelectTranslator }: FilterBarProps): ReactElement { export function FilterBar({ attributes, excluded, view, highlight, onChange, translators, translatorId, onSelectTranslator, below }: FilterBarProps): ReactElement {
return ( return (
<div className="cr-filter-bar"> <div className="cr-filter-bar">
<div className="mx-auto box-content flex max-w-[880px] flex-wrap items-center gap-2 px-6 py-1.5"> <div className="mx-auto box-content flex max-w-[880px] flex-wrap items-center gap-2 px-6 py-1.5">
@@ -72,6 +74,7 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra
</span> </span>
)} )}
</div> </div>
{below}
</div> </div>
) )
} }
@@ -839,3 +839,95 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c
animation: none; animation: none;
} }
} }
/*
* 代码差异:配色取 VS Code 编辑器行号旁的改动色带(新增绿、修改蓝、删除红),主题没给时用 Dark Modern 的默认值;
* 行内的字增删用 diff 编辑器的插入 / 删除文字底色
*/
body {
--cr-diff-add: var(--vscode-editorGutter-addedBackground, #2ea043);
--cr-diff-mod: var(--vscode-editorGutter-modifiedBackground, #0078d4);
--cr-diff-del: var(--vscode-editorGutter-deletedBackground, #f85149);
}
/* 卡片左边外侧的改动色条:3px 宽、和卡片等高;没变的卡片留一条透明的,同一层的卡片照样对齐 */
.cr-change-bar {
width: 3px;
flex-shrink: 0;
border-radius: 1.5px;
}
.cr-change-bar[data-change="add"] { background: var(--cr-diff-add); }
.cr-change-bar[data-change="mod"] { background: var(--cr-diff-mod); }
.cr-change-bar[data-change="del"] { background: var(--cr-diff-del); }
/* 卡片包了一层色条以后不再和子层相邻:悬停卡片时子层竖线换色改按外层认;问答区往右缩到和卡片对齐 */
.cr-change-row:has(> div > .cr-block:hover) + .cr-level > * > .cr-guide,
.cr-change-row:has(> div > .cr-block:hover) + .cr-qa + .cr-level > * > .cr-guide {
background: var(--kind);
}
.cr-change-row + .cr-qa {
margin-left: 8px;
}
/* 新增的卡片(连同它的子卡片):底色淡绿;最外面那张左上角压着「新增」小标 */
.cr-block[data-change="add"] {
background: color-mix(in srgb, var(--cr-diff-add) 10%, transparent);
}
.cr-block[data-change="add"]:hover {
background: color-mix(in srgb, var(--cr-diff-add) 16%, transparent);
}
.cr-added-badge {
position: absolute;
top: -8px;
left: 6px;
padding: 0 5px;
border-radius: 3px;
background: var(--cr-diff-add);
color: #fff;
font-size: 10px;
font-weight: 500;
line-height: 14px;
pointer-events: none;
}
/* 删掉的块:红色虚线边、淡红底、半透明,没有编辑、问 AI 按钮;展开后下面是旧内容,整张划掉 */
.cr-removed {
border: 1px dashed color-mix(in srgb, var(--cr-diff-del) 80%, transparent);
background: color-mix(in srgb, var(--cr-diff-del) 6%, transparent);
opacity: 0.6;
}
.cr-removed:hover {
opacity: 0.8;
}
.cr-removed-head {
color: var(--vscode-descriptionForeground);
}
.cr-removed-head:focus-visible {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 1px;
border-radius: 2px;
}
.cr-removed-label {
color: var(--cr-diff-del);
}
.cr-removed-title {
color: var(--cr-function);
font-family: var(--vscode-editor-font-family, monospace);
}
.cr-removed-old,
.cr-removed-old * {
text-decoration-line: line-through;
}
/* 筛选栏第二行:对比基准、改动统计、「只看改动」、上一处 / 下一处 */
.cr-diff-stat[data-change="add"] { color: var(--cr-diff-add); }
.cr-diff-stat[data-change="mod"] { color: var(--cr-diff-mod); }
.cr-diff-stat[data-change="del"] { color: var(--cr-diff-del); }
/* 基准里没有这个文件:顶部一条横幅,卡片不再逐张标绿 */
.cr-new-file {
border: 1px solid color-mix(in srgb, var(--cr-diff-add) 50%, transparent);
background: color-mix(in srgb, var(--cr-diff-add) 14%, transparent);
}
.cr-new-file > svg {
color: var(--cr-diff-add);
}
@@ -0,0 +1,71 @@
import type { BlockView, DocumentDiff, RemovedView } from './types'
import { describe, expect, it } from 'vitest'
import { diffTree, openedByDiff, removedTitle, statsParts } from './diffView'
function block(id: string, anchor: string, children: BlockView[] = [], extra: Partial<BlockView> = {}): BlockView {
return { id, anchor, kind: 'function', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'function' }, children, translation: [], ...extra } as BlockView
}
function removed(parent: string, after: string | undefined, kind = 'function'): RemovedView {
return { parent, ...(after !== undefined && { after }), block: block('9', `${kind} gone`, [], { kind }), line: 3, kindName: '函数' }
}
function diff(parts: Partial<DocumentDiff>): DocumentDiff {
return { base: '上一次提交', version: 2, blocks: {}, removed: [], stats: { add: 0, mod: 0, del: 0 }, lineCount: 10, ...parts }
}
const BLOCKS = [
block('0', 'function a', [block('0.0', 'function a > return @1')]),
block('1', 'function b'),
]
describe('diffTree', () => {
it('块按记号认成页面上的 id;页面上找不到的记号(文档刚改)不标', () => {
const tree = diffTree(BLOCKS, diff({ blocks: { 'function a': { change: 'mod' }, 'function a > return @1': { change: 'mod', own: true }, 'function gone': { change: 'add' } } }), 2, () => true)
expect([...tree!.changes]).toEqual([['0', { change: 'mod' }], ['0.0', { change: 'mod', own: true }]])
expect(tree!.current).toBe(true)
})
it('页面上不是差异算的那一版时,卡片里的标记不画', () => {
expect(diffTree(BLOCKS, diff({}), 3, () => true)?.current).toBe(false)
})
it('删掉的块按层挂:父块、排在后面的那块换成页面上的 id,找不到的不画,被筛掉的不画', () => {
const tree = diffTree(BLOCKS, diff({ removed: [
removed('', 'function b'),
removed('function a', undefined),
removed('function missing', undefined),
removed('', 'function missing'),
removed('', undefined, 'import'),
] }), 2, item => item.block.kind !== 'import')
expect(tree!.removed.get('')).toEqual([{ id: 'removed-0', after: '1', removed: removed('', 'function b') }])
expect(tree!.removed.get('0')).toEqual([{ id: 'removed-1', removed: removed('function a', undefined) }])
expect(tree!.removed.size).toBe(2)
})
it('不比、基准里没有这个文件时不逐块标', () => {
expect(diffTree(BLOCKS, null, 2, () => true)).toBeUndefined()
expect(diffTree(BLOCKS, diff({ newFile: '上一次提交里没有这个文件' }), 2, () => true)).toBeUndefined()
})
it('标了改动的块默认展开', () => {
const tree = diffTree(BLOCKS, diff({ blocks: { 'function b': { change: 'add', outermost: true } } }), 2, () => true)
expect([...openedByDiff(tree)]).toEqual(['1'])
expect(openedByDiff(undefined).size).toBe(0)
})
})
describe('statsParts', () => {
it('没有的那种不写', () => {
expect(statsParts({ add: 2, mod: 0, del: 1 })).toEqual([{ change: 'add', text: '新增 2' }, { change: 'del', text: '删除 1' }])
expect(statsParts({ add: 0, mod: 0, del: 0 })).toEqual([])
})
})
describe('removedTitle', () => {
it('属性表取第一行的值,一行译文取译文,只取第一行', () => {
expect(removedTitle(block('0', 'x', [], { properties: [{ label: '名称', value: [{ text: '_is_owner', role: 'function' }] }] } as Partial<BlockView>))).toBe('_is_owner')
expect(removedTitle(block('0', 'x', [], { translation: [{ text: '调用 f\n接着', role: 'plain' }] }))).toBe('调用 f')
expect(removedTitle(block('0', 'x', [], { kind: 'comment', translation: { markdown: '说明第一行\n第二行' } }))).toBe('说明第一行')
})
})
@@ -0,0 +1,70 @@
import type { BlockView, ChangedBlock, DiffStats, DocumentDiff, RemovedView } from './types'
import { inlineText } from './segments'
/** 页面上怎么标差异:按块 id 取改动,按层取删掉的块。基准里没有这个文件(整份新增)时不逐块标,没有它。 */
export interface DiffTree {
/** 按块 id:新增、修改的块 */
changes: ReadonlyMap<string, ChangedBlock>
/** 按父块 id(顶层 ''):这一层删掉的块,照差异里的先后 */
removed: ReadonlyMap<string, PlacedRemoved[]>
/** 页面上正是差异算的那一版:卡片里的标记按下标认,只在这时画 */
current: boolean
}
/** 放在页面上的一块删掉的块:id 是页面上认它用的(`removed-<序号>`,不会和块 id、属性行 id 撞上),after 是排在哪块(id)后面,最前面时没有。 */
export interface PlacedRemoved {
id: string
after?: string
removed: RemovedView
}
function* allBlocks(blocks: readonly BlockView[]): Generator<BlockView> {
for (const block of blocks) {
yield block
yield* allBlocks(block.children)
}
}
/**
* 差异(块按记号认)→ 页面上的块 id:记号在页面上找不到的(文档刚改、差异还没重算)不标;删掉的块挂的父块、排在后面的那块找不到的也不画。
* shown 挑删掉的块画不画(被筛选藏掉的种类不画)。
*/
export function diffTree(blocks: readonly BlockView[], diff: DocumentDiff | null, version: number, shown: (removed: RemovedView) => boolean): DiffTree | undefined {
if (!diff || diff.newFile !== undefined)
return undefined
const ids = new Map([...allBlocks(blocks)].map(block => [block.anchor, block.id]))
const changes = new Map<string, ChangedBlock>()
for (const [anchor, change] of Object.entries(diff.blocks)) {
const id = ids.get(anchor)
if (id !== undefined)
changes.set(id, change)
}
const removed = new Map<string, PlacedRemoved[]>()
diff.removed.forEach((item, index) => {
const parent = item.parent === '' ? '' : ids.get(item.parent)
const after = item.after === undefined ? undefined : ids.get(item.after)
if (parent === undefined || (item.after !== undefined && after === undefined) || !shown(item))
return
removed.set(parent, [...removed.get(parent) ?? [], { id: `removed-${index}`, ...(after !== undefined && { after }), removed: item }])
})
return { changes, removed, current: diff.version === version }
}
/** 标了改动的块默认展开(函数、类本来默认收起子块):改了什么一眼能看到。 */
export function openedByDiff(tree: DiffTree | undefined): ReadonlySet<string> {
return new Set(tree?.changes.keys())
}
/** 「新增 2 · 修改 3 · 删除 1」的各段:没有的那种不写。 */
export function statsParts(stats: DiffStats): { change: keyof DiffStats, text: string }[] {
const labels: Record<keyof DiffStats, string> = { add: '新增', mod: '修改', del: '删除' }
return (['add', 'mod', 'del'] as const).filter(change => stats[change] > 0).map(change => ({ change, text: `${labels[change]} ${stats[change]}` }))
}
/** 删掉的块收起时写的名字:属性表是第一行的值(函数名、变量名、导入的来源),一行译文是译文;只取第一行。 */
export function removedTitle(block: BlockView): string {
const text = 'properties' in block
? block.properties[0] && ('markdown' in block.properties[0].value ? block.properties[0].value.markdown : inlineText(block.properties[0].value))
: 'markdown' in block.translation ? block.translation.markdown : inlineText(block.translation)
return (text ?? '').split('\n', 1)[0].trim()
}
+92
View File
@@ -258,6 +258,96 @@ export interface AnswerEnd {
entryId?: string entryId?: string
} }
/** 一截是新加的还是删掉的;没变的不写 */
export type PartChange = 'add' | 'del'
/** 着色的词里的一截:没变的、新加的(带源码下标),或删掉的(取自旧版本,不带下标) */
export interface InlinePart { segments: Inline[], change?: PartChange }
/** Markdown 说明里的一截:按词切,中文按字 */
export interface MarkdownPart { text: string, change?: PartChange }
/** 一段预览文字改成了什么样:着色的词逐截标(改得多就是整段旧值 → 新值),Markdown 说明逐词 / 字标 */
export type TextDiff = { inline: InlinePart[] } | { markdown: MarkdownPart[] }
/** 删掉的一行 / 子行:旧的那行,和它排在新的第几行后面(-1 是最前面) */
export interface Removed<T> { after: number, value: T }
/** 一层子行(参数表、导入项、字典的各项、摊开实参的表)的改动:changed 按新子行的下标,没变的不写 */
export interface ItemsDiff {
changed: Record<number, ItemDiff>
removed: Removed<PropertyItem>[]
}
/** 一个子行的改动:整行新加的(它下面各层也都是),或按列标的格子、下一层、套着的表 */
export interface ItemDiff {
added?: true
cells?: Record<number, InlinePart[]>
items?: ItemsDiff
table?: ItemsDiff
}
/** 属性表各行的改动:changed 按新属性表的行下标,没变的不写 */
export interface RowsDiff {
changed: Record<number, RowDiff>
removed: Removed<PropertyRow>[]
}
/** 属性表一行的改动:整行新加的(它下面的子行也都是),或值、子行改了 */
export interface RowDiff {
added?: true
value?: TextDiff
items?: ItemsDiff
}
/** 一块自己的内容(不算子块)改了哪些:属性表逐行,一行译文逐截,摊开实参的表逐行,说明逐词 */
export interface BlockMarks {
rows?: RowsDiff
translation?: TextDiff
table?: ItemsDiff
doc?: TextDiff
}
/**
* 一块的改动:add 新增(outermost 是新增的最外面那块,挂「新增」小标;子孙跟着算新增),mod 修改(own 是这块自己的内容或头部变了,
* 不只是子块变了;marks 是卡片里要标的地方)
*/
export interface ChangedBlock {
change: 'add' | 'mod'
outermost?: true
own?: true
marks?: BlockMarks
}
/**
* 删掉的一块,放回原来的位置:parent 是父块的记号(顶层 ''),after 是排在这一层哪块后面(新块的记号,最前面时没有);
* block 是旧版本里的这块(只带它自己的内容,词不带源码下标),line 是它在新文件里的位置(0 起),kindName 是种类的名字
*/
export interface RemovedView {
parent: string
after?: string
block: BlockView
line: number
kindName: string
}
/** 「新增 N · 修改 N · 删除 N」:只数最外层带标记的块 */
export interface DiffStats { add: number, mod: number, del: number }
/**
* 这份文件相比对比基准改了什么:base 是「对比:」后面的字;块按记号(anchor)认,卡片里的标记只在页面上正是 version 这一版时画;
* newFile 是基准里没有这个文件时横幅上的说明(这时不逐块标);lineCount 是新文件一共几行,总览条按它排刻度
*/
export interface DocumentDiff {
base: string
version: number
newFile?: string
blocks: Record<string, ChangedBlock>
removed: RemovedView[]
stats: DiffStats
lineCount: number
}
/** /**
* reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal)
* definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义 * definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义
@@ -269,6 +359,7 @@ export interface AnswerEnd {
* answerEnd:askId 那个问题答完了,见 AnswerEnd * answerEnd:askId 那个问题答完了,见 AnswerEnd
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了
*/ */
export type ExtensionToWebview export type ExtensionToWebview
= | DocumentMessage = | DocumentMessage
@@ -282,6 +373,7 @@ export type ExtensionToWebview
| ({ type: 'askProfiles' } & ProfileChoice) | ({ type: 'askProfiles' } & ProfileChoice)
| { type: 'answer', askId: string, text: string, activity?: string } | { type: 'answer', askId: string, text: string, activity?: string }
| AnswerEnd | AnswerEnd
| { type: 'diff', diff: DocumentDiff | null }
/** /**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进)
@@ -119,6 +119,11 @@ describe('眼下折叠着哪些', () => {
expect([...collapsedIds([block('0', 'exception', [block('0.0', 'function')]), block('1', 'if')], {})]).toEqual(['0', '0.0']) expect([...collapsedIds([block('0', 'exception', [block('0.0', 'function')]), block('1', 'if')], {})]).toEqual(['0', '0.0'])
}) })
it('标了改动的块默认展开,用户折叠过的照样折叠', () => {
expect([...collapsedIds(BLOCKS, {}, new Set(['1', '2']))].sort()).toEqual(['1.0.0', '2.0'])
expect([...collapsedIds(BLOCKS, { 1: true }, new Set(['1']))].sort()).toEqual(['1', '1.0.0', '2', '2.0'])
})
it('点过的按用户的:展开的函数、类保持展开,折叠的其他块、属性行保持折叠', () => { it('点过的按用户的:展开的函数、类保持展开,折叠的其他块、属性行保持折叠', () => {
const folds = { '1': false, '2': false, '1.0': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false } const folds = { '1': false, '2': false, '1.0': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false }
expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '1.0', '2.0']) expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '1.0', '2.0'])
@@ -59,12 +59,12 @@ export function foldedByDefault(block: Pick<BlockView, 'kind' | 'badge'>): boole
return block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind) return block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind)
} }
/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */ /** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault;opened 里的块(标了改动的)默认展开。 */
export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Record<string, boolean>>): Set<string> { export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Record<string, boolean>>, opened: ReadonlySet<string> = new Set()): Set<string> {
const collapsed = new Set(Object.keys(folds).filter(id => folds[id])) const collapsed = new Set(Object.keys(folds).filter(id => folds[id]))
const visit = (list: readonly BlockView[]): void => { const visit = (list: readonly BlockView[]): void => {
for (const block of list) { for (const block of list) {
if (folds[block.id] === undefined && foldedByDefault(block)) if (folds[block.id] === undefined && foldedByDefault(block) && !opened.has(block.id))
collapsed.add(block.id) collapsed.add(block.id)
visit(block.children) visit(block.children)
} }