From 3b7ce6984227442913d7062c25a6d0c1b5f3371a Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 30 Sep 2026 08:41:55 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E9=A2=84=E8=A7=88=E6=A0=87=E5=87=BA=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20/=20=E4=BF=AE=E6=94=B9=20/=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E7=9A=84=E5=8D=A1=E7=89=87=EF=BC=88=E5=B7=A6=E4=BE=A7=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E8=89=B2=E6=9D=A1=E3=80=81=E6=96=B0=E5=A2=9E=E6=B7=A1?= =?UTF-8?q?=E7=BB=BF=E5=BA=95=E6=8C=82=E5=B0=8F=E6=A0=87=E3=80=81=E5=88=A0?= =?UTF-8?q?=E6=8E=89=E7=9A=84=E5=9D=97=E5=8E=9F=E4=BD=8D=E6=94=B6=E6=88=90?= =?UTF-8?q?=E4=B8=80=E8=A1=8C=E5=8F=AF=E5=B1=95=E5=BC=80=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E6=A0=8F=E7=AC=AC=E4=BA=8C=E8=A1=8C=E5=86=99?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E5=9F=BA=E5=87=86=E5=92=8C=E6=94=B9=E5=8A=A8?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=EF=BC=8C=E6=96=B0=E6=96=87=E4=BB=B6=E9=A1=B6?= =?UTF-8?q?=E9=83=A8=E6=A8=AA=E5=B9=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/webview-ui/src/codeReader/App.tsx | 22 ++++- .../webview-ui/src/codeReader/BlockTree.tsx | 96 +++++++++++++++++-- vscode/webview-ui/src/codeReader/DiffBar.tsx | 48 ++++++++++ .../webview-ui/src/codeReader/FilterBar.tsx | 9 +- .../webview-ui/src/codeReader/codeReader.css | 92 ++++++++++++++++++ .../src/codeReader/diffView.test.ts | 71 ++++++++++++++ vscode/webview-ui/src/codeReader/diffView.ts | 70 ++++++++++++++ vscode/webview-ui/src/codeReader/types.ts | 92 ++++++++++++++++++ .../src/codeReader/viewState.test.ts | 5 + vscode/webview-ui/src/codeReader/viewState.ts | 6 +- 10 files changed, 491 insertions(+), 20 deletions(-) create mode 100644 vscode/webview-ui/src/codeReader/DiffBar.tsx create mode 100644 vscode/webview-ui/src/codeReader/diffView.test.ts create mode 100644 vscode/webview-ui/src/codeReader/diffView.ts diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 8be4775..368b413 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -1,15 +1,17 @@ import type { ReactElement } from 'react' import type { BlockTreeState } from './BlockTree' 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 { ViewState } from './viewState' import { TriangleAlert } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BlockLevel } from './BlockTree' +import { DiffBar, NewFileBanner } from './DiffBar' +import { diffTree, openedByDiff } from './diffView' import { acceptNames, DisplayNameContext, documentNames } from './displayNames' import { coveredByEditing, startEditing } from './editing' -import { applyFilter, keptWhileEditing } from './filter' +import { applyFilter, excludedBy, keptWhileEditing } from './filter' import { FilterBar } from './FilterBar' import { useDefinitionLinks } from './useDefinitionLinks' import { HighlightContext, useOccurrences } from './useOccurrences' @@ -43,9 +45,15 @@ export function App(): ReactElement | null { const [keep, setKeep] = useState>(NONE) /** 页面上这个文档版本的中文名:文档带来的,扩展端查到了再换 */ const [names, setNames] = useState() + /** 这份文件相比对比基准改了什么:侧栏开着「未提交的更改」或审查工单时扩展端算好了补来,不比时是 null */ + const [diff, setDiff] = useState(null) /** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */ 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 covered = useMemo(() => coveredByEditing(doc?.blocks ?? [], new Set(view.editing)), [doc, view.editing]) /** 记下这几块、属性行、子行折叠还是展开,从此不再按默认 */ @@ -88,6 +96,9 @@ export function App(): ReactElement | null { case 'filter': setExcluded(msg.excluded) setKeep(NONE) + return + case 'diff': + setDiff(msg.diff) } }) postMessage({ type: 'ready' }) @@ -121,7 +132,8 @@ export function App(): ReactElement | null { }, onToggleFold: id => setFolded([id], !collapsed.has(id)), ...(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) return null @@ -138,8 +150,10 @@ export function App(): ReactElement | null { translators={doc.translators} translatorId={doc.translatorId} onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })} + below={diff && doc.error === undefined && } />
+ {diff?.newFile !== undefined && doc.error === undefined && } {doc.error !== undefined && (
diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index f891c20..814b367 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -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 { PendingImage } from './qaImages' import type { BlockView } from './types' import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react' -import { Fragment } from 'react' +import { Fragment, useState } from 'react' import { AskThread } from './AskThread' import { BlockTools } from './BlockTools' import { CodeEditor } from './CodeEditor' +import { removedTitle } from './diffView' import { KindIcon } from './kindIcons' import { PropertyGrid, PropertyTable } from './PropertyGrid' import { asksOf, historyOf } from './qa' @@ -33,6 +35,8 @@ export interface BlockTreeState { onToggleFold: (id: string) => void /** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */ qa?: QaTree + /** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */ + diff?: DiffTree } /** 问 AI 的状态和动作,块按自己的记号(anchor)取 */ @@ -48,9 +52,23 @@ export interface QaTree { onSelectProfile: (id: string) => void } -/** 块下面有没有画出来的子块:子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */ +/** 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 */ 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 ( +
+ +
{children}
+
+ ) } /** 块画不画:被筛掉的、被正在编辑的祖先盖住的都不画。 */ @@ -74,21 +92,35 @@ function kindProps(block: BlockView | undefined): { 'data-kind'?: string, 'data- /** * 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。 * owner 是这一层所属的块(顶层没有),悬停它时竖线换成它的种类色。 + * 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard)。 */ 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 => ) return (
- {blocks.filter(block => shown(block, tree)).map(block => ( + {removedAfter(undefined)} + {blocks.map(block => ( - - {tree.qa && } - {hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && ( - + {shown(block, tree) && ( + <> + + + + {tree.qa && } + {hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && ( + + )} + )} + {removedAfter(block.id)} ))}
@@ -139,14 +171,17 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): const collapsed = tree.collapsed.has(block.id) const hasChildren = hasShownChildren(block, tree) const Chevron = collapsed ? ChevronRight : ChevronDown + const change = tree.diff?.changes.get(block.id) return (
+ {change?.outermost && 新增} {hasChildren ? ( + +
+
+ 已删除 · + {kindName} + {title && {` ${title}`}} +
+ {open && ( +
+ {/* 旧块的 id 是旧版本里的位置,换成页面上认删掉的块用的 id,属性行的 id 才不和现在的块撞上 */} + +
+ )} +
+
+ + ) +} diff --git a/vscode/webview-ui/src/codeReader/DiffBar.tsx b/vscode/webview-ui/src/codeReader/DiffBar.tsx new file mode 100644 index 0000000..dca51df --- /dev/null +++ b/vscode/webview-ui/src/codeReader/DiffBar.tsx @@ -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 ( +
+ + + 对比: + {diff.base} + + {diff.newFile === undefined && ( + <> + · + + {parts.length === 0 + ? '没有改动' + : parts.map((part, index) => ( + + {index > 0 && ·} + {part.text} + + ))} + + + )} +
+ ) +} + +/** 基准里没有这个文件(新文件、未跟踪的):预览顶部一条横幅,卡片不再逐张标绿。 */ +export function NewFileBanner({ detail }: { detail: string }): ReactElement { + return ( +
+ + 新文件,全部是新增 + {detail} +
+ ) +} diff --git a/vscode/webview-ui/src/codeReader/FilterBar.tsx b/vscode/webview-ui/src/codeReader/FilterBar.tsx index c224660..1fa942d 100644 --- a/vscode/webview-ui/src/codeReader/FilterBar.tsx +++ b/vscode/webview-ui/src/codeReader/FilterBar.tsx @@ -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 { Highlight } from './occurrences' import type { Excluded, FileAttribute, TranslatorOption } from './types' @@ -18,13 +18,15 @@ export interface FilterBarProps { translators: TranslatorOption[] translatorId: string onSelectTranslator: (id: string) => void + /** 栏里接在这一行下面的(标差异时的第二行,见 DiffBar) */ + below?: ReactNode } /** * 预览顶部钉住的一条:「筛选」、这份文件里每个能筛的属性一个下拉框、右边汇总(高亮着名字时先是「名字 · 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 (
@@ -72,6 +74,7 @@ export function FilterBar({ attributes, excluded, view, highlight, onChange, tra )}
+ {below}
) } diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index f3bd87e..238ff6a 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -839,3 +839,95 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c 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); +} diff --git a/vscode/webview-ui/src/codeReader/diffView.test.ts b/vscode/webview-ui/src/codeReader/diffView.test.ts new file mode 100644 index 0000000..5256fa7 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/diffView.test.ts @@ -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 { + 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 { + 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))).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('说明第一行') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/diffView.ts b/vscode/webview-ui/src/codeReader/diffView.ts new file mode 100644 index 0000000..59b58d6 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/diffView.ts @@ -0,0 +1,70 @@ +import type { BlockView, ChangedBlock, DiffStats, DocumentDiff, RemovedView } from './types' +import { inlineText } from './segments' + +/** 页面上怎么标差异:按块 id 取改动,按层取删掉的块。基准里没有这个文件(整份新增)时不逐块标,没有它。 */ +export interface DiffTree { + /** 按块 id:新增、修改的块 */ + changes: ReadonlyMap + /** 按父块 id(顶层 ''):这一层删掉的块,照差异里的先后 */ + removed: ReadonlyMap + /** 页面上正是差异算的那一版:卡片里的标记按下标认,只在这时画 */ + current: boolean +} + +/** 放在页面上的一块删掉的块:id 是页面上认它用的(`removed-<序号>`,不会和块 id、属性行 id 撞上),after 是排在哪块(id)后面,最前面时没有。 */ +export interface PlacedRemoved { + id: string + after?: string + removed: RemovedView +} + +function* allBlocks(blocks: readonly BlockView[]): Generator { + 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() + 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() + 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 { + return new Set(tree?.changes.keys()) +} + +/** 「新增 2 · 修改 3 · 删除 1」的各段:没有的那种不写。 */ +export function statsParts(stats: DiffStats): { change: keyof DiffStats, text: string }[] { + const labels: Record = { 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() +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 1111942..2be9e54 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -258,6 +258,96 @@ export interface AnswerEnd { 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 { after: number, value: T } + +/** 一层子行(参数表、导入项、字典的各项、摊开实参的表)的改动:changed 按新子行的下标,没变的不写 */ +export interface ItemsDiff { + changed: Record + removed: Removed[] +} + +/** 一个子行的改动:整行新加的(它下面各层也都是),或按列标的格子、下一层、套着的表 */ +export interface ItemDiff { + added?: true + cells?: Record + items?: ItemsDiff + table?: ItemsDiff +} + +/** 属性表各行的改动:changed 按新属性表的行下标,没变的不写 */ +export interface RowsDiff { + changed: Record + removed: Removed[] +} + +/** 属性表一行的改动:整行新加的(它下面的子行也都是),或值、子行改了 */ +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 + removed: RemovedView[] + stats: DiffStats + lineCount: number +} + /** * reveal:已经开着的预览滚到某处(同 DocumentMessage.reveal) * definitionProbed:id 那次 probeDefinition 的回答,found 表示查得到定义 @@ -269,6 +359,7 @@ export interface AnswerEnd { * answerEnd:askId 那个问题答完了,见 AnswerEnd * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 + * diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有);每来一份文档之后补来,基准变了再来;null 是不比了 */ export type ExtensionToWebview = | DocumentMessage @@ -282,6 +373,7 @@ export type ExtensionToWebview | ({ type: 'askProfiles' } & ProfileChoice) | { type: 'answer', askId: string, text: string, activity?: string } | AnswerEnd + | { type: 'diff', diff: DocumentDiff | null } /** * edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来第一行的缩进) diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index 4be08fa..80cf24d 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -119,6 +119,11 @@ describe('眼下折叠着哪些', () => { 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('点过的按用户的:展开的函数、类保持展开,折叠的其他块、属性行保持折叠', () => { 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']) diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index 7bf7edf..4e703ff 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -59,12 +59,12 @@ export function foldedByDefault(block: Pick): boole return block.badge !== undefined || ['function', 'class', 'enum', 'exception'].includes(block.kind) } -/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */ -export function collapsedIds(blocks: readonly BlockView[], folds: Readonly>): Set { +/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault;opened 里的块(标了改动的)默认展开。 */ +export function collapsedIds(blocks: readonly BlockView[], folds: Readonly>, opened: ReadonlySet = new Set()): Set { const collapsed = new Set(Object.keys(folds).filter(id => folds[id])) const visit = (list: readonly BlockView[]): void => { 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) visit(block.children) }