diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 814b367..c837279 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -2,12 +2,13 @@ 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 type { BlockMarks, BlockView } from './types' import { ChevronDown, ChevronRight, EyeOff } from 'lucide-react' import { Fragment, useState } from 'react' import { AskThread } from './AskThread' import { BlockTools } from './BlockTools' import { CodeEditor } from './CodeEditor' +import { DiffText } from './DiffText' import { removedTitle } from './diffView' import { KindIcon } from './kindIcons' import { PropertyGrid, PropertyTable } from './PropertyGrid' @@ -147,20 +148,20 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement { /** * 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文; - * 译文下面依次接着摊开实参的表(跨几行写的调用)、说明。 + * 译文下面依次接着摊开实参的表(跨几行写的调用)、说明。标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。 */ -function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { +function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTreeState, marks?: BlockMarks }): ReactElement { if ('properties' in block) - return + return return ( <> - + {marks?.translation ? : } {block.table && (
- +
)} - {block.doc && } + {marks?.doc ? : block.doc && } ) } @@ -207,7 +208,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): onExit={() => tree.onToggleEdit(block.id)} /> ) - : } + : } {tree.temporary.has(block.id) && ( diff --git a/vscode/webview-ui/src/codeReader/DiffText.tsx b/vscode/webview-ui/src/codeReader/DiffText.tsx new file mode 100644 index 0000000..59f9a7f --- /dev/null +++ b/vscode/webview-ui/src/codeReader/DiffText.tsx @@ -0,0 +1,34 @@ +import type { ReactElement } from 'react' +import type { InlinePart, TextDiff } from './types' +import { cn } from '@/lib/utils' +import { renderMarkdownDiff } from './markdown' +import { SegmentText } from './SegmentText' + +/** + * 着色的词里的增删(同一行的值改了):没变的照常,删掉的一截红底划掉(旧值,不能跳定义),新加的一截绿底, + * 整段改了就是旧值紧跟新值。放在 .cr-code 的段落里。 + */ +export function DiffInline({ parts }: { parts: InlinePart[] }): ReactElement { + return ( + <> + {parts.map((part, index) => part.change + ? ( + + + + ) + : )} + + ) +} + +/** 预览里一段带增删的文字:着色的词逐截标(DiffInline),Markdown 说明照常排版、增删的字按词 / 字标(同 TextPreview 的排版)。 */ +export function DiffText({ diff, className }: { diff: TextDiff, className?: string }): ReactElement { + if ('markdown' in diff) + return
+ return ( +

+ +

+ ) +} diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index ce8789d..a359e75 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -1,7 +1,8 @@ import type { ReactElement } from 'react' -import type { Inline, PropertyItem, PropertyRow } from './types' +import type { Inline, InlinePart, ItemDiff, ItemsDiff, PropertyItem, PropertyRow, Removed, RowsDiff } from './types' import { ChevronDown, ChevronRight } from 'lucide-react' import { Fragment } from 'react' +import { DiffInline, DiffText } from './DiffText' import { inlineText } from './segments' import { SegmentText } from './SegmentText' import { subRowsLayout, tableCells, tableTemplate } from './subRows' @@ -22,6 +23,22 @@ interface Folding { onToggleFold: (id: string) => void } +/** 删掉的行下面的子行:总是展开,点了也不收(旧内容只是看看)。 */ +const FIXED: Folding = { collapsed: new Set(), onToggleFold: () => {} } + +/** 表格行第一格前的记号:加的 +、删的 −,没变的空一格,列照样对齐。 */ +const SIGNS = { add: '+', del: '−', none: ' ' } as const + +/** 删掉的行(子行)里排在新的第 after 行后面的那些,带着它在删掉的行里的序号(页面上认它用)。 */ +function removedAt(removed: readonly Removed[] | undefined, after: number): { value: T, index: number }[] { + return (removed ?? []).flatMap((entry, index) => entry.after === after ? [{ value: entry.value, index }] : []) +} + +/** 一格的字:这一格的值改了就逐截标(DiffInline),否则照常。 */ +function CellText({ cell, parts }: { cell: Inline[], parts: InlinePart[] | undefined }): ReactElement { + return parts ? : +} + /** * 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换; * 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。 @@ -60,32 +77,53 @@ function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Inline[], /** * 展开在一行或一个子行(parentId)下面的一层子行:左边一条竖线,每个子行几列对齐。 * 带下一层的子行(字典 / 对象的值又是字典 / 对象),下一层展开在它下面、再往右缩进一层,默认展开。 + * 标差异时(marks)新加的子行淡绿底、左边 +,删掉的放回原来的位置、淡红底划掉、左边 −,改了的格子逐截标。 */ -function PropertyItems({ parentId, items, folding }: { parentId: string, items: PropertyItem[], folding: Folding }): ReactElement { +function PropertyItems({ parentId, items, folding, marks }: { parentId: string, items: PropertyItem[], folding: Folding, marks?: ItemsDiff }): ReactElement { const nested = items.some(item => (item.items?.length ?? 0) > 0) + /** 一个子行:删掉的(change 是 del)不带 id、下面的子行总是展开 */ + const itemRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined): ReactElement => { + const children = item.items ?? [] + const folds = change === 'del' ? FIXED : folding + return ( + <> +
+ {item.cells.map((cell, i) => i === 0 && nested + ? 0 && change !== 'del'} folding={folds} /> + : ( +

+ +

+ ))} +
+ {children.length > 0 && !folds.collapsed.has(id) && (change === 'del' + ? ( +
+ +
+ ) + : )} + + ) + } + const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after) + .map(({ value, index }) => {itemRow(value, `${parentId}/x${index}`, 'del', undefined)}) return (
+ {removed(-1)} {items.map((item, index) => { - const id = propertyItemId(parentId, index) - const children = item.items ?? [] + const mark = marks?.changed[index] return ( -
- {item.cells.map((cell, i) => i === 0 && nested - ? 0} folding={folding} /> - : ( -

- -

- ))} -
- {children.length > 0 && !folding.collapsed.has(id) && } + {itemRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark)} + {removed(index)}
) })} @@ -98,34 +136,49 @@ function PropertyItems({ parentId, items, folding }: { parentId: string, items: * 给了列名的子行(输入参数、数据表的字段)排成表格:第一行是表头(列名),下面每个子行一行;外框圆角,格子之间、行之间一条分隔线。 * 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。一行译文下面摊开实参的表也用它; * 行带 table(实参的值里跨几行写的调用)时,那张表排在最后一格里、文字下面,往右缩一点,一层层往下套。 + * 标差异时(marks)有加行、删行的表每行第一格前多一个记号位(+ / −,没变的空着),加的行淡绿底,删的行放回原来的位置、淡红底划掉。 */ -export function PropertyTable({ parentId, columns, items }: { parentId: string, columns: string[], items: PropertyItem[] }): ReactElement { +export function PropertyTable({ parentId, columns, items, marks }: { parentId: string, columns: string[], items: PropertyItem[], marks?: ItemsDiff }): ReactElement { + // 有加行、删行的表每行第一格前留一个记号位 + const signed = marks !== undefined && (marks.removed.length > 0 || Object.values(marks.changed).some(mark => mark.added)) + /** 一行:删掉的(change 是 del)不带 id,套着的表不再标 */ + const tableRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined): ReactElement => { + const cells = tableCells(item, columns) + const text = (cell: Inline[], i: number): ReactElement => ( + <> + {signed && i === 0 && {SIGNS[change ?? 'none']}} + + + ) + return ( +
+ {cells.map((cell, i) => item.table && i === cells.length - 1 + ? ( +
+

{text(cell, i)}

+ +
+ ) + :

{text(cell, i)}

)} +
+ ) + } + const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after) + .map(({ value, index }) => {tableRow(value, `${parentId}/x${index}`, 'del', undefined)}) return (
{columns.map((name, i) => {name})}
+ {removed(-1)} {items.map((item, index) => { - const id = propertyItemId(parentId, index) - const cells = tableCells(item, columns) + const mark = marks?.changed[index] return ( -
- {cells.map((cell, i) => item.table && i === cells.length - 1 - ? ( -
-

- -

- -
- ) - : ( -

- -

- ))} -
+ + {tableRow(item, propertyItemId(parentId, index), mark?.added && 'add', mark)} + {removed(index)} + ) })}
@@ -136,23 +189,30 @@ export function PropertyTable({ parentId, columns, items }: { parentId: string, /** * 函数、类、枚举、导入、常量、变量块和库规则的卡片的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行, * 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。 + * 标差异时(marks)新加的行淡绿底、左边 +,删掉的行放回原来的位置、淡红底划掉、左边 −,值改了的按旧值 → 新值或逐词标。 */ -export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: { +export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks }: { blockId: string rows: PropertyRow[] collapsed: ReadonlySet onToggleFold: (id: string) => void + /** 标差异时这张属性表改了哪些行 */ + marks?: RowsDiff }): ReactElement { + const removed = (after: number): ReactElement[] => removedAt(marks?.removed, after) + .map(({ value, index }) => ) return (
+ {removed(-1)} {rows.map((row, index) => { const layout = subRowsLayout(row) const id = propertyRowId(blockId, index) const folded = collapsed.has(id) const Chevron = folded ? ChevronRight : ChevronDown + const mark = marks?.changed[index] return ( -
+
{layout ? (
- {!folded && layout === 'table' && } - {!folded && layout === 'list' && } + {!folded && layout === 'table' && } + {!folded && layout === 'list' && } + {removed(index)} ) })}
) } + +/** 属性表里删掉的一行:整行淡红底、划掉,左边 −;它下面的子行照样摊开、划掉。 */ +function RemovedRow({ id, row }: { id: string, row: PropertyRow }): ReactElement { + const layout = subRowsLayout(row) + return ( + <> +
+ {row.label} + +
+ {layout && ( +
+ {layout === 'table' + ? + : } +
+ )} + + ) +} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 238ff6a..a0d17e9 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -931,3 +931,69 @@ body { .cr-new-file > svg { color: var(--cr-diff-add); } + +/* + * 卡片里的标记:改了的值旧值红底划掉、紧跟新值绿底(.cr-diff-part),说明里增删的字同样(.cr-diff-word, / ); + * 新加的行、子行整行淡绿底、左边 +,删掉的整行淡红底、划掉、左边 −;表格的行记号写在第一格前(.cr-diff-sign) + */ +body { + --cr-diff-add-text: var(--vscode-diffEditor-insertedTextBackground, color-mix(in srgb, var(--cr-diff-add) 32%, transparent)); + --cr-diff-del-text: var(--vscode-diffEditor-removedTextBackground, color-mix(in srgb, var(--cr-diff-del) 32%, transparent)); +} +.cr-diff-part, +.cr-diff-word { + border-radius: 2px; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; +} +:is(.cr-diff-part, .cr-diff-word)[data-change="add"], +ins.cr-diff-word { + background: var(--cr-diff-add-text); + text-decoration: none; +} +:is(.cr-diff-part, .cr-diff-word)[data-change="del"], +del.cr-diff-word { + background: var(--cr-diff-del-text); + text-decoration-line: line-through; +} +.cr-diff-part[data-change="del"] * { + text-decoration-line: line-through; +} +:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-diff="add"] { + background: color-mix(in srgb, var(--cr-diff-add) 14%, transparent); +} +:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-diff="del"] { + background: color-mix(in srgb, var(--cr-diff-del) 14%, transparent); +} +:is(.cr-prop-row, .cr-prop-item, .cr-table-row)[data-diff="del"] :not(.cr-diff-sign), +.cr-diff-gone, +.cr-diff-gone * { + text-decoration-line: line-through; +} +:is(.cr-prop-row, .cr-prop-item)[data-diff] { + position: relative; +} +:is(.cr-prop-row, .cr-prop-item)[data-diff]::before { + position: absolute; + top: 0; + left: -9px; + width: 8px; + font-family: var(--vscode-editor-font-family, monospace); + line-height: 20px; + text-align: center; +} +:is(.cr-prop-row, .cr-prop-item)[data-diff="add"]::before { + content: "+"; + color: var(--cr-diff-add); +} +:is(.cr-prop-row, .cr-prop-item)[data-diff="del"]::before { + content: "−"; + color: var(--cr-diff-del); +} +.cr-diff-sign { + display: inline-block; + width: 1.4ch; + white-space: pre; +} +.cr-diff-sign[data-change="add"] { color: var(--cr-diff-add); } +.cr-diff-sign[data-change="del"] { color: var(--cr-diff-del); } diff --git a/vscode/webview-ui/src/codeReader/markdown.test.ts b/vscode/webview-ui/src/codeReader/markdown.test.ts index a92ed79..8905042 100644 --- a/vscode/webview-ui/src/codeReader/markdown.test.ts +++ b/vscode/webview-ui/src/codeReader/markdown.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { renderMarkdown } from './markdown' +import { renderMarkdown, renderMarkdownDiff } from './markdown' describe('renderMarkdown', () => { it('一行文字是一个段落;段内换行是软换行,空行分段', () => { @@ -45,3 +45,19 @@ describe('renderMarkdown', () => { expect(renderMarkdown('[文档](https://example.com "")')).toBe('

文档

\n') }) }) + +describe('renderMarkdownDiff', () => { + it('照常排版,新加的字包进 、删掉的包进 ', () => { + expect(renderMarkdownDiff([{ text: '放锁' }, { text: '前', change: 'del' }, { text: '都', change: 'add' }, { text: '必须比对 `token`' }])) + .toBe('

放锁前都必须比对 token

\n') + }) + + it('跨行的一截逐行包,新加的列表项留住列表记号', () => { + expect(renderMarkdownDiff([{ text: '- a\n' }, { text: '- b\n- c', change: 'add' }])) + .toBe('
    \n
  • a
  • \n
  • b
  • \n
  • c
  • \n
\n') + }) + + it('行中间的一截不把前面的字当块记号', () => { + expect(renderMarkdownDiff([{ text: '共 ' }, { text: '# 3', change: 'add' }])).toBe('

共 # 3

\n') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/markdown.ts b/vscode/webview-ui/src/codeReader/markdown.ts index b37b80c..0ee7fcb 100644 --- a/vscode/webview-ui/src/codeReader/markdown.ts +++ b/vscode/webview-ui/src/codeReader/markdown.ts @@ -40,3 +40,37 @@ export function renderMarkdown(text: string): string { cache.delete(cache.keys().next().value!) return html } + +/** 渲染带增删的 Markdown 时,新加 / 删掉的字前后夹的记号(Unicode 私用区的字,正文里不会有),渲染完换成 / 。 */ +const MARKS = { + add: ['', '', '', ''], + del: ['', '', '', ''], +} as const + +/** 一行开头的块记号(列表、标题、引用):新加的一行要把它留在记号外面,不然就不认成列表、标题了。 */ +const BLOCK_LEAD = /^\s*(?:(?:[\-*+>]|\d+[.)]|#{1,6})\s+)*/ + +/** + * 带增删的 Markdown 说明 → HTML:照常排版,新加的字包进 、删掉的包进 (样式见 .cr-diff-word)。 + * 一截跨了几行时逐行包,记号不跨段落、列表项。 + */ +export function renderMarkdownDiff(parts: readonly { text: string, change?: 'add' | 'del' }[]): string { + let text = '' + for (const { text: piece, change } of parts) { + if (!change) { + text += piece + continue + } + const [open, close] = MARKS[change] + text += piece.split('\n').map((line, index) => { + const atLineStart = index > 0 || text === '' || text.endsWith('\n') + const lead = atLineStart ? BLOCK_LEAD.exec(line)?.[0] ?? '' : '' + const rest = line.slice(lead.length) + return rest ? `${lead}${open}${rest}${close}` : line + }).join('\n') + } + let html = md.render(text) + for (const [open, close, openTag, closeTag] of Object.values(MARKS)) + html = html.replaceAll(open, openTag).replaceAll(close, closeTag) + return html +}