352 lines
17 KiB
TypeScript
352 lines
17 KiB
TypeScript
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>
|
||
)
|
||
}
|