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 /** 刚点了「编辑」、要把光标放进去的块 */ focusId: string | null } /** 函数、类落在几棵调用树里,开着哪一块的弹框。定义浮窗没有,跟问 AI 一样不画这排按钮。 */ export interface CallTreeState { counts: ReadonlyMap openAnchor?: string } /** * 块树要画的、会变的状态:一份不可变的快照,放在 BlockTreeHandle.state 里。每张卡片只订阅自己用到的那部分(useStore), * 一处变了只重画受影响的卡片。 */ export interface BlockTreeState { /** 能编辑(页面上的块树);定义卡片浮窗里没有 */ edit?: EditState /** 调用树。只读的树没有 */ callTree?: CallTreeState /** 眼下折叠着的块 id、属性行 id 和子行 id(propertyRowId、propertyItemId):用户的选择叠在默认上,见 collapsedIds */ collapsed: ReadonlySet /** collapsed 按卡片分好的(见 foldsByCard):卡片和它的属性表只看自己那份 */ folds: ReadonlyMap> /** 按筛选不画的块(连同子孙),见 applyFilter */ hidden: ReadonlySet /** 本来被筛掉、跳到定义时临时显示的块 */ temporary: ReadonlySet /** 被正在编辑的祖先盖住、不画的块(它们的代码在祖先的编辑框里),见 coveredByEditing */ covered: ReadonlyMap /** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */ diff?: DiffTree /** 「只看改动」开着时折起来的连着没变的块(见 foldUnchanged) */ folded?: FoldedRuns } /** * 一棵块树:会变的状态(state,卡片按自己那部分订阅)和动作。整页一份、不跟着状态变,卡片拿到的回调一直是同一个, * 卡片照 memo 跳过重画。 */ export interface BlockTreeHandle { state: ReadableStore onToggleFold: (id: string) => void /** 一次记下几块、几行折叠还是展开(点子行展开一块、同一行里别的收起) */ onSetFolds: (folds: Record) => 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 /** 点了块右上角的按钮:按技能命令重写这块(开新会话),展开它的重写记录 */ 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 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(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 ( ) } /** 块树的状态:folds 由 collapsed 按卡片分好(见 foldsByCard),和 previous 里一样的那几份沿用。 */ export function treeState(state: Omit, previous?: BlockTreeState): BlockTreeState { return { ...state, folds: foldsByCard(state.collapsed, previous?.folds) } } const NO_FOLDS: ReadonlySet = new Set() const NO_REMOVED: readonly PlacedRemoved[] = [] /** 没有问 AI、重写注释的块树(定义卡片浮窗):一直不能用 */ const NO_CHAT: ReadableStore = 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 (
{children}
) } /** 「只看改动」时连着没变的几块折成的一条「… N 块没变」,点开恢复这几块。 */ function UnchangedStrip({ count, onOpen }: { count: number, onOpen: () => void }): ReactElement { return ( ) } /** 块画不画:被筛掉的、被正在编辑的祖先盖住的都不画。 */ 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 | 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 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 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 && tree.onOpenRun(block.id)} />} {shape.shown && ( <> {/* 输入框、展开状态跟着块的记号走,文档刷新块 id 变了也不丢 */} {qa && ask.chat && ( qa.onAsk(anchor, question, images)} onStop={qa.onStop} onDelete={entryId => qa.onDelete(anchor, entryId)} onClose={() => qa.onClose(anchor)} /> )} {/* 重写记录紧跟在问答区后面;样子和问答区一样,输入框里说的是对这段注释的修改意见 */} {rewrite && rewriting.chat && ( rewrite.onAsk(anchor, request, images)} onStop={rewrite.onStop} onDelete={entryId => rewrite.onDelete(anchor, entryId)} onClose={() => rewrite.onClose(anchor)} /> )} {shape.open && } )} ) }) 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 => ) const removedElsewhere = removed .filter(item => item.after !== undefined && !listedIds.has(item.after)) .map(item => ) return (
{removedAfter(undefined)} {listed.map(block => ( {removedAfter(block.id)} ))} {removedElsewhere}
) } /** * 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文; * 译文下面依次接着拆开的各项(跨几行写的字面量,排法同变量初始值下面的)、摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。 * folds 是这张卡片里折叠着的(见 foldsByCard);标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。 */ const BlockPreview = memo(({ block, folds, marks }: { block: BlockView, folds: ReadonlySet, marks?: BlockMarks }): ReactElement => { const tree = useBlockTree() const slots = useMemo(() => cardSlots(block, folds, tree), [block, folds, tree]) if ('properties' in block) return const folding = { collapsed: folds, onToggleFold: tree.onToggleFold } return ( <> {marks?.translation ? : } {block.items && (
)} {block.table && (
)} {marks?.doc ? : block.doc && } {block.rows && (
)} ) }) BlockPreview.displayName = 'BlockPreview' /** * 挂在卡片子行上的子块(BlockSlot)怎么画:一行下面展开着的几块画成一层块(同块树);点那个子行的第一格展开它、 * 同一行里别的收起,展开着的再点收起。卡片没有挂在子行上的子块时没有。 */ function cardSlots(block: BlockView, folds: ReadonlySet, tree: BlockTreeHandle): GridSlots | undefined { const blocks = block.children.filter(child => child.slot) if (blocks.length === 0) return undefined return { blocks, render: list => , 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 (
{change?.outermost && 新增} {shape.hasChildren ? ( ) : }
{edit && shape.version !== undefined ? ( edit.onToggleEdit(block.id)} /> ) : }
{shape.temporary && ( 已被筛选隐藏,临时显示 )} {edit && ( 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} /> )}
) } /** * 删掉的一块,放在原来的位置:红色虚线边、半透明,没有编辑、问 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 (
已删除 · {kindName} {title && {` ${title}`}}
{open && (
)}
) }