Files
superwork/vscode/webview-ui/src/codeReader/BlockTree.tsx
T

560 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CSSProperties, ReactElement, ReactNode } from 'react'
import type { RewriteTool } from './BlockTools'
import type { DiffTree, FoldedRuns, PlacedRemoved } from './diffView'
import type { GridSlots } from './PropertyGrid'
import type { ChatState, ChatView, QaState } from './qa'
import type { PendingImage } from './qaImages'
import type { ReadableStore } from './store'
import type { BlockMarks, BlockView, ChangedBlock } from './types'
import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react'
import { createContext, Fragment, memo, useContext, useMemo, useState } from 'react'
import { AskThread, REWRITE_THREAD } from './AskThread'
import { BlockTools } from './BlockTools'
import { CodeEditor } from './CodeEditor'
import { DiffText } from './DiffText'
import { removedTitle } from './diffView'
import { KindIcon } from './kindIcons'
import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid'
import { chatOf, EMPTY_CHAT, sameChat, unfinished } from './qa'
import { shallowEqual, staticStore, useStore } from './store'
import { TextPreview } from './TextPreview'
import { foldsByCard, listId, tableId } from './viewState'
/** 块树里改代码的那部分状态:哪些块在编辑。页面上的块树才有;定义卡片浮窗里的块树没有,卡片只读,不画右上角的按钮 */
export interface EditState {
version: number
editing: ReadonlySet<string>
/** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null
}
/** 函数、类落在几棵调用树里,开着哪一块的弹框。定义浮窗没有,跟问 AI 一样不画这排按钮。 */
export interface CallTreeState {
counts: ReadonlyMap<string, number>
openAnchor?: string
}
/**
* 块树要画的、会变的状态:一份不可变的快照,放在 BlockTreeHandle.state 里。每张卡片只订阅自己用到的那部分(useStore),
* 一处变了只重画受影响的卡片。
*/
export interface BlockTreeState {
/** 能编辑(页面上的块树);定义卡片浮窗里没有 */
edit?: EditState
/** 调用树。只读的树没有 */
callTree?: CallTreeState
/** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */
collapsed: ReadonlySet<string>
/** collapsed 按卡片分好的(见 foldsByCard):卡片和它的属性表只看自己那份 */
folds: ReadonlyMap<string, ReadonlySet<string>>
/** 按筛选不画的块(连同子孙),见 applyFilter */
hidden: ReadonlySet<string>
/** 本来被筛掉、跳到定义时临时显示的块 */
temporary: ReadonlySet<string>
/** 被正在编辑的祖先盖住、不画的块(它们的代码在祖先的编辑框里),见 coveredByEditing */
covered: ReadonlyMap<string, string>
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
diff?: DiffTree
/** 「只看改动」开着时折起来的连着没变的块(见 foldUnchanged) */
folded?: FoldedRuns
}
/**
* 一棵块树:会变的状态(state,卡片按自己那部分订阅)和动作。整页一份、不跟着状态变,卡片拿到的回调一直是同一个,
* 卡片照 memo 跳过重画。
*/
export interface BlockTreeHandle {
state: ReadableStore<BlockTreeState>
onToggleFold: (id: string) => void
/** 一次记下几块、几行折叠还是展开(点子行展开一块、同一行里别的收起) */
onSetFolds: (folds: Record<string, boolean>) => void
/** 「只看改动」时点开一段折起来的块(按第一块的 id) */
onOpenRun: (id: string) => void
/** 编辑:点「编辑 / 预览」、光标放进去之后。和 state.edit 同时有 */
edit?: { onToggleEdit: (id: string) => void, onFocused: () => void }
/** 打开一块的调用树弹框。和 state.callTree 同时有 */
onOpenCall?: (anchor: string) => void
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时 state 里 available 是 false */
qa?: QaTree
/** 按注释规范重写;同问 AI */
rewrite?: RewriteTree
}
/** 重写注释的状态(块按自己的记号订阅)和动作;profile 下拉是自己的,和问 AI 的互不影响 */
export interface RewriteTree {
store: ReadableStore<ChatState>
/** 点了块右上角的按钮:按技能命令重写这块(开新会话),展开它的重写记录 */
onRewrite: (anchor: string) => void
/** 在输入框里说的修改意见,接着这块最近一次重写的会话说;images 是贴的图片 */
onAsk: (anchor: string, request: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (anchor: string, entryId: string) => void
onOpen: (anchor: string) => void
onClose: (anchor: string) => void
/** 换重写注释用的 profile(所有块共用),`id` 同 [下拉项的 id](vscode/webview-ui/src/codeReader/types.ts#ProfileOption.id) */
onSelectProfile: (id: string) => void
}
/** 问 AI 的状态(块按自己的记号订阅)和动作 */
export interface QaTree {
store: ReadableStore<QaState>
onToggle: (anchor: string) => void
onClose: (anchor: string) => void
/** images 是贴进输入框的图片,随问题一起发 */
onAsk: (anchor: string, question: string, images: PendingImage[]) => void
onStop: (askId: string) => void
onDelete: (anchor: string, entryId: string) => void
/** 换「问 AI」用的 profile(所有块共用),id 同 ProfileOption.id */
onSelectProfile: (id: string) => void
}
const BlockTreeContext = createContext<BlockTreeHandle | null>(null)
function useBlockTree(): BlockTreeHandle {
const tree = useContext(BlockTreeContext)
if (!tree)
throw new Error('块树外面没有 BlockTree')
return tree
}
/** 一棵块树:blocks 是顶层的块,tree 是它的状态和动作(见 BlockTreeHandle)。 */
export function BlockTree({ blocks, tree }: { blocks: BlockView[], tree: BlockTreeHandle }): ReactElement {
return (
<BlockTreeContext value={tree}>
<BlockLevel blocks={blocks} />
</BlockTreeContext>
)
}
/** 块树的状态:folds 由 collapsed 按卡片分好(见 foldsByCard),和 previous 里一样的那几份沿用。 */
export function treeState(state: Omit<BlockTreeState, 'folds'>, previous?: BlockTreeState): BlockTreeState {
return { ...state, folds: foldsByCard(state.collapsed, previous?.folds) }
}
const NO_FOLDS: ReadonlySet<string> = new Set()
const NO_REMOVED: readonly PlacedRemoved[] = []
/** 没有问 AI、重写注释的块树(定义卡片浮窗):一直不能用 */
const NO_CHAT: ReadableStore<ChatState> = staticStore(EMPTY_CHAT)
/**
* 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。
* 挂在卡片子行上的子块(BlockSlot)画在卡片属性表里,不算。
*/
function hasShownChildren(block: BlockView, state: BlockTreeState): boolean {
return block.children.some(child => !child.slot && shown(child, state)) || (!state.covered.has(block.id) && state.diff?.removed.has(block.id) === true)
}
const CHANGE_NAMES = { add: '新增', mod: '修改', del: '删除' } as const
/** 标差异时卡片左边外侧的改动色条(没变的留一条透明的,同一层照样对齐);不标差异时原样。 */
function ChangeRow({ diff, change, children }: { diff: boolean, change: keyof typeof CHANGE_NAMES | undefined, children: ReactNode }): ReactElement {
if (!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>
)
}
/** 「只看改动」时连着没变的几块折成的一条「… N 块没变」,点开恢复这几块。 */
function UnchangedStrip({ count, onOpen }: { count: number, onOpen: () => void }): ReactElement {
return (
<ChangeRow diff change={undefined}>
<button type="button" className="cr-unchanged flex h-[22px] w-full items-center gap-1.5 rounded px-2.5 text-xs" onClick={onOpen}>
<ChevronsUpDown size={12} strokeWidth={1.8} className="shrink-0" aria-hidden />
{`… ${count} 块没变`}
<span className="cr-link">点开</span>
</button>
</ChangeRow>
)
}
/** 块画不画:被筛掉的、被正在编辑的祖先盖住的都不画。 */
function shown(block: BlockView, state: BlockTreeState): boolean {
return !state.hidden.has(block.id) && !state.covered.has(block.id)
}
/**
* 块的种类色写在哪:内置种类按 data-kind 取 CSS 里的配色;库规则认领的块(带 badge)按规则给的颜色,
* 暗色、亮色两个都写在 style 上,CSS 按主题挑一个(见 [data-badge])。
*/
export function kindProps(block: Pick<BlockView, 'kind' | 'badge'> | undefined): { 'data-kind'?: string, 'data-badge'?: '', 'style'?: CSSProperties } {
if (!block)
return {}
if (!block.badge)
return { 'data-kind': block.kind }
const { dark, light } = block.badge.color
return { 'data-kind': block.kind, 'data-badge': '', 'style': { '--kind-dark': dark, '--kind-light': light } as CSSProperties }
}
/** 一块画成什么样要的块树状态:只选这一块自己的,别的块变了它不重画。 */
interface BlockShape {
/** 画不画:被筛掉的、被正在编辑的祖先盖住的、「只看改动」折起来的不画 */
shown: boolean
/** 「只看改动」时从这块起折起来的一段有几块 */
run?: number
/** 子块那一层画不画 */
open: boolean
/** 标差异 */
diff: boolean
change?: ChangedBlock
/** 页面上正是差异算的那一版:卡片里的标记这时才画 */
current: boolean
editing: boolean
/** 这张卡片里折叠着的(见 foldsByCard) */
folds: ReadonlySet<string>
hasChildren: boolean
temporary: boolean
focus: boolean
/** 编辑框每来一份新文档都要对一次内容;不在编辑的卡片不跟着版本重画 */
version?: number
/** 调用树:扩展端标了能看调用树(BlockView.callTree)的块才有棵数 */
calls?: number
callOpen: boolean
}
function shapeOf(block: BlockView, state: BlockTreeState): BlockShape {
const editing = state.edit?.editing.has(block.id) ?? false
return {
shown: shown(block, state) && !state.folded?.folded.has(block.id),
run: state.folded?.runs.get(block.id),
open: hasShownChildren(block, state) && !state.collapsed.has(block.id),
diff: state.diff !== undefined,
change: state.diff?.changes.get(block.id),
current: state.diff?.current ?? false,
editing,
folds: state.folds.get(block.id) ?? NO_FOLDS,
// 点父卡片子行才展开的子块:画出来就是展开着的,箭头收起整张
hasChildren: block.slot !== undefined || hasShownChildren(block, state),
temporary: state.temporary.has(block.id),
focus: state.edit?.focusId === block.id,
version: editing ? state.edit?.version : undefined,
calls: state.callTree && block.callTree ? state.callTree.counts.get(block.anchor) ?? 0 : undefined,
callOpen: state.callTree?.openAnchor === block.anchor,
}
}
/** 一块的问 AI:能问(文件在工作区文件夹里)时按钮展开没有;对话区展开着或说过时才有,没展开又没说过的块不画。 */
interface AskShape {
asking?: boolean
chat?: ChatView
}
const NO_ASK: AskShape = {}
function askOf(anchor: string, state: ChatState): AskShape {
if (!state.available)
return NO_ASK
const chat = chatOf(state, anchor)
return { asking: chat.open, ...((chat.open || chat.history.length > 0) && { chat }) }
}
/**
* 一块的重写注释:能写注释的块才有按钮。重写过、记录收着时点了先展开记录(好接着说);展开着、或还没重写过时点了按技能命令重写。
* 展开着又正在写、排着队时不能再点。重写记录同问 AI 的对话区。
*/
interface RewriteShape {
tool?: Omit<RewriteTool, 'onRewrite'>
chat?: ChatView
}
const NO_REWRITE: RewriteShape = {}
function rewriteOf(block: BlockView, state: ChatState): RewriteShape {
if (!state.available)
return NO_REWRITE
const chat = chatOf(state, block.anchor)
return {
...(block.commentTarget && {
tool: {
fresh: block.commentTarget.kind === 'insert',
rewriting: chat.open && chat.asks.some(unfinished),
reopen: !chat.open && (chat.asks.length > 0 || chat.history.length > 0),
},
}),
...((chat.open || chat.history.length > 0) && { chat }),
}
}
function sameChatIn(a: ChatView | undefined, b: ChatView | undefined): boolean {
return a === b || (a !== undefined && b !== undefined && sameChat(a, b))
}
function sameAsk(a: AskShape, b: AskShape): boolean {
return a.asking === b.asking && sameChatIn(a.chat, b.chat)
}
function sameRewrite(a: RewriteShape, b: RewriteShape): boolean {
return shallowEqual(a.tool, b.tool) && sameChatIn(a.chat, b.chat)
}
/**
* 一块:「只看改动」折起来的一段、卡片、下面的问答区和重写记录、子块那一层。块树、问 AI、重写注释三份状态里只订阅这一块自己的,
* 都没变就不重画;子块那一层的各块、卡片的内容各自 memo,这一块重画了它们也不跟着重画。
*/
const BlockItem = memo(({ block }: { block: BlockView }): ReactElement => {
const tree = useBlockTree()
const { qa, rewrite } = tree
const shape = useStore(tree.state, state => shapeOf(block, state), shallowEqual)
const ask = useStore(qa?.store ?? NO_CHAT, state => askOf(block.anchor, state), sameAsk)
const rewriting = useStore(rewrite?.store ?? NO_CHAT, state => rewriteOf(block, state), sameRewrite)
const { anchor } = block
return (
<>
{shape.run !== undefined && <UnchangedStrip count={shape.run} onOpen={() => tree.onOpenRun(block.id)} />}
{shape.shown && (
<>
<ChangeRow diff={shape.diff} change={shape.change?.change}>
<BlockCard block={block} shape={shape} ask={ask} rewrite={rewriting} />
</ChangeRow>
{/* 输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */}
{qa && ask.chat && (
<AskThread
key={anchor}
history={ask.chat.history}
asks={ask.chat.asks}
open={ask.chat.open}
profiles={ask.chat.profiles}
onSelectProfile={qa.onSelectProfile}
onAsk={(question, images) => qa.onAsk(anchor, question, images)}
onStop={qa.onStop}
onDelete={entryId => qa.onDelete(anchor, entryId)}
onClose={() => qa.onClose(anchor)}
/>
)}
{/* 重写记录紧跟在问答区后面;样子和问答区一样,输入框里说的是对这段注释的修改意见 */}
{rewrite && rewriting.chat && (
<AskThread
key={`rewrite ${anchor}`}
wording={REWRITE_THREAD}
history={rewriting.chat.history}
asks={rewriting.chat.asks}
open={rewriting.chat.open}
profiles={rewriting.chat.profiles}
onSelectProfile={rewrite.onSelectProfile}
onAsk={(request, images) => rewrite.onAsk(anchor, request, images)}
onStop={rewrite.onStop}
onDelete={entryId => rewrite.onDelete(anchor, entryId)}
onClose={() => rewrite.onClose(anchor)}
/>
)}
{shape.open && <BlockLevel blocks={block.children} owner={block} />}
</>
)}
</>
)
})
BlockItem.displayName = 'BlockItem'
/**
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。
* owner 是这一层所属的块(顶层没有,记在 data-owner 上,钉住卡片时按它算卡片连同子块管到哪、套着几层),悬停它时竖线换成它的种类色。
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。
* slotted:这一层是画在卡片属性表某一行下面的子块(BlockSlot),blocks 只有那几块。
*/
export function BlockLevel({ blocks, owner, slotted = false }: { blocks: BlockView[], owner?: BlockView, slotted?: boolean }): ReactElement {
const tree = useBlockTree()
// 挂在卡片子行上的子块画在卡片属性表里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层
const listed = useMemo(() => slotted ? blocks : blocks.filter(block => !block.slot), [blocks, slotted])
const removed = useStore(tree.state, state => slotted ? NO_REMOVED : state.diff?.removed.get(owner?.id ?? '') ?? NO_REMOVED)
const listedIds = new Set(listed.map(block => block.id))
// 删掉的块排在它原来前面那块后面;那块被筛掉了也照排,不跟着消失;那块画在卡片里时排到这一层最后
const removedAfter = (id: string | undefined): ReactElement[] => removed
.filter(item => item.after === id)
.map(item => <RemovedCard key={item.id} placed={item} />)
const removedElsewhere = removed
.filter(item => item.after !== undefined && !listedIds.has(item.after))
.map(item => <RemovedCard key={item.id} placed={item} />)
return (
<div className="cr-level flex gap-2" {...kindProps(owner)} data-owner={owner?.id}>
<div className="flex w-3 shrink-0 justify-center">
<div className="cr-guide w-px" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{removedAfter(undefined)}
{listed.map(block => (
<Fragment key={block.id}>
<BlockItem block={block} />
{removedAfter(block.id)}
</Fragment>
))}
{removedElsewhere}
</div>
</div>
)
}
/**
* 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文;
* 译文下面依次接着拆开的各项(跨几行写的字面量,排法同变量初始值下面的)、摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。
* folds 是这张卡片里折叠着的(见 foldsByCard);标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。
*/
const BlockPreview = memo(({ block, folds, marks }: { block: BlockView, folds: ReadonlySet<string>, marks?: BlockMarks }): ReactElement => {
const tree = useBlockTree()
const slots = useMemo(() => cardSlots(block, folds, tree), [block, folds, tree])
if ('properties' in block)
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={slots} />
const folding = { collapsed: folds, onToggleFold: tree.onToggleFold }
return (
<>
{marks?.translation ? <DiffText diff={marks.translation} /> : <TextPreview text={block.translation} />}
{block.items && (
<div className="mt-[3px]">
<PropertyItems parentId={listId(block.id)} items={block.items} folding={folding} marks={marks?.items} />
</div>
)}
{block.table && (
<div className="mt-[3px]">
<PropertyTable parentId={tableId(block.id)} columns={block.table.columns} items={block.table.items} marks={marks?.table} folding={folding} />
</div>
)}
{marks?.doc ? <DiffText diff={marks.doc} className="mt-0.5" /> : block.doc && <TextPreview text={block.doc} className="mt-0.5" />}
{block.rows && (
<div className="mt-[3px]">
<PropertyGrid blockId={block.id} rows={block.rows} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} />
</div>
)}
</>
)
})
BlockPreview.displayName = 'BlockPreview'
/**
* 挂在卡片子行上的子块(BlockSlot)怎么画:一行下面展开着的几块画成一层块(同块树);点那个子行的第一格展开它、
* 同一行里别的收起,展开着的再点收起。卡片没有挂在子行上的子块时没有。
*/
function cardSlots(block: BlockView, folds: ReadonlySet<string>, tree: BlockTreeHandle): GridSlots | undefined {
const blocks = block.children.filter(child => child.slot)
if (blocks.length === 0)
return undefined
return {
blocks,
render: list => <BlockLevel blocks={list} owner={block} slotted />,
onToggle: (child) => {
if (!folds.has(child.id)) {
tree.onSetFolds({ [child.id]: true })
return
}
const others = blocks.filter(other => other !== child && other.slot?.row === child.slot?.row)
tree.onSetFolds({ ...Object.fromEntries(others.map(other => [other.id, true])), [child.id]: false })
},
}
}
/**
* 一块的卡片:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools,只读的块树没有)。
* 卡片上记着块记号(data-anchor),说明里不带路径的引用靠它认自己写在哪块(见 commentLinkOf)。
*/
function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: BlockShape, ask: AskShape, rewrite: RewriteShape }): ReactElement {
const tree = useBlockTree()
const { edit, onOpenCall } = tree
const { anchor } = block
const collapsed = shape.folds.has(block.id)
const toggled = block.slot !== undefined
const Chevron = collapsed && !toggled ? ChevronRight : ChevronDown
const { change } = shape
const { tool } = rewrite
return (
<div
className="cr-block relative flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2"
{...kindProps(block)}
data-id={block.id}
data-anchor={anchor}
data-line={block.startLine}
data-editing={shape.editing ? '' : undefined}
data-change={change?.change}
>
{change?.outermost && <span className="cr-added-badge">新增</span>}
{shape.hasChildren
? (
<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={toggled ? '收起' : collapsed ? '展开子块' : '折叠子块'}
aria-expanded={toggled || !collapsed}
onClick={() => tree.onToggleFold(block.id)}
>
<Chevron size={14} strokeWidth={1.8} />
</button>
)
: <span className="w-3.5 shrink-0" />}
<KindIcon concepts={block.concepts} />
<div className="min-w-0 flex-1">
{edit && shape.version !== undefined
? (
<CodeEditor
block={block}
version={shape.version}
autoFocus={shape.focus}
onAutoFocused={edit.onFocused}
onExit={() => edit.onToggleEdit(block.id)}
/>
)
: <BlockPreview block={block} folds={shape.folds} marks={shape.current ? change?.marks : undefined} />}
</div>
{shape.temporary && (
<span className="cr-temporary flex h-5 shrink-0 items-center gap-1 rounded px-1.5 text-[11px] whitespace-nowrap">
<EyeOff size={12} strokeWidth={1.8} aria-hidden />
已被筛选隐藏,临时显示
</span>
)}
{edit && (
<BlockTools
editing={shape.editing}
onToggleEdit={() => edit.onToggleEdit(block.id)}
asking={ask.asking}
onToggleAsk={tree.qa && ask.asking !== undefined ? () => tree.qa?.onToggle(anchor) : undefined}
rewrite={tree.rewrite && tool && { ...tool, onRewrite: () => tool.reopen ? tree.rewrite?.onOpen(anchor) : tree.rewrite?.onRewrite(anchor) }}
callTree={onOpenCall && shape.calls !== undefined ? { anchor, count: shape.calls, open: shape.callOpen, onOpen: () => onOpenCall(anchor) } : undefined}
/>
)}
</div>
)
}
/**
* 删掉的一块,放在原来的位置:红色虚线边、半透明,没有编辑、问 AI 按钮。默认收成一行「已删除 · 种类 名字」,
* 点箭头或这一行展开,下面是旧内容(整张划掉,名字不能跳定义);子块不画。
*/
function RemovedCard({ placed }: { placed: PlacedRemoved }): ReactElement {
const tree = useBlockTree()
const [open, setOpen] = useState(false)
const folds = useStore(tree.state, state => state.folds.get(placed.id) ?? NO_FOLDS)
const { block, kindName } = placed.removed
const title = removedTitle(block)
const Chevron = open ? ChevronDown : ChevronRight
const toggle = (): void => setOpen(value => !value)
// 旧块的 id 是旧版本里的位置,换成页面上认删掉的块用的 id,属性行的 id 才不和现在的块撞上
const old = useMemo(() => ({ ...block, id: placed.id }), [block, placed.id])
return (
<ChangeRow diff 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 concepts={block.concepts} />
<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">
<BlockPreview block={old} folds={folds} />
</div>
)}
</div>
</div>
</ChangeRow>
)
}