560 lines
26 KiB
TypeScript
560 lines
26 KiB
TypeScript
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>
|
||
)
|
||
}
|