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

352 lines
17 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 { DiffTree, FoldedRuns, PlacedRemoved } from './diffView'
import type { GridSlots } from './PropertyGrid'
import type { QaState } from './qa'
import type { PendingImage } from './qaImages'
import type { RewriteRun } from './rewriteRun'
import type { BlockMarks, BlockView } from './types'
import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react'
import { Fragment, useState } from 'react'
import { AskThread, RewriteThread } 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'
import { asksOf, historyOf } from './qa'
import { TextPreview } from './TextPreview'
import { tableId } from './viewState'
/**
* 块树里改代码的那部分:哪些块在编辑、点「编辑」、按注释规范重写。页面上的块树才有;定义卡片浮窗里的块树没有,
* 卡片只读,不画右上角的按钮
*/
export interface EditTree {
version: number
editing: ReadonlySet<string>
/** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null
onFocused: () => void
onToggleEdit: (id: string) => void
/** 按注释规范重写的过程,按块的记号。只在这一页上 */
rewrites: ReadonlyMap<string, RewriteRun>
onRewriteComment: (anchor: string) => void
/** 叫停这块正在进行的重写,不写文件 */
onStopRewrite: (anchor: string) => void
}
export interface BlockTreeState {
/** 能编辑(页面上的块树);定义卡片浮窗里没有 */
edit?: EditTree
/** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */
collapsed: ReadonlySet<string>
/** 按筛选不画的块(连同子孙),见 applyFilter */
hidden: ReadonlySet<string>
/** 本来被筛掉、跳到定义时临时显示的块 */
temporary: ReadonlySet<string>
/** 被正在编辑的祖先盖住、不画的块(它们的代码在祖先的编辑框里),见 coveredByEditing */
covered: ReadonlyMap<string, string>
/** 种类叫什么(种类图标的悬停提示和 aria-label):翻译插件给种类起的名字(库规则的种类是规则给的 badge.label),没给就是 id */
kindName: (kind: string) => string
onToggleFold: (id: string) => void
/** 一次记下几块、几行折叠还是展开(点子行展开一块、同一行里别的收起) */
onSetFolds: (folds: Record<string, boolean>) => void
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
qa?: QaTree
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
diff?: DiffTree
/** 「只看改动」开着时折起来的连着没变的块(见 foldUnchanged);onOpenRun 点开一段(按第一块的 id) */
folded?: FoldedRuns
onOpenRun: (id: string) => void
}
/** 问 AI 的状态和动作,块按自己的记号(anchor)取 */
export interface QaTree {
state: 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
}
/**
* 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。
* 挂在卡片子行上的子块(BlockSlot)画在卡片属性表里,不算。
*/
function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean {
return block.children.some(child => !child.slot && 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>
)
}
/** 「只看改动」时连着没变的几块折成的一条「… N 块没变」,点开恢复这几块。 */
function UnchangedStrip({ count, tree, onOpen }: { count: number, tree: BlockTreeState, onOpen: () => void }): ReactElement {
return (
<ChangeRow tree={tree} 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, tree: BlockTreeState): boolean {
return !tree.hidden.has(block.id) && !tree.covered.has(block.id)
}
/**
* 块的种类色写在哪:内置种类按 data-kind 取 CSS 里的配色;库规则认领的块(带 badge)按规则给的颜色,
* 暗色、亮色两个都写在 style 上,CSS 按主题挑一个(见 [data-badge])。
*/
function kindProps(block: BlockView | 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 }
}
/**
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉、被盖住的块不画,不留空位,竖线跟着变短。
* owner 是这一层所属的块(顶层没有,记在 data-owner 上,钉住外层卡片时按它认哪张卡片的子块画出来了),悬停它时竖线换成它的种类色。
* 标差异时每张卡片左边外侧一条改动色条,删掉的块收成一行放回原来的位置(见 RemovedCard);「只看改动」时连着没变的块折成一条(UnchangedStrip)。
* slotted:这一层是画在卡片属性表某一行下面的子块(BlockSlot),blocks 只有那几块。
*/
export function BlockLevel({ blocks, owner, tree, slotted = false }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState, slotted?: boolean }): ReactElement {
// 挂在卡片子行上的子块画在卡片属性表里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层
const listed = slotted ? blocks : blocks.filter(block => !block.slot)
const removed = slotted ? [] : tree.diff?.removed.get(owner?.id ?? '') ?? []
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} tree={tree} />)
const removedElsewhere = removed
.filter(item => item.after !== undefined && !listedIds.has(item.after))
.map(item => <RemovedCard key={item.id} placed={item} tree={tree} />)
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}>
{tree.folded?.runs.has(block.id) && <UnchangedStrip count={tree.folded.runs.get(block.id) ?? 0} tree={tree} onOpen={() => tree.onOpenRun(block.id)} />}
{shown(block, tree) && !tree.folded?.folded.has(block.id) && (
<>
<ChangeRow tree={tree} change={tree.diff?.changes.get(block.id)?.change}>
<BlockCard block={block} tree={tree} />
</ChangeRow>
{tree.qa && <BlockQa key={block.anchor} anchor={block.anchor} qa={tree.qa} />}
{tree.edit && <BlockRewrite anchor={block.anchor} edit={tree.edit} />}
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
<BlockLevel blocks={block.children} owner={block} tree={tree} />
)}
</>
)}
{removedAfter(block.id)}
</Fragment>
))}
{removedElsewhere}
</div>
</div>
)
}
/** 块下面的问答区,紧跟在块后面、子块前面;输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */
function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement {
return (
<AskThread
history={historyOf(qa.state, anchor)}
asks={asksOf(qa.state, anchor)}
open={qa.state.open.includes(anchor)}
profiles={qa.state.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)}
/>
)
}
function BlockRewrite({ anchor, edit }: { anchor: string, edit: EditTree }): ReactElement | null {
const run = edit.rewrites.get(anchor)
if (!run)
return null
return <RewriteThread run={run} onStop={() => edit.onStopRewrite(anchor)} />
}
/**
* 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文;
* 译文下面依次接着摊开实参的表(跨几行写的调用)、说明。标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。
*/
function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTreeState, marks?: BlockMarks }): ReactElement {
if ('properties' in block)
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={cardSlots(block, tree)} />
return (
<>
{marks?.translation ? <DiffText diff={marks.translation} /> : <TextPreview text={block.translation} />}
{block.table && (
<div className="mt-[3px]">
<PropertyTable parentId={tableId(block.id)} columns={block.table.columns} items={block.table.items} marks={marks?.table} />
</div>
)}
{marks?.doc ? <DiffText diff={marks.doc} className="mt-0.5" /> : block.doc && <TextPreview text={block.doc} className="mt-0.5" />}
</>
)
}
/**
* 挂在卡片子行上的子块(BlockSlot)怎么画:一行下面展开着的几块画成一层块(同块树);点那个子行的第一格展开它、
* 同一行里别的收起,展开着的再点收起。卡片没有挂在子行上的子块时没有。
*/
function cardSlots(block: BlockView, tree: BlockTreeState): 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} tree={tree} slotted />,
onToggle: (child) => {
if (!tree.collapsed.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,只读的块树没有)。 */
function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
const { edit } = tree
const editing = edit?.editing.has(block.id) ?? false
const collapsed = tree.collapsed.has(block.id)
// 点父卡片子行才展开的子块:画出来就是展开着的,箭头收起整张
const toggled = block.slot !== undefined
const hasChildren = toggled || hasShownChildren(block, tree)
const Chevron = collapsed && !toggled ? ChevronRight : ChevronDown
const change = tree.diff?.changes.get(block.id)
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-line={block.startLine}
data-editing={editing ? '' : undefined}
data-change={change?.change}
>
{change?.outermost && <span className="cr-added-badge">新增</span>}
{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 && editing
? (
<CodeEditor
block={block}
version={edit.version}
autoFocus={edit.focusId === block.id}
onAutoFocused={edit.onFocused}
onExit={() => edit.onToggleEdit(block.id)}
/>
)
: <BlockPreview block={block} tree={tree} marks={tree.diff?.current ? change?.marks : undefined} />}
</div>
{tree.temporary.has(block.id) && (
<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={editing}
onToggleEdit={() => edit.onToggleEdit(block.id)}
asking={tree.qa?.state.open.includes(block.anchor)}
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
rewriting={edit.rewrites.get(block.anchor)?.status === 'running'}
onRewrite={block.commentStartLine !== undefined ? () => edit.onRewriteComment(block.anchor) : undefined}
/>
)}
</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 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">
{/* 旧块的 id 是旧版本里的位置,换成页面上认删掉的块用的 id,属性行的 id 才不和现在的块撞上 */}
<BlockPreview block={{ ...block, id: placed.id }} tree={tree} />
</div>
)}
</div>
</div>
</ChangeRow>
)
}