⚡ perf(vscode): 代码阅读预览按卡片订阅状态:中文名、调用树棵数、单击高亮、问答一条消息只重画受影响的卡片和文字,新文档里内容没变的块沿用原来的对象不重画,没展开又没说过的块不画问答区

This commit is contained in:
cruldra committed 2026-10-13 14:40:53 +11:00
1 parent 6e2c541558
commit 0ad4748cc6
27 files changed
+1359 -620

No files matched your search

@@ -9,6 +9,58 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { App } from './App'
import { createViewStateStore } from './viewState'
/**
* 一次次提交里哪些组件真的画了(新挂上的不算):赶在 react-dom 加载之前挂上 React 开发者工具的钩子,每次提交顺着 fiber 树数。
* 跳过重画的整棵子树(fiber 和上一次的共用子节点)不往下数。
*/
const renders = vi.hoisted(() => {
interface Fiber { type: unknown, elementType: unknown, alternate: Fiber | null, flags: number, child: Fiber | null, sibling: Fiber | null }
const counts = new Map<string, number>()
let counting = false
/** 组件叫什么:memo 包着的看它的 displayName,别的看函数名 */
const nameOf = (fiber: Fiber): string | undefined => {
const memoName = (fiber.elementType as { displayName?: string } | null)?.displayName
return memoName ?? (typeof fiber.type === 'function' ? fiber.type.name : undefined)
}
const walk = (root: { current: Fiber }): void => {
const stack = [root.current]
while (stack.length > 0) {
const fiber = stack.pop()!
const name = nameOf(fiber)
// flags 的第 1 位(PerformedWork):这次真的调用了组件函数
if (name && fiber.alternate && (fiber.flags & 1))
counts.set(name, (counts.get(name) ?? 0) + 1)
if (fiber.alternate && fiber.child === fiber.alternate.child)
continue
for (let child = fiber.child; child; child = child.sibling)
stack.push(child)
}
}
Object.assign(globalThis, {
__REACT_DEVTOOLS_GLOBAL_HOOK__: {
supportsFiber: true,
isDisabled: false,
renderers: new Map(),
inject: () => 1,
checkDCE: () => {},
onScheduleFiberRoot: () => {},
onCommitFiberRoot: (_id: number, root: { current: Fiber }) => counting && walk(root),
onCommitFiberUnmount: () => {},
onPostCommitFiberRoot: () => {},
},
})
return {
start: () => {
counts.clear()
counting = true
},
stop: (): Record<string, number> => {
counting = false
return Object.fromEntries(counts)
},
}
})
/** 扩展端那头:页面发出去的消息、页面挂着的收消息回调,以及 webview 自己存的状态(页面重新加载时还在) */
const host = vi.hoisted(() => ({
posted: [] as unknown[],
@@ -171,3 +223,70 @@ describe('代码配色跟着当前颜色主题', () => {
expect(style.getPropertyValue('--cr-kind-loop')).toBe('#AF00DB')
})
})
describe('一条消息只重画受影响的卡片', () => {
/** 三张卡片,译文里各有一个带下标的名字(下标 10、20、30) */
function named(id: string, name: string, offset: number, text = '调用'): BlockView {
return { ...block(id, name), translation: [{ text, role: 'keyword' }, { text: name, role: 'function', offset }] }
}
const DOC = documentOf(3, [named('0', 'load', 10), named('1', 'save', 20), named('2', 'drop', 30)])
/** 扩展端每次发来的都是新对象:深拷一份 */
const copy = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
async function rendered(msg: ExtensionToWebview): Promise<Record<string, number>> {
renders.start()
await deliver(msg)
return renders.stop()
}
it('中文名到了:只重画换了字的那段文字,卡片不动', async () => {
await load()
await deliver(copy(DOC))
const counts = await rendered({ type: 'displayNames', version: 3, names: [{ offset: 20, length: 4, name: '保存' }] })
expect(counts).toEqual({ SegmentText: 1 })
expect(container.querySelector('[data-offset="20"]')?.textContent).toBe('保存')
})
it('调用树棵数到了:只重画棵数变了的那几块', async () => {
await load()
await deliver(copy(DOC))
const counts = await rendered({ type: 'callTreeCounts', version: 3, counts: { save: 2 } })
expect(counts.BlockItem).toBe(1)
expect(counts.SegmentText).toBeUndefined()
expect(container.querySelector('[data-call-tree="save"]')?.textContent).toBe('2')
})
it('同一版文档重发、只改了一块的新版本:内容没变的卡片不重画', async () => {
await load()
await deliver(copy(DOC))
expect(await rendered(copy(DOC))).not.toHaveProperty('BlockItem')
const edited = documentOf(4, [named('0', 'load', 10), named('1', 'save', 20, '调用了'), named('2', 'drop', 30)])
const counts = await rendered(copy(edited))
expect(counts.BlockItem).toBe(1)
expect(container.querySelector('[data-id="1"]')?.textContent).toContain('调用了')
})
it('单击名字高亮:只重画高亮落到的那段文字', async () => {
await load()
await deliver(copy(DOC))
await act(async () => container.querySelector<HTMLElement>('[data-offset="30"]')!.click())
const asked = [...host.posted].reverse().find((msg): msg is Extract<WebviewToExtension, { type: 'findOccurrences' }> => (msg as WebviewToExtension).type === 'findOccurrences')!
const counts = await rendered({ type: 'occurrencesFound', id: asked.id, occurrences: [{ start: 30, end: 34, kind: 'read' }] })
expect(counts.SegmentText).toBe(1)
expect(counts.BlockItem).toBeUndefined()
expect(container.querySelector('[data-offset="30"]')?.getAttribute('data-occurrence')).toBe('normal')
})
it('没展开、没说过的块下面不画问答区;展开一块只重画那一块', async () => {
await load()
await deliver({ type: 'qa', available: true, threads: [], path: [], subject: 'file', current: [], rewrites: [], rewriteCurrent: [] })
await deliver(copy(DOC))
// 顶部那条一直摊开
expect(container.querySelectorAll('.cr-qa')).toHaveLength(1)
renders.start()
await act(async () => container.querySelector<HTMLButtonElement>('[data-id="1"] [aria-label="问 AI"]')!.click())
const counts = renders.stop()
expect(counts.BlockItem).toBe(1)
expect(container.querySelectorAll('.cr-qa')).toHaveLength(2)
})
})
+172 -234
View File
@@ -1,29 +1,32 @@
import type { ReactElement } from 'react'
import type { BlockTreeState } from './BlockTree'
import type { NameMap, VersionNames } from './displayNames'
import type { BlockView, CallTree, ContractMiss, DocumentDiff, DocumentMessage, Excluded, KindBadge, Reveal } from './types'
import type { BlockTreeHandle } from './BlockTree'
import type { OpenCall } from './pageState'
import type { TextMarks } from './SegmentText'
import type { BlockView, DocumentMessage } from './types'
import type { OccurrencePage } from './useOccurrences'
import type { ViewState } from './viewState'
import { TriangleAlert } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AskThread, FEATURE_THREAD } from './AskThread'
import { BlockLevel } from './BlockTree'
import { BlockTree } from './BlockTree'
import { CallTreeDialog } from './CallTreeDialog'
import { applyCodeColors } from './codeColors'
import { DefinitionPeekPopup } from './DefinitionPeekPopup'
import { DiffBar, NewFileBanner } from './DiffBar'
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
import { acceptNames, DisplayNameContext, documentNames } from './displayNames'
import { coveredByEditing, startEditing } from './editing'
import { applyFilter, excludedBy, keptWhileEditing } from './filter'
import { nameMap } from './displayNames'
import { startEditing } from './editing'
import { keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar'
import { OverviewRuler } from './OverviewRuler'
import { asksOf, historyOf, PATH_ASK_ANCHOR } from './qa'
import { createPageStore } from './pageState'
import { chatOf, PATH_ASK_ANCHOR, sameChat } from './qa'
import { TextMarksContext } from './SegmentText'
import { shareUnchanged } from './sharing'
import { StickyLines } from './StickyLines'
import { createStore, useStore } from './store'
import { useChangeNav } from './useChangeNav'
import { useDefinitionLinks } from './useDefinitionLinks'
import { useDefinitionPeek } from './useDefinitionPeek'
import { HighlightContext, useOccurrences } from './useOccurrences'
import { useOccurrences } from './useOccurrences'
import { useQa } from './useQa'
import { useRepoImages } from './useRepoImages'
import { useRepoLinks } from './useRepoLinks'
@@ -31,32 +34,9 @@ import { useReveal } from './useReveal'
import { useRewrite } from './useRewrite'
import { useScrollPosition } from './useScrollPosition'
import { useStickySettings } from './useStickyScroll'
import { collapsedIds } from './viewState'
import { onMessage, postMessage, viewState } from './vscode'
type BlockModes = Pick<ViewState, 'editing' | 'folds'>
const NONE: ReadonlySet<string> = new Set()
const NO_NAMES: NameMap = new Map()
function savedBlockModes(): BlockModes {
const { editing, folds } = viewState.get()
return { editing, folds }
}
/** 开着的调用树弹框:哪一版文档的哪一块、选中第几个入口,扩展端发来的树 */
interface OpenCall {
anchor: string
version: number
entry: number
status: 'loading' | 'ready'
name: string
where: string
kind: string
badge?: KindBadge
trees: CallTree[]
contractMiss?: ContractMiss
}
/** 打开 doc 里 anchor 那块的调用树弹框,先是「正在查」,等扩展端发树来;这块不在了、或者它看不了调用树时是 undefined。 */
function openingCall(doc: DocumentMessage, anchor: string, entry: number): OpenCall | undefined {
@@ -77,6 +57,10 @@ function blockAt(blocks: readonly BlockView[], anchor: string): BlockView | unde
return undefined
}
/**
* 代码阅读预览页。页面状态放在一份 store 里(createPageStore),扩展端的消息、用户的点击都改它;
* 块树的卡片各自订阅自己那部分(见 BlockTree),一条消息只重画受影响的卡片;这一层重画时,块树顶层各块照 memo 跳过。
*/
export function App(): ReactElement | null {
// 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答
const qa = useQa()
@@ -84,54 +68,25 @@ export function App(): ReactElement | null {
const [pathFocus, setPathFocus] = useState(0)
/** 按注释规范重写 */
const rewrite = useRewrite()
const [doc, setDoc] = useState<DocumentMessage | null>(null)
const [view, setView] = useState<BlockModes>(savedBlockModes)
const [focusId, setFocusId] = useState<string | null>(null)
/** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */
const [reveal, setReveal] = useState<Reveal | null>(null)
/** 扩展端发来的块筛选(整个工作区共用);本地不改,改了发给扩展端、等它发回来 */
const [excluded, setExcluded] = useState<Excluded>({})
/** 跳到定义时临时露出来的块(目标和它的各层祖先),筛选一变、文档一刷新就回到筛选状态 */
const [keep, setKeep] = useState<ReadonlySet<string>>(NONE)
/** 页面上这个文档版本的中文名:文档带来的,扩展端查到了再换 */
const [names, setNames] = useState<VersionNames | undefined>()
/** 这份文件相比对比基准改了什么:审查工作副本里的、从「未提交的更改」点开的有改动的文件扩展端算好了补来,不比时是 null */
const [diff, setDiff] = useState<DocumentDiff | null>(null)
/** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */
const editing = useRef(view.editing)
/** 页面上这份文档的版本:调用树的回答不是这一版就丢掉 */
const docVersion = useRef<number | undefined>(undefined)
const [callCounts, setCallCounts] = useState<{ version: number, counts: Record<string, number> }>()
const [openCall, setOpenCall] = useState<OpenCall | null>(null)
const [page] = useState(() => createPageStore(viewState.get()))
/** 页面上文字的标记:这个文档版本的中文名(文档带来的,扩展端查到了再换)和单击高亮的名字;各段文字按自己那几段订阅 */
const [marks] = useState(() => createStore<TextMarks>({ names: new Map() }))
/** 页面重新加载前开着的调用树弹框:第一份文档到了,那块还在就重新打开;到之前记着的不动 */
const restoreCall = useRef(viewState.get().callTree)
/** 块树所在的页面内容:钉住卡片时在这里面量 */
const content = useRef<HTMLDivElement>(null)
// 解析失败的文档没有块,不标差异;删掉的块按块筛选同样藏
const changes = useMemo(() => doc && doc.error === undefined
? diffTree(doc.blocks, diff, doc.version, removed => !excludedBy(removed.block, excluded))
: undefined, [doc, diff, excluded])
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds, openedByDiff(changes)), [doc, view.folds, changes])
const filter = useMemo(() => applyFilter(doc?.blocks ?? [], excluded, keep), [doc, excluded, keep])
const covered = useMemo(() => coveredByEditing(doc?.blocks ?? [], new Set(view.editing)), [doc, view.editing])
/** 「只看改动」开着(整个工作区共用,扩展端记着);本地不改,改了发给扩展端、等它发回来 */
const [onlyChanges, setOnlyChanges] = useState(false)
/** 「只看改动」时点开过的几段没变的块(按第一块的 id),筛选一变就重新折 */
const [openedRuns, setOpenedRuns] = useState<ReadonlySet<string>>(NONE)
const folded = useMemo(() => doc && changes && onlyChanges
? foldUnchanged(doc.blocks, changes, block => !filter.hidden.has(block.id) && !covered.has(block.id), openedRuns, keep)
: undefined, [doc, changes, onlyChanges, filter, covered, openedRuns, keep])
/** 页面上没画出来的块:被筛掉的,和「只看改动」折起来的 */
const unshown = useMemo(() => folded ? new Set([...filter.hidden, ...folded.folded]) : filter.hidden, [filter, folded])
const stops = useMemo(() => doc && changes ? changeStops(doc.blocks, changes, filter.hidden) : [], [doc, changes, filter])
const { doc, editing, folds, reveal, excluded, diff, onlyChanges, openCall, changes, collapsed, filter, covered, unshown, stops } = useStore(page, current => current)
const pathChat = useStore(qa.store, state => chatOf(state, PATH_ASK_ANCHOR), sameChat)
const qaAvailable = useStore(qa.store, state => state.available)
const qaSubject = useStore(qa.store, state => state.subject)
/** 记下这几块、属性行、子行折叠还是展开,从此不再按默认 */
const setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({
...prev,
folds: { ...prev.folds, ...Object.fromEntries(ids.map(id => [id, folded])) },
})), [])
const setFolded = useCallback((ids: string[], folded: boolean) => page.update(current => ({
folds: { ...current.folds, ...Object.fromEntries(ids.map(id => [id, folded])) },
})), [page])
const expand = useCallback((ids: string[]) => setFolded(ids, false), [setFolded])
const show = useCallback((ids: string[]) => setKeep(prev => new Set([...prev, ...ids])), [])
const revealed = useCallback(() => setReveal(null), [])
const show = useCallback((ids: string[]) => page.update(current => ({ keep: new Set([...current.keep, ...ids]) })), [page])
const revealed = useCallback(() => page.update({ reveal: null }), [page])
useScrollPosition(doc)
useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed)
useDefinitionLinks(doc?.version)
@@ -143,12 +98,40 @@ export function App(): ReactElement | null {
const occurrencePage = useMemo((): OccurrencePage | undefined => doc
? {
blocks: doc.blocks,
skip: id => unshown.has(id) || covered.has(id) || view.editing.includes(id),
skip: id => unshown.has(id) || covered.has(id) || editing.includes(id),
collapsed,
expand,
}
: undefined, [doc, unshown, covered, view.editing, collapsed, expand])
const { highlight, moveOccurrence } = useOccurrences(doc?.version, doc?.translatorId, occurrencePage)
: undefined, [doc, unshown, covered, editing, collapsed, expand])
const occurrences = useOccurrences(doc?.version, doc?.translatorId, occurrencePage, marks)
/** 块树:状态是页面 store 里的 tree,动作改页面 store;整页一份,卡片拿到的回调一直是同一个 */
const [tree] = useState((): BlockTreeHandle => ({
state: { get: () => page.get().tree, subscribe: page.subscribe },
onToggleFold: id => page.update(current => ({ folds: { ...current.folds, [id]: !current.collapsed.has(id) } })),
onSetFolds: next => page.update(current => ({ folds: { ...current.folds, ...next } })),
onOpenRun: id => page.update(current => ({ openedRuns: new Set([...current.openedRuns, id]) })),
edit: {
onToggleEdit: id => page.update((current) => {
const leaving = current.editing.includes(id)
return {
focusId: leaving ? null : id,
editing: leaving ? current.editing.filter(other => other !== id) : startEditing(current.doc?.blocks ?? [], current.editing, id),
}
}),
onFocused: () => page.update({ focusId: null }),
},
onOpenCall: (anchor) => {
const current = page.get().doc
const opening = current && openingCall(current, anchor, 0)
if (!opening)
return
page.update({ openCall: opening })
postMessage({ type: 'openCallTree', version: opening.version, anchor })
},
qa,
rewrite,
}))
useEffect(() => {
const off = onMessage((msg) => {
@@ -156,52 +139,53 @@ export function App(): ReactElement | null {
case 'document': {
const restoring = restoreCall.current
restoreCall.current = undefined
const reopened = restoring && openingCall(msg, restoring.anchor, restoring.entry)
docVersion.current = msg.version
setDoc(msg)
setNames(documentNames(msg.version, msg.names))
setKeep(prev => keptWhileEditing(prev, editing.current))
setCallCounts(prev => prev?.version === msg.version ? prev : undefined)
setOpenCall(prev => prev?.version === msg.version ? prev : reopened ?? null)
// 和上一份内容一样的块还是原来的对象:没变的卡片不重画
const next = shareUnchanged(page.get().doc, msg)
const reopened = restoring && openingCall(next, restoring.anchor, restoring.entry)
marks.set(current => ({ ...current, names: nameMap(msg.names) }))
page.update(current => ({
doc: next,
keep: keptWhileEditing(current.keep, current.editing),
callCounts: current.callCounts?.version === msg.version ? current.callCounts : undefined,
openCall: current.openCall?.version === msg.version ? current.openCall : reopened ?? null,
...(msg.reveal && { reveal: msg.reveal }),
}))
if (reopened)
postMessage({ type: 'openCallTree', version: msg.version, anchor: reopened.anchor })
else if (restoring)
viewState.update({ callTree: undefined })
if (msg.reveal)
setReveal(msg.reveal)
return
}
case 'callTreeCounts':
if (msg.version !== docVersion.current)
if (msg.version !== page.get().doc?.version)
return
setCallCounts({ version: msg.version, counts: msg.counts })
page.update({ callCounts: { version: msg.version, counts: msg.counts } })
return
case 'callTree':
if (msg.version !== docVersion.current)
if (msg.version !== page.get().doc?.version)
return
setOpenCall((prev) => {
if (!prev || prev.anchor !== msg.anchor || prev.version !== msg.version)
return prev
return { ...prev, status: 'ready', name: msg.name, where: msg.where, trees: msg.trees, contractMiss: msg.contractMiss }
})
page.update(({ openCall: prev }) => ({
openCall: !prev || prev.anchor !== msg.anchor || prev.version !== msg.version
? prev
: { ...prev, status: 'ready', name: msg.name, where: msg.where, trees: msg.trees, contractMiss: msg.contractMiss },
}))
return
case 'reveal':
setReveal({ line: msg.line, definition: msg.definition })
page.update({ reveal: { line: msg.line, definition: msg.definition } })
return
case 'displayNames':
setNames(prev => acceptNames(prev, msg.version, msg.names))
// 查到的是页面上这个版本的才换上,迟到的旧版本的不理
if (msg.version === page.get().doc?.version)
marks.set(current => ({ ...current, names: nameMap(msg.names) }))
return
case 'filter':
setExcluded(msg.excluded)
setOnlyChanges(msg.onlyChanges)
setKeep(NONE)
setOpenedRuns(NONE)
page.update({ excluded: msg.excluded, onlyChanges: msg.onlyChanges, keep: NONE, openedRuns: NONE })
return
case 'codeColors':
applyCodeColors(document.body.style, msg)
return
case 'diff':
setDiff(msg.diff)
page.update(current => ({ diff: shareUnchanged(current.diff, msg.diff) }))
return
case 'openedFrom':
viewState.update({ openedFrom: msg.from })
@@ -214,12 +198,11 @@ export function App(): ReactElement | null {
const { openedFrom } = viewState.get()
postMessage({ type: 'ready', ...(openedFrom && { openedFrom }) })
return off
}, [])
}, [page, marks])
useEffect(() => {
viewState.update(view)
editing.current = view.editing
}, [view])
viewState.update({ editing, folds })
}, [editing, folds])
useEffect(() => {
if (restoreCall.current)
@@ -227,137 +210,92 @@ export function App(): ReactElement | null {
viewState.update({ callTree: openCall ? { anchor: openCall.anchor, entry: openCall.entry } : undefined })
}, [openCall])
const onFocused = useCallback(() => setFocusId(null), [])
const onOpenCall = useCallback((anchor: string) => {
const opening = doc && openingCall(doc, anchor, 0)
if (!opening)
return
setOpenCall(opening)
postMessage({ type: 'openCallTree', version: opening.version, anchor })
}, [doc])
const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc])
const kindName = useCallback((kind: string) => kindNames.get(kind) ?? kind, [kindNames])
const tree = useMemo((): BlockTreeState | null => doc && {
edit: {
version: doc.version,
editing: new Set(view.editing),
focusId,
onFocused,
onToggleEdit: (id) => {
setFocusId(view.editing.includes(id) ? null : id)
setView(prev => ({
...prev,
editing: prev.editing.includes(id) ? prev.editing.filter(other => other !== id) : startEditing(doc.blocks, prev.editing, id),
}))
},
},
collapsed,
hidden: filter.hidden,
temporary: filter.temporary,
covered,
kindName,
onToggleFold: id => setFolded([id], !collapsed.has(id)),
onSetFolds: folds => setView(prev => ({ ...prev, folds: { ...prev.folds, ...folds } })),
...(qa.state.available && { qa }),
...(rewrite.state.available && { rewrite }),
...(changes && { diff: changes }),
...(folded && { folded }),
onOpenRun: id => setOpenedRuns(prev => new Set([...prev, id])),
callTree: {
counts: callCounts?.version === doc.version ? new Map(Object.entries(callCounts.counts)) : new Map(),
...(openCall?.version === doc.version && { openAnchor: openCall.anchor }),
onOpen: onOpenCall,
},
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrite, qa, changes, folded, callCounts, openCall, onOpenCall])
if (!doc || !tree)
if (!doc)
return null
return (
<HighlightContext value={highlight}>
<DisplayNameContext value={names?.names ?? NO_NAMES}>
<FilterBar
attributes={doc.attributes}
excluded={excluded}
view={filter}
highlight={highlight}
onMoveOccurrence={moveOccurrence}
onChange={next => postMessage({ type: 'setFilter', excluded: next })}
translators={doc.translators}
translatorId={doc.translatorId}
onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })}
below={diff && doc.error === undefined && (
<DiffBar
diff={diff}
onlyChanges={onlyChanges}
onToggleOnly={() => postMessage({ type: 'setOnlyChanges', on: !onlyChanges })}
canMove={stops.length > 0}
onMove={nav.move}
/>
)}
/>
{changes && diff && <OverviewRuler stops={stops} lineCount={diff.lineCount} onPick={nav.goTo} />}
<div ref={content} className="mx-auto box-content max-w-[880px] px-6 py-5">
{qa.state.available && (
<div className="mb-4">
<AskThread
history={historyOf(qa.state, PATH_ASK_ANCHOR)}
asks={asksOf(qa.state, PATH_ASK_ANCHOR)}
open
hint={qa.state.subject === 'folder' ? '这个文件夹' : '这个文件'}
{...(qa.state.subject === 'feature' && { wording: FEATURE_THREAD })}
autoFocus={false}
focusToken={pathFocus}
closable={false}
profiles={qa.state.profiles}
onSelectProfile={qa.onSelectProfile}
onAsk={(question, images) => qa.onAsk(PATH_ASK_ANCHOR, question, images)}
onStop={qa.onStop}
onDelete={entryId => qa.onDelete(PATH_ASK_ANCHOR, entryId)}
onClose={() => undefined}
/>
</div>
)}
{diff?.newFile !== undefined && doc.error === undefined && <NewFileBanner detail={diff.newFile} />}
{doc.error !== undefined && (
<div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2">
<TriangleAlert size={16} strokeWidth={1.8} className="mt-px shrink-0 text-[var(--vscode-errorForeground)]" />
<div className="min-w-0">
<div className="break-words">
解析失败:
{doc.error}
</div>
<div className="mt-0.5 text-xs text-[var(--vscode-descriptionForeground)]">点编辑器右上角的「编辑」查看源码。</div>
</div>
</div>
)}
{doc.blocks.length === 0
? doc.error === undefined && qa.state.subject === 'file' && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>
: doc.blocks.some(block => !filter.hidden.has(block.id))
? <BlockLevel blocks={doc.blocks} tree={tree} />
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
</div>
{sticky?.enabled && <StickyLines content={content} blocks={doc.blocks} settings={sticky} />}
<DefinitionPeekPopup shown={peek.shown} onJump={peek.jump} />
{openCall && (
<CallTreeDialog
kind={openCall.kind}
badge={openCall.badge}
name={openCall.name}
where={openCall.where}
status={openCall.status === 'loading' ? 'loading' : openCall.trees.length === 0 ? 'empty' : 'ready'}
trees={openCall.trees}
selected={openCall.entry}
contractMiss={openCall.contractMiss}
onSelect={entry => setOpenCall(prev => prev && { ...prev, entry })}
onClose={() => setOpenCall(null)}
onJump={(jump) => {
setOpenCall(null)
postMessage({ type: 'goToCallNode', ...jump })
}}
<TextMarksContext value={marks}>
<FilterBar
attributes={doc.attributes}
excluded={excluded}
view={filter}
highlight={occurrences.highlight}
onMoveOccurrence={occurrences.moveOccurrence}
onChange={next => postMessage({ type: 'setFilter', excluded: next })}
translators={doc.translators}
translatorId={doc.translatorId}
onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })}
below={diff && doc.error === undefined && (
<DiffBar
diff={diff}
onlyChanges={onlyChanges}
onToggleOnly={() => postMessage({ type: 'setOnlyChanges', on: !onlyChanges })}
canMove={stops.length > 0}
onMove={nav.move}
/>
)}
</DisplayNameContext>
</HighlightContext>
/>
{changes && diff && <OverviewRuler stops={stops} lineCount={diff.lineCount} onPick={nav.goTo} />}
<div ref={content} className="mx-auto box-content max-w-[880px] px-6 py-5">
{qaAvailable && (
<div className="mb-4">
<AskThread
history={pathChat.history}
asks={pathChat.asks}
open
hint={qaSubject === 'folder' ? '这个文件夹' : '这个文件'}
{...(qaSubject === 'feature' && { wording: FEATURE_THREAD })}
autoFocus={false}
focusToken={pathFocus}
closable={false}
profiles={pathChat.profiles}
onSelectProfile={qa.onSelectProfile}
onAsk={(question, images) => qa.onAsk(PATH_ASK_ANCHOR, question, images)}
onStop={qa.onStop}
onDelete={entryId => qa.onDelete(PATH_ASK_ANCHOR, entryId)}
onClose={() => undefined}
/>
</div>
)}
{diff?.newFile !== undefined && doc.error === undefined && <NewFileBanner detail={diff.newFile} />}
{doc.error !== undefined && (
<div className="mb-3 flex items-start gap-2 rounded-lg border border-[var(--vscode-inputValidation-errorBorder)] bg-[var(--vscode-inputValidation-errorBackground)] px-3 py-2">
<TriangleAlert size={16} strokeWidth={1.8} className="mt-px shrink-0 text-[var(--vscode-errorForeground)]" />
<div className="min-w-0">
<div className="break-words">
解析失败:
{doc.error}
</div>
<div className="mt-0.5 text-xs text-[var(--vscode-descriptionForeground)]">点编辑器右上角的「编辑」查看源码。</div>
</div>
</div>
)}
{doc.blocks.length === 0
? doc.error === undefined && qaSubject === 'file' && <div className="text-[var(--vscode-descriptionForeground)]">这个文件里没有代码。</div>
: doc.blocks.some(block => !filter.hidden.has(block.id))
? <BlockTree blocks={doc.blocks} tree={tree} />
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
</div>
{sticky?.enabled && <StickyLines content={content} blocks={doc.blocks} settings={sticky} />}
<DefinitionPeekPopup shown={peek.shown} onJump={peek.jump} />
{openCall && (
<CallTreeDialog
kind={openCall.kind}
badge={openCall.badge}
name={openCall.name}
where={openCall.where}
status={openCall.status === 'loading' ? 'loading' : openCall.trees.length === 0 ? 'empty' : 'ready'}
trees={openCall.trees}
selected={openCall.entry}
contractMiss={openCall.contractMiss}
onSelect={entry => page.update(({ openCall: prev }) => ({ openCall: prev && { ...prev, entry } }))}
onClose={() => page.update({ openCall: null })}
onJump={(jump) => {
page.update({ openCall: null })
postMessage({ type: 'goToCallNode', ...jump })
}}
/>
)}
</TextMarksContext>
)
}
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import type { BlockTreeState, RewriteTree } from './BlockTree'
import type { BlockTreeHandle, BlockTreeState, RewriteTree } from './BlockTree'
import type { Ask, ChatState } from './qa'
import type { BlockView, KindBadge } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
@@ -11,17 +11,14 @@ import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { fastapiRule } from '../../../src/codeReader/rules/fastapi'
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { BlockLevel } from './BlockTree'
import { BlockTree, treeState } from './BlockTree'
import { applyFilter } from './filter'
import { FilterBar } from './FilterBar'
import { EMPTY_CHAT } from './qa'
import { staticStore } from './store'
import { collapsedIds } from './viewState'
// 高亮的上下文所在的模块、编辑框都连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
vi.mock('./useOccurrences', async () => {
const { createContext } = await import('react')
return { HighlightContext: createContext(undefined) }
})
// 编辑框连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
const BADGE: KindBadge = { label: '卡片', color: { dark: '#61dafb', light: '#0a7ea4' }, icon: 'box' }
@@ -64,32 +61,34 @@ const CARD: BlockView = {
/** 重写注释的状态和动作:动作什么都不做 */
function rewriteTree(state: ChatState): RewriteTree {
return { state, onRewrite: () => {}, onAsk: () => {}, onStop: () => {}, onDelete: () => {}, onOpen: () => {}, onClose: () => {}, onSelectProfile: () => {} }
return { store: staticStore(state), onRewrite: () => {}, onAsk: () => {}, onStop: () => {}, onDelete: () => {}, onOpen: () => {}, onClose: () => {}, onSelectProfile: () => {} }
}
function state(collapsed: ReadonlySet<string>, rewrite: ChatState = { ...EMPTY_CHAT, available: true }): BlockTreeState {
/** 能编辑、能重写注释的块树,动作什么都不做;view 盖在默认的状态上(什么都没折、没筛掉) */
function tree(view: Partial<Omit<BlockTreeState, 'folds'>> = {}, rewrite: ChatState = { ...EMPTY_CHAT, available: true }): BlockTreeHandle {
return {
edit: {
version: 1,
editing: new Set(),
focusId: null,
onFocused: () => {},
onToggleEdit: () => {},
},
rewrite: rewriteTree(rewrite),
collapsed,
hidden: new Set(),
temporary: new Set(),
covered: new Map(),
kindName: kind => kind,
state: staticStore(treeState({
edit: { version: 1, editing: new Set(), focusId: null },
collapsed: new Set(),
hidden: new Set(),
temporary: new Set(),
covered: new Map(),
...view,
})),
onToggleFold: () => {},
onSetFolds: () => {},
onOpenRun: () => {},
edit: { onToggleEdit: () => {}, onFocused: () => {} },
rewrite: rewriteTree(rewrite),
}
}
function markup(blocks: BlockView[], handle: BlockTreeHandle): string {
return renderToStaticMarkup(<BlockTree blocks={blocks} tree={handle} />)
}
function render(folds: Record<string, boolean>): string {
return renderToStaticMarkup(<BlockLevel blocks={[CARD]} tree={state(collapsedIds([CARD], folds))} />)
return markup([CARD], tree({ collapsed: collapsedIds([CARD], folds) }))
}
describe('路由筛选后展开函数体', () => {
@@ -118,11 +117,11 @@ describe('路由筛选后展开函数体', () => {
const attributes = fileAttributes(blocks, pythonZhTranslator.attributeNames)
const excluded = { kind: attributes.find(attribute => attribute.id === 'kind')!.values.map(value => value.id).filter(kind => kind !== 'fastapi.route') }
const filtered = applyFilter(blocks, excluded, new Set())
const tree = { ...state(collapsedIds(blocks, {})), ...filtered }
const closed = renderToStaticMarkup(<BlockLevel blocks={blocks} tree={tree} />)
const view = { collapsed: collapsedIds(blocks, {}), hidden: filtered.hidden, temporary: filtered.temporary }
const closed = markup(blocks, tree(view))
expect(closed).toContain('aria-label="展开子块"')
expect(closed).not.toContain('enabled-body')
const opened = renderToStaticMarkup(<BlockLevel blocks={blocks} tree={{ ...tree, collapsed: new Set() }} />)
const opened = markup(blocks, tree({ ...view, collapsed: new Set() }))
expect(opened).toContain('enabled-body')
expect(opened).toContain('disabled-body')
expect(opened).not.toContain('unrelated-body')
@@ -182,14 +181,13 @@ describe('挂在卡片子行上的子块', () => {
describe('卡片记着块记号', () => {
it('每张卡片带着它那块的记号(data-anchor):说明里不带路径的引用靠它认自己写在哪块', () => {
const method = view('0.1', 'function', '', { anchor: 'class AgentService > function get_default_agent', translation: { markdown: '先看 [get_agent_admitted]' } })
const markup = renderToStaticMarkup(<BlockLevel blocks={[method]} tree={state(new Set())} />)
expect(markup).toMatch(/<div class="cr-block [^"]*"[^>]*data-anchor="class AgentService &gt; function get_default_agent"[\s\S]*href="symbol:get_agent_admitted"/)
expect(markup([method], tree())).toMatch(/<div class="cr-block [^"]*"[^>]*data-anchor="class AgentService &gt; function get_default_agent"[\s\S]*href="symbol:get_agent_admitted"/)
})
})
describe('「注释」一行和「重写注释」按钮', () => {
const comment = { label: '注释', value: { markdown: '按名字找' } }
const html = (block: BlockView): string => renderToStaticMarkup(<BlockLevel blocks={[block]} tree={state(new Set())} />)
const html = (block: BlockView): string => markup([block], tree())
/** 按钮只有一个,排在右上角那排按钮(块操作)的最前面 */
const inToolbar = (markup: string, label: string): void => {
expect(markup.match(new RegExp(`aria-label="${label}"`, 'g'))).toHaveLength(1)
@@ -216,10 +214,10 @@ describe('「注释」一行和「重写注释」按钮', () => {
const block = view('5', 'statement', 'save()', { commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } })
const entry = { id: 'r1', question: '/ecc:writing-code-comments a.py:1-1', answer: '保存。', createdAt: '2026-09-25T10:00:00.000Z' }
const closed = { ...EMPTY_CHAT, available: true, threads: [{ anchor: block.anchor, entries: [entry] }] }
const collapsedMarkup = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={state(new Set(), closed)} />)
const collapsedMarkup = markup([block], tree({}, closed))
expect(collapsedMarkup.replace(/<[^>]*>/g, '')).toContain('重写过 1 次')
inToolbar(collapsedMarkup, '展开重写记录')
const opened = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={state(new Set(), { ...closed, open: [block.anchor] })} />)
const opened = markup([block], tree({}, { ...closed, open: [block.anchor] }))
expect(opened).toMatch(/role="group" aria-label="块操作"><button[^>]*aria-label="按注释规范重写"/)
expect(opened).toContain('<section class="cr-qa" aria-label="按注释规范重写">')
expect(opened).toContain('aria-label="对这段注释的修改意见"')
@@ -229,8 +227,7 @@ describe('「注释」一行和「重写注释」按钮', () => {
const block = view('6', 'statement', 'save()', { commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } })
const button = (status: Ask['status']): string => {
const rewrite = { ...EMPTY_CHAT, available: true, open: [block.anchor], asks: [{ askId: 'r1', anchor: block.anchor, question: 'q', text: '', status }] }
const markup = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={state(new Set(), rewrite)} />)
return /<button[^>]*aria-label="按注释规范重写"[^>]*>/.exec(markup)?.[0] ?? ''
return /<button[^>]*aria-label="按注释规范重写"[^>]*>/.exec(markup([block], tree({}, rewrite)))?.[0] ?? ''
}
expect(button('running')).toContain('disabled')
expect(button('queued')).toContain('disabled')
@@ -266,7 +263,7 @@ describe('一行译文下面拆开的各项', () => {
/** 画成页面(只画这一块),collapsed 是折叠着的 id */
function page(block: BlockView, collapsed: ReadonlySet<string> = new Set(), diff?: BlockTreeState['diff']): HTMLElement {
const root = document.createElement('div')
root.innerHTML = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={{ ...state(collapsed), ...(diff && { diff }) }} />)
root.innerHTML = markup([block], tree({ collapsed, ...(diff && { diff }) }))
return root
}
+287 -160
View File
@@ -2,11 +2,12 @@ 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, QaState } from './qa'
import type { ChatState, ChatView, QaState } from './qa'
import type { PendingImage } from './qaImages'
import type { BlockMarks, BlockView } from './types'
import type { ReadableStore } from './store'
import type { BlockMarks, BlockView, ChangedBlock } from './types'
import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react'
import { Fragment, useState } from 'react'
import { createContext, Fragment, memo, useContext, useMemo, useState } from 'react'
import { AskThread, REWRITE_THREAD } from './AskThread'
import { BlockTools } from './BlockTools'
import { CodeEditor } from './CodeEditor'
@@ -14,62 +15,74 @@ import { DiffText } from './DiffText'
import { removedTitle } from './diffView'
import { KindIcon } from './kindIcons'
import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid'
import { asksOf, historyOf, unfinished } from './qa'
import { chatOf, EMPTY_CHAT, sameChat, unfinished } from './qa'
import { shallowEqual, staticStore, useStore } from './store'
import { TextPreview } from './TextPreview'
import { listId, tableId } from './viewState'
import { foldsByCard, listId, tableId } from './viewState'
/**
* 块树里改代码的那部分:哪些块在编辑、点「编辑」。页面上的块树才有;定义卡片浮窗里的块树没有,
* 卡片只读,不画右上角的按钮
*/
export interface EditTree {
/** 块树里改代码的那部分状态:哪些块在编辑。页面上的块树才有;定义卡片浮窗里的块树没有,卡片只读,不画右上角的按钮 */
export interface EditState {
version: number
editing: ReadonlySet<string>
/** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null
onFocused: () => void
onToggleEdit: (id: string) => void
}
/** 函数、类落在几棵调用树里。定义浮窗不传,跟问 AI 一样不画这排按钮。 */
export interface CallTreeTools {
/** 函数、类落在几棵调用树里,开着哪一块的弹框。定义浮窗没有,跟问 AI 一样不画这排按钮。 */
export interface CallTreeState {
counts: ReadonlyMap<string, number>
openAnchor?: string
onOpen: (anchor: string) => void
}
/**
* 块树要画的、会变的状态:一份不可变的快照,放在 BlockTreeHandle.state 里。每张卡片只订阅自己用到的那部分(useStore),
* 一处变了只重画受影响的卡片。
*/
export interface BlockTreeState {
/** 能编辑(页面上的块树);定义卡片浮窗里没有 */
edit?: EditTree
/** 调用树。只读的树不传 */
callTree?: CallTreeTools
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>
/** 种类叫什么(种类图标的悬停提示和 aria-label):翻译插件给种类起的名字(库规则的种类是规则给的 badge.label),没给就是 id */
kindName: (kind: 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
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
qa?: QaTree
/** 按注释规范重写;同问 AI,文件不在工作区文件夹里时没有 */
rewrite?: RewriteTree
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
diff?: DiffTree
/** 「只看改动」开着时折起来的连着没变的块(见 foldUnchanged);onOpenRun 点开一段(按第一块的 id) */
folded?: FoldedRuns
/** 「只看改动」时点开一段折起来的块(按第一块的 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
}
/** 重写注释的状态和动作,块按自己的记号(anchor)取;profile 下拉是自己的,和问 AI 的互不影响 */
/** 重写注释的状态(块按自己的记号订阅)和动作;profile 下拉是自己的,和问 AI 的互不影响 */
export interface RewriteTree {
state: ChatState
store: ReadableStore<ChatState>
/** 点了块右上角的按钮:按技能命令重写这块(开新会话),展开它的重写记录 */
onRewrite: (anchor: string) => void
/** 在输入框里说的修改意见,接着这块最近一次重写的会话说;images 是贴的图片 */
@@ -82,9 +95,9 @@ export interface RewriteTree {
onSelectProfile: (id: string) => void
}
/** 问 AI 的状态和动作,块按自己的记号(anchor)取 */
/** 问 AI 的状态(块按自己的记号订阅)和动作 */
export interface QaTree {
state: QaState
store: ReadableStore<QaState>
onToggle: (anchor: string) => void
onClose: (anchor: string) => void
/** images 是贴进输入框的图片,随问题一起发 */
@@ -95,19 +108,47 @@ export interface QaTree {
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, 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)
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({ tree, change, children }: { tree: BlockTreeState, change: keyof typeof CHANGE_NAMES | undefined, children: ReactNode }): ReactElement {
if (!tree.diff)
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]">
@@ -118,9 +159,9 @@ function ChangeRow({ tree, change, children }: { tree: BlockTreeState, change: k
}
/** 「只看改动」时连着没变的几块折成的一条「… N 块没变」,点开恢复这几块。 */
function UnchangedStrip({ count, tree, onOpen }: { count: number, tree: BlockTreeState, onOpen: () => void }): ReactElement {
function UnchangedStrip({ count, onOpen }: { count: number, onOpen: () => void }): ReactElement {
return (
<ChangeRow tree={tree} change={undefined}>
<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} 块没变`}
@@ -131,8 +172,8 @@ function UnchangedStrip({ count, tree, onOpen }: { count: number, tree: BlockTre
}
/** 块画不画:被筛掉的、被正在编辑的祖先盖住的都不画。 */
function shown(block: BlockView, tree: BlockTreeState): boolean {
return !tree.hidden.has(block.id) && !tree.covered.has(block.id)
function shown(block: BlockView, state: BlockTreeState): boolean {
return !state.hidden.has(block.id) && !state.covered.has(block.id)
}
/**
@@ -148,24 +189,184 @@ export function kindProps(block: Pick<BlockView, 'kind' | 'badge'> | undefined):
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, tree, slotted = false }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState, slotted?: boolean }): ReactElement {
export function BlockLevel({ blocks, owner, slotted = false }: { blocks: BlockView[], owner?: BlockView, slotted?: boolean }): ReactElement {
const tree = useBlockTree()
// 挂在卡片子行上的子块画在卡片属性表里(slotted 的那一层),这一层不画;删掉的块都放回卡片后面这一层
const listed = slotted ? blocks : blocks.filter(block => !block.slot)
const removed = slotted ? [] : tree.diff?.removed.get(owner?.id ?? '') ?? []
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} tree={tree} />)
.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} tree={tree} />)
.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">
@@ -175,19 +376,7 @@ export function BlockLevel({ blocks, owner, tree, slotted = false }: { blocks: B
{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.rewrite && <BlockRewrite key={`rewrite ${block.anchor}`} anchor={block.anchor} rewrite={tree.rewrite} />}
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
<BlockLevel blocks={block.children} owner={block} tree={tree} />
)}
</>
)}
<BlockItem block={block} />
{removedAfter(block.id)}
</Fragment>
))}
@@ -197,50 +386,17 @@ export function BlockLevel({ blocks, owner, tree, slotted = false }: { blocks: B
)
}
/** 块下面的问答区,紧跟在块后面、子块前面;输入框、展开状态跟着块的记号走,文档刷新块 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, rewrite }: { anchor: string, rewrite: RewriteTree }): ReactElement | null {
return (
<AskThread
wording={REWRITE_THREAD}
history={historyOf(rewrite.state, anchor)}
asks={asksOf(rewrite.state, anchor)}
open={rewrite.state.open.includes(anchor)}
profiles={rewrite.state.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)}
/>
)
}
/**
* 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文;
* 译文下面依次接着拆开的各项(跨几行写的字面量,排法同变量初始值下面的)、摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。
* 标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。
* folds 是这张卡片里折叠着的(见 foldsByCard);标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。
*/
function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTreeState, marks?: BlockMarks }): ReactElement {
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={tree.collapsed} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={cardSlots(block, tree)} />
const folding = { collapsed: tree.collapsed, onToggleFold: tree.onToggleFold }
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} />}
@@ -257,59 +413,27 @@ function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTre
{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={tree.collapsed} onToggleFold={tree.onToggleFold} marks={marks?.rows} />
<PropertyGrid blockId={block.id} rows={block.rows} collapsed={folds} onToggleFold={tree.onToggleFold} marks={marks?.rows} />
</div>
)}
</>
)
}
/** 这块的调用树按钮:扩展端标了能看调用树(BlockView.callTree)的块才有。 */
function callTreeTool(block: BlockView, tree: BlockTreeState) {
const callTree = tree.callTree
if (!callTree || !block.callTree)
return undefined
return {
anchor: block.anchor,
count: callTree.counts.get(block.anchor) ?? 0,
open: callTree.openAnchor === block.anchor,
onOpen: () => callTree.onOpen(block.anchor),
}
}
/**
* 这块的「重写注释」按钮:能写注释的块才有。重写过、记录收着时点了先展开记录(好接着说);展开着、或还没重写过时点了按技能命令重写。
* 展开着又正在写、排着队时不能再点。
*/
function rewriteTool(block: BlockView, tree: BlockTreeState): RewriteTool | undefined {
const { edit, rewrite } = tree
if (!edit || !rewrite || !block.commentTarget)
return undefined
const { anchor } = block
const open = rewrite.state.open.includes(anchor)
const asks = asksOf(rewrite.state, anchor)
const reopen = !open && (asks.length > 0 || historyOf(rewrite.state, anchor).length > 0)
return {
fresh: block.commentTarget.kind === 'insert',
rewriting: open && asks.some(unfinished),
reopen,
onRewrite: () => reopen ? rewrite.onOpen(anchor) : rewrite.onRewrite(anchor),
}
}
})
BlockPreview.displayName = 'BlockPreview'
/**
* 挂在卡片子行上的子块(BlockSlot)怎么画:一行下面展开着的几块画成一层块(同块树);点那个子行的第一格展开它、
* 同一行里别的收起,展开着的再点收起。卡片没有挂在子行上的子块时没有。
*/
function cardSlots(block: BlockView, tree: BlockTreeState): GridSlots | undefined {
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} tree={tree} slotted />,
render: list => <BlockLevel blocks={list} owner={block} slotted />,
onToggle: (child) => {
if (!tree.collapsed.has(child.id)) {
if (!folds.has(child.id)) {
tree.onSetFolds({ [child.id]: true })
return
}
@@ -320,30 +444,30 @@ function cardSlots(block: BlockView, tree: BlockTreeState): GridSlots | undefine
}
/**
* 一块:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools,只读的块树没有)。
* 一块的卡片:折叠箭头、种类图标、预览内容或源码、右上角的一排图标按钮(BlockTools,只读的块树没有)。
* 卡片上记着块记号(data-anchor),说明里不带路径的引用靠它认自己写在哪块(见 commentLinkOf)。
*/
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)
// 点父卡片子行才展开的子块:画出来就是展开着的,箭头收起整张
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 hasChildren = toggled || hasShownChildren(block, tree)
const Chevron = collapsed && !toggled ? ChevronRight : ChevronDown
const change = tree.diff?.changes.get(block.id)
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={block.anchor}
data-anchor={anchor}
data-line={block.startLine}
data-editing={editing ? '' : undefined}
data-editing={shape.editing ? '' : undefined}
data-change={change?.change}
>
{change?.outermost && <span className="cr-added-badge">新增</span>}
{hasChildren
{shape.hasChildren
? (
<button
type="button"
@@ -358,19 +482,19 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
: <span className="w-3.5 shrink-0" />}
<KindIcon concepts={block.concepts} />
<div className="min-w-0 flex-1">
{edit && editing
{edit && shape.version !== undefined
? (
<CodeEditor
block={block}
version={edit.version}
autoFocus={edit.focusId === block.id}
version={shape.version}
autoFocus={shape.focus}
onAutoFocused={edit.onFocused}
onExit={() => edit.onToggleEdit(block.id)}
/>
)
: <BlockPreview block={block} tree={tree} marks={tree.diff?.current ? change?.marks : undefined} />}
: <BlockPreview block={block} folds={shape.folds} marks={shape.current ? change?.marks : undefined} />}
</div>
{tree.temporary.has(block.id) && (
{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 />
已被筛选隐藏,临时显示
@@ -378,12 +502,12 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
)}
{edit && (
<BlockTools
editing={editing}
editing={shape.editing}
onToggleEdit={() => edit.onToggleEdit(block.id)}
asking={tree.qa?.state.open.includes(block.anchor)}
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
rewrite={rewriteTool(block, tree)}
callTree={callTreeTool(block, tree)}
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>
@@ -394,14 +518,18 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
* 删掉的一块,放在原来的位置:红色虚线边、半透明,没有编辑、问 AI 按钮。默认收成一行「已删除 · 种类 名字」,
* 点箭头或这一行展开,下面是旧内容(整张划掉,名字不能跳定义);子块不画。
*/
function RemovedCard({ placed, tree }: { placed: PlacedRemoved, tree: BlockTreeState }): ReactElement {
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 tree={tree} change="del">
<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"
@@ -421,8 +549,7 @@ function RemovedCard({ placed, tree }: { placed: PlacedRemoved, tree: BlockTreeS
</div>
{open && (
<div className="cr-removed-old mt-1">
{/* 旧块的 id 是旧版本里的位置,换成页面上认删掉的块用的 id,属性行的 id 才不和现在的块撞上 */}
<BlockPreview block={{ ...block, id: placed.id }} tree={tree} />
<BlockPreview block={old} folds={folds} />
</div>
)}
</div>
@@ -2,18 +2,15 @@
import type { ReactElement } from 'react'
import type { Root } from 'react-dom/client'
import type { BlockTreeState } from './BlockTree'
import type { BlockTreeHandle, CallTreeState } from './BlockTree'
import type { BlockView, CallTree } from './types'
import { act, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BlockLevel } from './BlockTree'
import { BlockTree, treeState } from './BlockTree'
import { CallTreeDialog, splitWhere } from './CallTreeDialog'
import { staticStore } from './store'
vi.mock('./useOccurrences', async () => {
const { createContext } = await import('react')
return { HighlightContext: createContext(undefined) }
})
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
let container: HTMLDivElement
@@ -53,26 +50,22 @@ function block(kind: string, id = kind, callTree?: BlockView['callTree']): Block
}
}
function tree(callTree?: BlockTreeState['callTree'], edit = true): BlockTreeState {
/** 块树:callTree 是各块的棵数、开着哪一块的弹框和点按钮时做什么;edit 是 false 时是只读的树 */
function tree(callTree?: CallTreeState & { onOpen: (anchor: string) => void }, edit = true): BlockTreeHandle {
return {
...(edit && {
edit: {
version: 1,
editing: new Set<string>(),
focusId: null,
onFocused: () => {},
onToggleEdit: () => {},
},
}),
collapsed: new Set(),
hidden: new Set(),
temporary: new Set(),
covered: new Map(),
kindName: kind => kind,
state: staticStore(treeState({
...(edit && { edit: { version: 1, editing: new Set<string>(), focusId: null } }),
collapsed: new Set(),
hidden: new Set(),
temporary: new Set(),
covered: new Map(),
...(callTree && { callTree: { counts: callTree.counts, ...(callTree.openAnchor !== undefined && { openAnchor: callTree.openAnchor }) } }),
})),
onToggleFold: () => {},
onSetFolds: () => {},
onOpenRun: () => {},
...(callTree && { callTree }),
...(edit && { edit: { onToggleEdit: () => {}, onFocused: () => {} } }),
...(callTree && { onOpenCall: callTree.onOpen }),
}
}
@@ -106,7 +99,7 @@ describe('调用树弹框', () => {
const counts = new Map([['function', 0], ['class', 2], ['statement', 4], ['route', 1], ['bare', 5]])
const endpoint = { method: 'GET', path: '/agents' }
await render(
<BlockLevel
<BlockTree
blocks={[block('function', 'function', {}), block('class', 'class', {}), block('statement'), block('fastapi.route', 'route', { endpoint }), block('function', 'bare')]}
tree={tree({ counts, onOpen: () => {} })}
/>,
@@ -118,7 +111,7 @@ describe('调用树弹框', () => {
expect(container.querySelector('[data-call-tree="route"]')!.textContent).toBe('1')
expect(container.querySelector('[data-call-tree="bare"]')).toBeNull()
await render(<BlockLevel blocks={[block('function', 'function', {})]} tree={tree({ counts: new Map([['function', 3]]), onOpen: () => {} }, false)} />)
await render(<BlockTree blocks={[block('function', 'function', {})]} tree={tree({ counts: new Map([['function', 3]]), onOpen: () => {} }, false)} />)
expect(container.querySelector('[aria-label="块操作"]')).toBeNull()
expect(container.querySelector('[data-call-tree="function"]')).toBeNull()
})
@@ -129,7 +122,7 @@ describe('调用树弹框', () => {
const [open, setOpen] = useState(false)
return (
<>
<BlockLevel
<BlockTree
blocks={[card]}
tree={tree({ counts: new Map([['mask', 3]]), openAnchor: open ? 'mask' : undefined, onOpen: () => setOpen(true) })}
/>
@@ -1,14 +1,15 @@
import type { ReactElement } from 'react'
import type { BlockTreeState } from './BlockTree'
import type { BlockTreeHandle, BlockTreeState } from './BlockTree'
import type { ShownPeek } from './definitionPeek'
import type { DefinitionPeek } from './types'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { BlockLevel } from './BlockTree'
import { BlockTree, treeState } from './BlockTree'
import { PEEK_ATTRIBUTE, placePeek } from './definitionPeek'
import { DisplayNameContext, nameMap } from './displayNames'
import { nameMap } from './displayNames'
import { TextMarksContext } from './SegmentText'
import { createStore, staticStore } from './store'
import { MarkdownKindContext } from './TextPreview'
import { HighlightContext } from './useOccurrences'
import { collapsedIds } from './viewState'
const NONE: ReadonlySet<string> = new Set()
@@ -19,28 +20,30 @@ const NOT_COVERED: ReadonlyMap<string, string> = new Map()
* 名字的下标是那个文件的:中文名换成卡片带来的,不画页面上的高亮;Markdown 不认不带路径的引用。
*/
export function PeekTree({ peek }: { peek: DefinitionPeek }): ReactElement {
const [folds, setFolds] = useState<Record<string, boolean>>({})
const collapsed = useMemo(() => collapsedIds([peek.block], folds), [peek, folds])
const names = useMemo(() => nameMap(peek.names), [peek])
const tree: BlockTreeState = {
collapsed,
hidden: NONE,
temporary: NONE,
covered: NOT_COVERED,
kindName: kind => kind,
onToggleFold: id => setFolds(prev => ({ ...prev, [id]: !collapsed.has(id) })),
onSetFolds: next => setFolds(prev => ({ ...prev, ...next })),
// 不标差异就没有「只看改动」折起来的块
onOpenRun: () => {},
}
const [tree] = useState((): BlockTreeHandle => {
const viewOf = (folds: Record<string, boolean>, previous?: BlockTreeState): BlockTreeState =>
treeState({ collapsed: collapsedIds([peek.block], folds), hidden: NONE, temporary: NONE, covered: NOT_COVERED }, previous)
let folds: Record<string, boolean> = {}
const state = createStore(viewOf(folds))
const setFolds = (next: Record<string, boolean>): void => {
folds = { ...folds, ...next }
state.set(previous => viewOf(folds, previous))
}
return {
state,
onToggleFold: id => setFolds({ [id]: !state.get().collapsed.has(id) }),
onSetFolds: setFolds,
// 不标差异就没有「只看改动」折起来的块
onOpenRun: () => {},
}
})
const marks = useMemo(() => staticStore({ names: nameMap(peek.names) }), [peek])
return (
<HighlightContext value={undefined}>
<DisplayNameContext value={names}>
<MarkdownKindContext value="peek">
<BlockLevel blocks={[peek.block]} tree={tree} />
</MarkdownKindContext>
</DisplayNameContext>
</HighlightContext>
<TextMarksContext value={marks}>
<MarkdownKindContext value="peek">
<BlockTree blocks={[peek.block]} tree={tree} />
</MarkdownKindContext>
</TextMarksContext>
)
}
@@ -1,13 +1,7 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { SegmentText } from './SegmentText'
// 高亮的上下文所在的模块连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
vi.mock('./useOccurrences', async () => {
const { createContext } = await import('react')
return { HighlightContext: createContext(undefined) }
})
describe('着色的词', () => {
it('按词性上色;带自己颜色的词(库规则给的)把暗色、亮色写在 style 上,CSS 按主题挑', () => {
const html = renderToStaticMarkup(
@@ -1,11 +1,26 @@
import type { LucideIcon } from 'lucide-react'
import type { CSSProperties, ReactElement } from 'react'
import type { GroupStyle, Inline, SegmentGroup } from './types'
import { useContext, useState } from 'react'
import { DisplayNameContext, shownName } from './displayNames'
import type { NameMap } from './displayNames'
import type { Highlight, OccurrenceStyle } from './occurrences'
import type { ReadableStore } from './store'
import type { GroupStyle, Inline, Segment, SegmentGroup, ShownName } from './types'
import { createContext, memo, useContext, useState } from 'react'
import { sameShownName, shownName } from './displayNames'
import { KIND_ICON_COMPONENTS } from './kindIcons'
import { isCurrent, segmentOccurrence, sourceOf } from './occurrences'
import { HighlightContext } from './useOccurrences'
import { staticStore, useStore } from './store'
/** 页面上文字的两样标记:中文名(下标 → 显示成什么),眼下高亮着的名字(没有高亮时没有)。 */
export interface TextMarks {
names: NameMap
highlight?: Highlight
}
/**
* 文字的标记从哪份 store 读:页面上是 App 的那份(中文名、单击高亮都记在里面);定义卡片浮窗是那个文件的中文名,钉住的行不画高亮。
* 每段文字只订阅自己那几段的标记。
*/
export const TextMarksContext = createContext<ReadableStore<TextMarks>>(staticStore({ names: new Map() }))
/**
* 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换,带自己颜色(color)的段用它(.cr-colored);带源码下标的名字记在 data-offset 上
@@ -13,24 +28,25 @@ import { HighlightContext } from './useOccurrences'
* 再标 data-current。起了中文名的名字显示中文名(data-name),悬停看原名,定义处本身在后面淡淡写原名;下标照旧,跳到定义、高亮照常。
* 带悬停提示(tooltip)的段悬停看它。
* 框起来的一组词画成小框,框里的词同样处理。
* 中文名和高亮只订阅自己这几段的(TextMarksContext):中文名到了、换了高亮的名字,只有字或底色变了的那几串文字重画。
*/
export function SegmentText({ segments }: { segments: Inline[] }): ReactElement {
const highlight = useContext(HighlightContext)
const names = useContext(DisplayNameContext)
export const SegmentText = memo(({ segments }: { segments: Inline[] }): ReactElement => {
const marks = useStore(useContext(TextMarksContext), current => segments.map(piece => 'role' in piece ? markOf(piece, current) : undefined), sameMarks)
return (
<>
{segments.map((piece, index) => {
if (!('role' in piece))
return <GroupBox key={index} group={piece} />
const shown = shownName(piece, names)
const mark = marks[index]
const shown = mark?.shown
return (
<span
key={index}
className={piece.color ? `cr-role-${piece.role} cr-colored` : `cr-role-${piece.role}`}
style={piece.color && { '--color-dark': piece.color.dark, '--color-light': piece.color.light } as CSSProperties}
data-offset={sourceOf(piece)?.start}
data-occurrence={highlight && segmentOccurrence(piece, highlight.occurrences)}
data-current={highlight && isCurrent(piece, highlight) ? '' : undefined}
data-occurrence={mark?.occurrence}
data-current={mark?.current ? '' : undefined}
data-name={shown?.name}
data-tooltip={shown ? `原名 ${piece.text}` : piece.tooltip}
>
@@ -41,6 +57,28 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement
})}
</>
)
})
SegmentText.displayName = 'SegmentText'
/** 一段的标记:显示成的中文名,高亮里画哪种底色、是不是当前那一处;都没有时没有。 */
interface SegmentMark {
shown?: ShownName
occurrence?: OccurrenceStyle
current: boolean
}
function markOf(piece: Segment, { names, highlight }: TextMarks): SegmentMark | undefined {
const shown = shownName(piece, names)
const occurrence = highlight && segmentOccurrence(piece, highlight.occurrences)
const current = highlight !== undefined && isCurrent(piece, highlight)
return shown || occurrence || current ? { shown, occurrence, current } : undefined
}
function sameMarks(a: (SegmentMark | undefined)[], b: (SegmentMark | undefined)[]): boolean {
return a.length === b.length && a.every((mark, index) => {
const other = b[index]
return mark === other || (mark !== undefined && other !== undefined && sameShownName(mark.shown, other.shown) && mark.occurrence === other.occurrence && mark.current === other.current)
})
}
/**
@@ -1,8 +1,10 @@
import type { BlockView } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { DisplayNameContext, nameMap } from './displayNames'
import { nameMap } from './displayNames'
import { TextMarksContext } from './SegmentText'
import { PinnedHead } from './StickyLines'
import { staticStore } from './store'
vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, viewState: { get: () => ({ editing: [], folds: {} }), update: () => {} } }))
@@ -25,9 +27,9 @@ function block(kind: string, extra: Partial<BlockView>): BlockView {
function render(view: BlockView): string {
return renderToStaticMarkup(
<DisplayNameContext value={nameMap([{ offset: 40, length: 10, name: '加载配置', definition: true }])}>
<TextMarksContext value={staticStore({ names: nameMap([{ offset: 40, length: 10, name: '加载配置', definition: true }]) })}>
<PinnedHead block={view} />
</DisplayNameContext>,
</TextMarksContext>,
)
}
@@ -1,9 +1,9 @@
import type { ReactElement, RefObject } from 'react'
import type { BlockView, StickyScrollSettings } from './types'
import { useLayoutEffect, useMemo } from 'react'
import { useContext, useLayoutEffect, useMemo } from 'react'
import { KindIcon } from './kindIcons'
import { SegmentText } from './SegmentText'
import { HighlightContext } from './useOccurrences'
import { SegmentText, TextMarksContext } from './SegmentText'
import { selectStore } from './store'
import { flash } from './useReveal'
import { pinnedHeight, usePinnedHeight } from './useScrollPosition'
import { STICKY_LINE_HEIGHT, stickyInset, useStickyLines } from './useStickyScroll'
@@ -52,6 +52,9 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject<
const top = usePinnedHeight()
const lines = useStickyLines(content, settings.maxLineCount)
const byId = useMemo(() => blocksById(blocks), [blocks])
// 钉着的名字照换中文名,不画页面上的高亮
const marks = useContext(TextMarksContext)
const unhighlighted = useMemo(() => selectStore(marks, ({ names }) => ({ names })), [marks])
const count = lines.scopes.length
const height = count * STICKY_LINE_HEIGHT + lines.offset
@@ -76,7 +79,7 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject<
}
return (
<HighlightContext value={undefined}>
<TextMarksContext value={unhighlighted}>
<div className="cr-sticky" style={{ top, height }}>
{lines.scopes.map((scope, index) => {
const block = byId.get(scope.id)
@@ -96,6 +99,6 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject<
)
})}
</div>
</HighlightContext>
</TextMarksContext>
)
}
@@ -1,7 +1,7 @@
import type { ReactElement } from 'react'
import type { MarkdownKind } from './markdown'
import type { PreviewText } from './types'
import { createContext, useContext, useMemo } from 'react'
import { createContext, memo, useContext, useMemo } from 'react'
import { cn } from '@/lib/utils'
import { renderMarkdown } from './markdown'
import { SegmentText } from './SegmentText'
@@ -10,7 +10,7 @@ import { SegmentText } from './SegmentText'
export const MarkdownKindContext = createContext<Exclude<MarkdownKind, 'answer'>>('comment')
/** 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。 */
export function TextPreview({ text, className }: { text: PreviewText, className?: string }): ReactElement {
export const TextPreview = memo(({ text, className }: { text: PreviewText, className?: string }): ReactElement => {
const kind = useContext(MarkdownKindContext)
if ('markdown' in text)
return <MarkdownPreview html={renderMarkdown(text.markdown, kind)} className={className} />
@@ -19,7 +19,8 @@ export function TextPreview({ text, className }: { text: PreviewText, className?
<SegmentText segments={text} />
</p>
)
}
})
TextPreview.displayName = 'TextPreview'
export function MarkdownPreview({ html, className }: { html: string, className?: string }): ReactElement {
const content = useMemo(() => ({ __html: html }), [html])
@@ -1,15 +1,10 @@
import type { Inline, ShownName, WebviewToExtension } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { acceptNames, DisplayNameContext, documentNames, nameMap, shownName } from './displayNames'
import { describe, expect, it } from 'vitest'
import { nameMap, sameShownName, shownName } from './displayNames'
import { OccurrenceHighlight } from './occurrences'
import { SegmentText } from './SegmentText'
// 高亮的上下文所在的模块连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
vi.mock('./useOccurrences', async () => {
const { createContext } = await import('react')
return { HighlightContext: createContext(undefined) }
})
import { SegmentText, TextMarksContext } from './SegmentText'
import { staticStore } from './store'
const CURRENT_USER: ShownName = { offset: 40, length: 12, name: '当前用户', definition: true }
const USE: ShownName = { offset: 90, length: 12, name: '当前用户' }
@@ -17,19 +12,19 @@ const ADMIN: ShownName = { offset: 130, length: 5, name: '管理员' }
function render(segments: Inline[], names: ShownName[]): string {
return renderToStaticMarkup(
<DisplayNameContext value={nameMap(names)}>
<TextMarksContext value={staticStore({ names: nameMap(names) })}>
<SegmentText segments={segments} />
</DisplayNameContext>,
</TextMarksContext>,
)
}
describe('页面上的中文名', () => {
it('收到新文档换成它带来的;查到的是页面上这个版本的才换上,迟到的旧版本不理', () => {
const current = documentNames(3, [USE])
expect([...current.names.values()]).toEqual([USE])
expect(acceptNames(current, 2, [ADMIN])).toBe(current)
expect([...acceptNames(current, 3, [USE, ADMIN])!.names.values()]).toEqual([USE, ADMIN])
expect(acceptNames(undefined, 3, [ADMIN])).toBeUndefined()
it('两段显示成的中文名按内容比:中文名重新发来是新对象,字没变就算一样(那段文字不重画)', () => {
expect(sameShownName(USE, { ...USE })).toBe(true)
expect(sameShownName(USE, undefined)).toBe(false)
expect(sameShownName(undefined, undefined)).toBe(true)
expect(sameShownName(USE, { ...USE, name: '用户' })).toBe(false)
expect(sameShownName(CURRENT_USER, { ...CURRENT_USER, definition: undefined })).toBe(false)
})
it('只认下标和长度都对得上的名字:没下标的译词、长度不一样的(名字改长了)不换', () => {
@@ -1,34 +1,19 @@
import type { Segment, ShownName } from './types'
import { createContext } from 'react'
/** 页面上这个版本的中文名:下标 → 显示成什么。 */
export type NameMap = ReadonlyMap<number, ShownName>
/** 眼下页面上显示成中文名的名字,SegmentText 按它换字。 */
export const DisplayNameContext = createContext<NameMap>(new Map())
/** 某个文档版本的中文名。 */
export interface VersionNames {
version: number
names: NameMap
}
export function nameMap(names: readonly ShownName[]): NameMap {
return new Map(names.map(name => [name.offset, name]))
}
/** 收到新文档:换成它带来的中文名(上一版查到的跟着改动挪过位置的,或这一版查过的)。 */
export function documentNames(version: number, names: readonly ShownName[]): VersionNames {
return { version, names: nameMap(names) }
}
/** 收到查到的中文名:是页面上这个版本的才换上,迟到的旧版本的不理。 */
export function acceptNames(current: VersionNames | undefined, version: number, names: readonly ShownName[]): VersionNames | undefined {
return current?.version === version ? { version, names: nameMap(names) } : current
}
/** 这段名字显示成什么中文名:照抄自源码、下标和长度都对得上才算。 */
export function shownName(segment: Segment, names: NameMap): ShownName | undefined {
const shown = segment.offset === undefined ? undefined : names.get(segment.offset)
return shown?.length === segment.text.length ? shown : undefined
}
/** 两段显示成的中文名一样(中文名到了是新对象,按内容比)。 */
export function sameShownName(a: ShownName | undefined, b: ShownName | undefined): boolean {
return a === b || (a !== undefined && b !== undefined && a.name === b.name && a.definition === b.definition)
}
@@ -43,6 +43,7 @@ import {
Variable,
Zap,
} from 'lucide-react'
import { memo } from 'react'
/** 插件定义的这组种类图标,每个名字一个 lucide 图标(逐个 import,打包只带这些)。 */
export const KIND_ICON_COMPONENTS: Record<KindIconName, LucideIcon> = {
@@ -93,7 +94,7 @@ export const KIND_ICON_COMPONENTS: Record<KindIconName, LucideIcon> = {
* 没有颜色的是语言的种类,按 id 用 CSS 的种类色;有颜色的只给这个图标上色。
* 每个图标悬停自己的名字(data-tooltip,见 components/ui/tooltip.tsx);读屏把名字连起来念。
*/
export function KindIcon({ concepts }: { concepts: readonly BlockConcept[] }): ReactElement {
export const KindIcon = memo(({ concepts }: { concepts: readonly BlockConcept[] }): ReactElement => {
return (
<span className="flex shrink-0 items-center gap-0.5" role="img" aria-label={concepts.map(concept => concept.label).join(',')}>
{concepts.map((concept) => {
@@ -109,4 +110,5 @@ export function KindIcon({ concepts }: { concepts: readonly BlockConcept[] }): R
})}
</span>
)
}
})
KindIcon.displayName = 'KindIcon'
@@ -46,6 +46,27 @@ describe('occurrences:哪些段画高亮', () => {
expect(segmentOccurrence({ text: '\'./util\'', role: 'string', offset: 9 }, occurrences)).toBe('normal')
})
it('出现很多、乱序、有长有短还互相重叠时,结果和逐处比一样', () => {
const brute = (segment: Segment, occurrences: readonly Occurrence[]): string | undefined => {
const hits = occurrences.filter(occurrence => occurrence.start < segment.offset! + segment.text.length && segment.offset! < occurrence.end)
return hits.length === 0 ? undefined : hits.some(hit => hit.kind === 'write') ? 'strong' : 'normal'
}
let seed = 7
const random = (n: number): number => {
seed = (seed * 1103515245 + 12345) % 2147483648
return seed % n
}
const kinds: Occurrence['kind'][] = ['read', 'write', 'text']
const occurrences = Array.from({ length: 300 }, () => {
const start = random(2000)
return at(start, start + 1 + random(12), kinds[random(3)])
})
for (let offset = 0; offset < 2020; offset += 3) {
const segment = name('x'.repeat(1 + (offset % 7)), offset)
expect(segmentOccurrence(segment, occurrences), `offset ${offset}`).toBe(brute(segment, occurrences))
}
})
it('写入用强高亮色,读取和按文字认出的用普通高亮色;同一段碰上两种按写入画', () => {
expect(occurrenceStyle('write')).toBe('strong')
expect(occurrenceStyle('read')).toBe('normal')
@@ -12,9 +12,16 @@ export function occurrenceStyle(kind: Occurrence['kind']): OccurrenceStyle {
* 同时碰上写入和读取的按写入画。没有下标的(译词、提示词、编辑状态的源码)不画。
*/
export function segmentOccurrence(segment: Segment, occurrences: readonly Occurrence[]): OccurrenceStyle | undefined {
const source = sourceOf(segment)
if (!source)
return undefined
const { sorted, longest } = occurrenceIndex(occurrences)
// 起点不早于段尾的都不重叠;往前看到起点早于段头一整个最长出现的为止,再往前的都在段头之前结束
let index = firstStartAtOrAfter(sorted, source.end)
let style: OccurrenceStyle | undefined
for (const occurrence of occurrences) {
if (overlaps(segment, occurrence)) {
while (--index >= 0 && sorted[index].start > source.start - longest) {
const occurrence = sorted[index]
if (occurrence.end > source.start) {
style = occurrenceStyle(occurrence.kind)
if (style === 'strong')
return style
@@ -23,6 +30,40 @@ export function segmentOccurrence(segment: Segment, occurrences: readonly Occurr
return style
}
/** 一份出现按起点排好,带最长的一处有多长:整份文档几千段文字各找一次和自己重叠的那几处,按二分找,不逐处比 */
interface OccurrenceIndex {
sorted: readonly Occurrence[]
longest: number
}
const indexes = new WeakMap<readonly Occurrence[], OccurrenceIndex>()
function occurrenceIndex(occurrences: readonly Occurrence[]): OccurrenceIndex {
let index = indexes.get(occurrences)
if (!index) {
index = {
sorted: [...occurrences].sort((a, b) => a.start - b.start),
longest: occurrences.reduce((max, occurrence) => Math.max(max, occurrence.end - occurrence.start), 0),
}
indexes.set(occurrences, index)
}
return index
}
/** 第一处起点不早于 at 的出现的序号,没有时是总数 */
function firstStartAtOrAfter(sorted: readonly Occurrence[], at: number): number {
let low = 0
let high = sorted.length
while (low < high) {
const middle = (low + high) >> 1
if (sorted[middle].start < at)
low = middle + 1
else
high = middle
}
return low
}
/**
* 段讲的是源码里哪个名字,[start, end):讲别处名字的段(refers,写在字符串里的参数名)是那个名字,照抄自源码的段是它自己占的
* [offset, offset + 字数);没有下标的段(译词、提示词)没有。跳到定义、单击高亮、各处出现画在哪都按它。
@@ -0,0 +1,178 @@
import type { BlockTreeState } from './BlockTree'
import type { ChangeStop, DiffTree, FoldedRuns } from './diffView'
import type { FilterView } from './filter'
import type { ReadableStore } from './store'
import type { BlockView, CallTree, ContractMiss, DocumentDiff, DocumentMessage, Excluded, KindBadge, Reveal } from './types'
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
import { coveredByEditing } from './editing'
import { applyFilter, excludedBy } from './filter'
import { createStore } from './store'
import { collapsedIds, foldsByCard } from './viewState'
/** 开着的调用树弹框:哪一版文档的哪一块、选中第几个入口,扩展端发来的树 */
export interface OpenCall {
anchor: string
version: number
entry: number
status: 'loading' | 'ready'
name: string
where: string
kind: string
badge?: KindBadge
trees: CallTree[]
contractMiss?: ContractMiss
}
/** 页面状态里由扩展端发来、由用户点出来的那些;别的都由它们算出来(见 Page)。 */
export interface PageInputs {
doc: DocumentMessage | null
/** 停在「编辑」的块 */
editing: string[]
/** 用户自己点过折叠箭头的块、属性行和子行:id → 折叠着没有 */
folds: Record<string, boolean>
/** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null
/** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */
reveal: Reveal | null
/** 扩展端发来的块筛选(整个工作区共用);本地不改,改了发给扩展端、等它发回来 */
excluded: Excluded
/** 跳到定义时临时露出来的块(目标和它的各层祖先),筛选一变、文档一刷新就回到筛选状态 */
keep: ReadonlySet<string>
/** 这份文件相比对比基准改了什么:审查工作副本里的、从「未提交的更改」点开的有改动的文件扩展端算好了补来,不比时是 null */
diff: DocumentDiff | null
/** 「只看改动」开着(整个工作区共用,扩展端记着);本地不改,改了发给扩展端、等它发回来 */
onlyChanges: boolean
/** 「只看改动」时点开过的几段没变的块(按第一块的 id),筛选一变就重新折 */
openedRuns: ReadonlySet<string>
/** 后台数出的各块落在几棵调用树里,是哪一版文档的 */
callCounts: { version: number, counts: Record<string, number> } | undefined
openCall: OpenCall | null
}
/** 整页的状态:输入的那些,和由它们算出来的;块树要的那部分在 tree 上,卡片按自己那部分订阅。 */
export interface Page extends PageInputs {
/** 标差异时各块改了什么;解析失败、不比时没有 */
changes: DiffTree | undefined
collapsed: ReadonlySet<string>
filter: FilterView
covered: ReadonlyMap<string, string>
/** 「只看改动」折起来的块 */
folded: FoldedRuns | undefined
/** 页面上没画出来的块:被筛掉的,和「只看改动」折起来的 */
unshown: ReadonlySet<string>
stops: ChangeStop[]
tree: BlockTreeState
}
/** 整页状态的 store:update 改输入,算出来的部分只在它依赖的输入换了时重算,没换的还是原来那个对象。 */
export interface PageStore extends ReadableStore<Page> {
update: (patch: Partial<PageInputs> | ((page: Page) => Partial<PageInputs>)) => void
}
const NONE: ReadonlySet<string> = new Set()
const NO_BLOCKS: BlockView[] = []
const NO_COUNTS: ReadonlyMap<string, number> = new Map()
/** 只记上一次:参数(逐个同一个对象)和上次一样就交回上次的结果,同 memoize-one */
function memoizeOne<A extends unknown[], R>(compute: (...args: A) => R): (...args: A) => R {
let last: { args: A, result: R } | undefined
return (...args: A): R => {
if (last && last.args.length === args.length && last.args.every((arg, index) => Object.is(arg, args[index])))
return last.result
const result = compute(...args)
last = { args, result }
return result
}
}
export function createPageStore(initial: Pick<PageInputs, 'editing' | 'folds'>): PageStore {
// 按 doc.blocks 而不是 doc 认:同一版重发、只改了版本号时块是原来那份(见 shareUnchanged),算出来的也还是原来的
const changesOf = memoizeOne((blocks: BlockView[], version: number, error: boolean, diff: DocumentDiff | null, excluded: Excluded) => error
? undefined
// 删掉的块按块筛选同样藏
: diffTree(blocks, diff, version, removed => !excludedBy(removed.block, excluded)))
const collapsedOf = memoizeOne((blocks: BlockView[], folds: Record<string, boolean>, changes: DiffTree | undefined) => collapsedIds(blocks, folds, openedByDiff(changes)))
const filterOf = memoizeOne(applyFilter)
const editingOf = memoizeOne((editing: string[]) => new Set(editing))
const coveredOf = memoizeOne(coveredByEditing)
const foldedOf = memoizeOne((blocks: BlockView[], changes: DiffTree | undefined, onlyChanges: boolean, filter: FilterView, covered: ReadonlyMap<string, string>, openedRuns: ReadonlySet<string>, keep: ReadonlySet<string>) => changes && onlyChanges
? foldUnchanged(blocks, changes, block => !filter.hidden.has(block.id) && !covered.has(block.id), openedRuns, keep)
: undefined)
const unshownOf = memoizeOne((filter: FilterView, folded: FoldedRuns | undefined) => folded ? new Set([...filter.hidden, ...folded.folded]) : filter.hidden)
const stopsOf = memoizeOne((blocks: BlockView[], changes: DiffTree | undefined, filter: FilterView) => changes ? changeStops(blocks, changes, filter.hidden) : [])
let folds: ReadonlyMap<string, ReadonlySet<string>> | undefined
const foldsOf = memoizeOne((collapsed: ReadonlySet<string>) => {
folds = foldsByCard(collapsed, folds)
return folds
})
const countsOf = memoizeOne((version: number | undefined, callCounts: PageInputs['callCounts']) => callCounts && callCounts.version === version
? new Map(Object.entries(callCounts.counts))
: NO_COUNTS)
const treeOf = memoizeOne((
version: number,
editing: ReadonlySet<string>,
focusId: string | null,
counts: ReadonlyMap<string, number>,
openAnchor: string | undefined,
collapsed: ReadonlySet<string>,
folds: ReadonlyMap<string, ReadonlySet<string>>,
filter: FilterView,
covered: ReadonlyMap<string, string>,
changes: DiffTree | undefined,
folded: FoldedRuns | undefined,
): BlockTreeState => ({
edit: { version, editing, focusId },
callTree: { counts, ...(openAnchor !== undefined && { openAnchor }) },
collapsed,
folds,
hidden: filter.hidden,
temporary: filter.temporary,
covered,
...(changes && { diff: changes }),
...(folded && { folded }),
}))
const derive = (inputs: PageInputs): Page => {
const { doc } = inputs
const blocks = doc?.blocks ?? NO_BLOCKS
const version = doc?.version ?? 0
const changes = doc ? changesOf(blocks, version, doc.error !== undefined, inputs.diff, inputs.excluded) : undefined
const collapsed = collapsedOf(blocks, inputs.folds, changes)
const filter = filterOf(blocks, inputs.excluded, inputs.keep)
const editing = editingOf(inputs.editing)
const covered = coveredOf(blocks, editing)
const folded = foldedOf(blocks, changes, inputs.onlyChanges, filter, covered, inputs.openedRuns, inputs.keep)
const openAnchor = inputs.openCall?.version === version ? inputs.openCall.anchor : undefined
return {
...inputs,
changes,
collapsed,
filter,
covered,
folded,
unshown: unshownOf(filter, folded),
stops: stopsOf(blocks, changes, filter),
tree: treeOf(version, editing, inputs.focusId, countsOf(doc?.version, inputs.callCounts), openAnchor, collapsed, foldsOf(collapsed), filter, covered, changes, folded),
}
}
const store = createStore(derive({
doc: null,
editing: initial.editing,
folds: initial.folds,
focusId: null,
reveal: null,
excluded: {},
keep: NONE,
diff: null,
onlyChanges: false,
openedRuns: NONE,
callCounts: undefined,
openCall: null,
}))
return {
get: store.get,
subscribe: store.subscribe,
update: patch => store.set(page => derive({ ...page, ...(typeof patch === 'function' ? patch(page) : patch) })),
}
}
+23 -2
View File
@@ -5,6 +5,8 @@
*/
import type { AskRound, ProfileChoice, QaEntryView, QaMessage, QaThreadView, RoundEnd } from './types'
import { shareUnchanged } from './sharing'
import { shallowEqual } from './store'
/** 这次打开以来的一轮 */
export interface Ask {
@@ -92,7 +94,8 @@ export function chatReducer<S extends ChatState>(state: S, action: ChatAction):
return {
...state,
available: action.available,
threads: action.threads,
// 没变的记录还是原来的对象:块下面的对话区按自己那几条订阅,别的块的记录变了它不重画
threads: shareUnchanged(state.threads, action.threads),
asks: [...state.asks, ...adopted],
open: adopted.reduce((open, round) => withOpen(open, round.anchor), state.open),
}
@@ -137,7 +140,7 @@ export function qaReducer(state: QaState, action: QaAction): QaState {
switch (action.type) {
case 'qa': {
const { available, threads, current, path, subject } = action.message
return { ...chatReducer(state, { type: 'sync', available, threads, current }), path, subject }
return { ...chatReducer(state, { type: 'sync', available, threads, current }), path: shareUnchanged(state.path, path), subject }
}
default:
return chatReducer(state, action)
@@ -163,6 +166,24 @@ export function historyOf(state: ChatState & { path?: QaEntryView[] }, anchor: s
return entries.filter(entry => !current.has(entry.id))
}
/** 一块下面的对话区要画的:灰字历史、当前这一轮、展开着没有、输入框旁边的 profile 下拉。 */
export interface ChatView {
history: QaEntryView[]
asks: Ask[]
open: boolean
profiles: ProfileChoice
}
/** anchor 那块的对话区(顶部问答是 PATH_ASK_ANCHOR)。块按自己的记号订阅它,比较用 sameChat。 */
export function chatOf(state: ChatState & { path?: QaEntryView[] }, anchor: string): ChatView {
return { history: historyOf(state, anchor), asks: asksOf(state, anchor), open: state.open.includes(anchor), profiles: state.profiles }
}
/** 两次选出来的对话区一样:同样的几条历史、几轮(同一个对象),展开状态和下拉一样。别的块问答变了,这块不重画。 */
export function sameChat(a: ChatView, b: ChatView): boolean {
return a.open === b.open && a.profiles === b.profiles && shallowEqual(a.history, b.history) && shallowEqual(a.asks, b.asks)
}
const MINUTE = 60_000
const DAY = 24 * 60 * MINUTE
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { shareUnchanged } from './sharing'
describe('新消息里没变的部分沿用上一份的对象', () => {
const prev = {
version: 1,
blocks: [
{ id: '0', translation: [{ text: 'a', offset: 1 }], children: [] },
{ id: '1', translation: [{ text: 'b', offset: 5 }], children: [{ id: '1.0', children: [] }] },
],
}
it('整份一样:交回上一份本身', () => {
expect(shareUnchanged(prev, structuredClone(prev))).toBe(prev)
})
it('只改了一块:那一块(连同它的各层父对象)是新的,别的块还是原来的对象', () => {
const next = structuredClone(prev)
next.version = 2
next.blocks[1].translation[0].offset = 6
const shared = shareUnchanged(prev, next)
expect(shared).not.toBe(prev)
expect(shared).toEqual(next)
expect(shared.blocks).not.toBe(prev.blocks)
expect(shared.blocks[0]).toBe(prev.blocks[0])
expect(shared.blocks[1]).not.toBe(prev.blocks[1])
expect(shared.blocks[1].children).toBe(prev.blocks[1].children)
})
it('多了、少了项或键都算变了;没有上一份时原样交回', () => {
const longer = { ...structuredClone(prev), blocks: [...structuredClone(prev.blocks), { id: '2', translation: [], children: [] }] }
const shared = shareUnchanged(prev, longer)
expect(shared.blocks).not.toBe(prev.blocks)
expect(shared.blocks[0]).toBe(prev.blocks[0])
const withKey = { ...structuredClone(prev), error: 'x' }
expect(shareUnchanged(prev, withKey)).not.toBe(prev)
const { version: _, ...withoutKey } = structuredClone(prev)
expect(shareUnchanged(prev, withoutKey)).not.toBe(prev)
expect(shareUnchanged(undefined, withKey)).toBe(withKey)
})
})
@@ -0,0 +1,37 @@
/**
* 新收到的一份数据里,和上一份内容一样的部分换回上一份里的那个对象(整份一样就交回上一份本身),
* 同 TanStack Query 的 replaceEqualDeep:扩展端每次发来的都是新对象,换回去之后没变的块、行还是原来那个,按卡片订阅的组件看得出没变、不重画。
* 只认消息里那样的数据:普通对象、数组、原始值。
*/
export function shareUnchanged<T>(prev: unknown, next: T): T {
if (Object.is(prev, next))
return next
if (Array.isArray(prev) && Array.isArray(next)) {
let same = prev.length === next.length
const shared = next.map((item: unknown, index) => {
const value = shareUnchanged(prev[index], item)
same &&= Object.is(value, prev[index])
return value
})
return (same ? prev : shared) as T
}
if (isPlainObject(prev) && isPlainObject(next)) {
const keys = Object.keys(next)
let same = keys.length === Object.keys(prev).length
const shared: Record<string, unknown> = {}
for (const key of keys) {
const value = shareUnchanged(prev[key], next[key])
same &&= Object.hasOwn(prev, key) && Object.is(value, prev[key])
shared[key] = value
}
return (same ? prev : shared) as T
}
return next
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null)
return false
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import type { Root } from 'react-dom/client'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createStore, selectStore, shallowEqual, useStore } from './store'
let container: HTMLDivElement
let root: Root
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
vi.unstubAllGlobals()
})
describe('按自己用到的那部分订阅', () => {
it('选出来的值变了才重画;按 isEqual 相等时还是上次那个对象', async () => {
const store = createStore({ counts: { a: 1, b: 1 }, other: 0 })
const seen: unknown[] = []
function Count({ name }: { name: 'a' | 'b' }): null {
seen.push(useStore(store, state => ({ count: state.counts[name] }), shallowEqual))
return null
}
await act(async () => root.render(<Count name="a" />))
expect(seen).toHaveLength(1)
await act(async () => store.set(state => ({ ...state, other: 1 })))
await act(async () => store.set(state => ({ ...state, counts: { ...state.counts, b: 2 } })))
expect(seen).toHaveLength(1)
await act(async () => store.set(state => ({ ...state, counts: { ...state.counts, a: 3 } })))
expect(seen).toEqual([{ count: 1 }, { count: 3 }])
// props 变了重新选:选出来的一样就还是上次那个对象
await act(async () => root.render(<Count name="b" />))
await act(async () => root.render(<Count name="b" />))
expect(seen[2]).toEqual({ count: 2 })
expect(seen[3]).toBe(seen[2])
})
it('换成同一个对象不通知;一部分当成 store 时,整份没换就选出同一个对象', () => {
const store = createStore({ names: ['x'], other: 0 })
const listener = vi.fn()
store.subscribe(listener)
store.set(state => state)
expect(listener).not.toHaveBeenCalled()
const names = selectStore(store, state => ({ names: state.names }))
const first = names.get()
expect(names.get()).toBe(first)
store.set(state => ({ ...state, other: 1 }))
expect(listener).toHaveBeenCalledTimes(1)
expect(names.get()).not.toBe(first)
expect(names.get()).toEqual(first)
})
})
+84
View File
@@ -0,0 +1,84 @@
import { useRef, useSyncExternalStore } from 'react'
/** 放在 React 外面的一份状态:能读、能订阅变化。组件按自己用到的那部分订阅(useStore)。 */
export interface ReadableStore<T> {
get: () => T
subscribe: (listener: () => void) => () => void
}
/** 能改的一份状态:换成新的一份(不可变),和原来不是同一个对象时通知订阅的组件。 */
export interface Store<T> extends ReadableStore<T> {
set: (next: T | ((prev: T) => T)) => void
}
export function createStore<T>(initial: T): Store<T> {
let state = initial
const listeners = new Set<() => void>()
return {
get: () => state,
set: (next) => {
const value = typeof next === 'function' ? (next as (prev: T) => T)(state) : next
if (Object.is(value, state))
return
state = value
for (const listener of listeners)
listener()
},
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
/** 一份不会变的状态:没有订阅方的地方(定义卡片浮窗、钉住的行、测试)给个固定值。 */
export function staticStore<T>(state: T): ReadableStore<T> {
return { get: () => state, subscribe: () => () => {} }
}
/** 两个值浅比较相等:同一个值,或同样的键(数组是同样的项)各自是同一个值。 */
export function shallowEqual<T>(a: T, b: T): boolean {
if (Object.is(a, b))
return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null)
return false
if (Array.isArray(a) || Array.isArray(b))
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, index) => Object.is(item, b[index]))
const keys = Object.keys(a)
return keys.length === Object.keys(b).length
&& keys.every(key => Object.hasOwn(b, key) && Object.is((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]))
}
/**
* 订阅 store 里的一部分:select 从整份状态里选出这个组件要的值,状态变了、选出来的值按 isEqual 也变了才重画,
* 一样时交回上次那个值。同 React 官方 use-sync-external-store 的 useSyncExternalStoreWithSelector。
*/
export function useStore<T, S>(store: ReadableStore<T>, select: (state: T) => S, isEqual: (a: S, b: S) => boolean = Object.is): S {
/** 上次选的:整份状态、用的 select、选出来的值。状态和 select 都没换就交回它;新选出来的和它相等也交回它,props 变了重新选也不换对象 */
const last = useRef<{ state: T, select: (state: T) => S, selection: S } | null>(null)
const getSelection = (): S => {
const state = store.get()
const memo = last.current
if (memo && Object.is(memo.state, state) && memo.select === select)
return memo.selection
const next = select(state)
const selection = memo && isEqual(memo.selection, next) ? memo.selection : next
last.current = { state, select, selection }
return selection
}
return useSyncExternalStore(store.subscribe, getSelection, getSelection)
}
/** store 里的一部分当成一份只读的 store:select 只在整份状态换了时重选(交回的对象要能当快照用,同一份状态选出同一个对象)。 */
export function selectStore<T, S>(store: ReadableStore<T>, select: (state: T) => S): ReadableStore<S> {
let memo: { state: T, selection: S } | undefined
return {
get: () => {
const state = store.get()
if (!memo || !Object.is(memo.state, state))
memo = { state, selection: select(state) }
return memo.selection
},
subscribe: store.subscribe,
}
}
@@ -1,15 +1,15 @@
import type { Click, Highlight, ScrollPosition } from './occurrences'
import type { TextMarks } from './SegmentText'
import type { Store } from './store'
import type { BlockView, Occurrence } from './types'
import { createContext, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { documentNameAt, PEEK_ATTRIBUTE } from './definitionPeek'
import { clickAction, KeyScroll, OccurrenceHighlight, occurrenceJumpKey, OccurrenceNavigation } from './occurrences'
import { occurrencePlan } from './reveal'
import { useStore } from './store'
import { showInViewport } from './useReveal'
import { onMessage, postMessage } from './vscode'
/** 眼下高亮着的名字,SegmentText 按它给各处出现上底色、给当前那一处描边。 */
export const HighlightContext = createContext<Highlight | undefined>(undefined)
/** 自己管点击的东西:点在上面(名字以外的地方)高亮不动。定义卡片浮窗里的名字是别的文件的,点了也不动 */
const CONTROLS = `a, button, input, select, textarea, [role="listbox"], [role="dialog"], .cr-filter-bar, .cr-prop-label, [${PEEK_ATTRIBUTE}]`
@@ -72,15 +72,16 @@ function scrollBack({ x, y }: ScrollPosition, own: (position: ScrollPosition) =>
}
/**
* 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;返回眼下高亮着的名字,和筛选栏上「上一处 / 下一处」用的跳转。
* 单击名字高亮它在文档里的各处出现,点空白处、Esc、换文档版本或翻译清掉;高亮着的名字记进 marks(页面文字的标记),返回它和筛选栏上「上一处 / 下一处」用的跳转。
* 高亮着时扩展端的「跳到下一处 / 上一处高亮」命令发来 moveOccurrence,筛选栏上的按钮走同一条跳转:当前那一处换成下一处页面上找得到的出现,
* 展开把它折叠住的块、属性行和子行,再把它滚进视口。按钮不是按键,不按 ⌘↓ 那样拉回按键时的位置。
* 默认的 ⌘↓ / ⌘↑(Windows 是 ctrl)在能跳的时候取消浏览器滚到底 / 滚到顶,但不停事件继续传(VS Code 在后面才把按键收成命令)。
* 别的键浏览器仍可能自己滚,跳的时候盖掉它,见 KeyScroll。
*/
export function useOccurrences(version: number | undefined, translatorId: string | undefined, page: OccurrencePage | undefined): { highlight: Highlight | undefined, moveOccurrence: (step: 1 | -1) => void } {
const [highlight, setHighlight] = useState<Highlight | undefined>()
const [occurrences] = useState(() => new OccurrenceHighlight(postMessage, setHighlight))
export function useOccurrences(version: number | undefined, translatorId: string | undefined, page: OccurrencePage | undefined, marks: Store<TextMarks>): { highlight: Highlight | undefined, moveOccurrence: (step: 1 | -1) => void } {
// 高亮记在页面文字的标记里(TextMarksContext),各段文字按自己那几段订阅,换个名字高亮只重画画法变了的那几段
const highlight = useStore(marks, current => current.highlight)
const [occurrences] = useState(() => new OccurrenceHighlight(postMessage, next => marks.set(current => ({ ...current, highlight: next }))))
const [navigation] = useState(() => new OccurrenceNavigation(postMessage, { focused: document.hasFocus(), typing: isTyping(document.activeElement) }))
const [keyScroll] = useState(() => new KeyScroll())
/** 用键盘或筛选栏按钮跳过几次:每跳一次,画完后把当前那一处滚进视口 */
+30 -29
View File
@@ -1,19 +1,39 @@
import type { QaTree } from './BlockTree'
import type { QaState } from './qa'
import { useEffect, useMemo, useReducer, useRef } from 'react'
import type { QaAction, QaState } from './qa'
import { useEffect, useState } from 'react'
import { EMPTY_QA, newAskId, qaReducer } from './qa'
import { toAskImage } from './qaImages'
import { createStore } from './store'
import { onMessage, postMessage } from './vscode'
/** 逐字长出来的回答最多隔多久画一次:一个字一画整棵块树跟着重画,大文件会卡 */
/** 逐字长出来的回答最多隔多久画一次:一个字一画,正在答的那块的对话区跟着重画太勤 */
export const ANSWER_FRAME_MS = 80
/** 问 AI:收扩展端的问答、正在答的进展,给块树用的状态和动作(问、停、删、展开 / 收起)。 */
/** 问 AI:收扩展端的问答、正在答的进展,给块树用的状态(块按自己的记号订阅)和动作(问、停、删、展开 / 收起)。整页一份,不跟着状态变。 */
export function useQa(): QaTree {
const [state, dispatch] = useReducer(qaReducer, EMPTY_QA)
/** 动作里读眼下的状态(这块展开着没有) */
const current = useRef<QaState>(state)
current.current = state
const [{ tree, dispatch }] = useState(() => {
const store = createStore<QaState>(EMPTY_QA)
const dispatch = (action: QaAction): void => store.set(state => qaReducer(state, action))
/** 收起就结束这一轮,扩展端留着的那一轮(出错、被叫停的)也去掉 */
const close = (anchor: string): void => {
dispatch({ type: 'close', anchor })
postMessage({ type: 'forgetRound', kind: 'ask', anchor })
}
const tree: QaTree = {
store,
onToggle: anchor => store.get().open.includes(anchor) ? close(anchor) : dispatch({ type: 'open', anchor }),
onClose: close,
onAsk: (anchor, question, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question, images: images.map(image => image.url) })
postMessage({ type: 'ask', askId, anchor, question, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopAsk', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
onSelectProfile: id => postMessage({ type: 'selectProfile', kind: 'ask', id }),
}
return { tree, dispatch }
})
useEffect(() => {
/** 攒着没画的回答:askId → 最新的一份 */
@@ -48,26 +68,7 @@ export function useQa(): QaTree {
clearTimeout(timer)
off()
}
}, [])
}, [dispatch])
return useMemo((): QaTree => {
/** 收起就结束这一轮,扩展端留着的那一轮(出错、被叫停的)也去掉 */
const close = (anchor: string): void => {
dispatch({ type: 'close', anchor })
postMessage({ type: 'forgetRound', kind: 'ask', anchor })
}
return {
state,
onToggle: anchor => current.current.open.includes(anchor) ? close(anchor) : dispatch({ type: 'open', anchor }),
onClose: close,
onAsk: (anchor, question, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question, images: images.map(image => image.url) })
postMessage({ type: 'ask', askId, anchor, question, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopAsk', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteQa', anchor, entryId }),
onSelectProfile: id => postMessage({ type: 'selectProfile', kind: 'ask', id }),
}
}, [state])
return tree
}
+35 -33
View File
@@ -1,17 +1,46 @@
import type { RewriteTree } from './BlockTree'
import type { ChatState } from './qa'
import { useEffect, useMemo, useReducer, useRef } from 'react'
import type { ChatAction, ChatState } from './qa'
import { useEffect, useState } from 'react'
import { chatReducer, EMPTY_CHAT, newAskId, unfinished } from './qa'
import { toAskImage } from './qaImages'
import { createStore } from './store'
import { ANSWER_FRAME_MS } from './useQa'
import { onMessage, postMessage } from './vscode'
/**
* 按注释规范重写:收扩展端的重写记录、当前一轮的进展,给块树用的状态和动作。和问 AI 同一套:
* 按注释规范重写:收扩展端的重写记录、当前一轮的进展,给块树用的状态(块按自己的记号订阅)和动作。和问 AI 同一套:
* 排着队的、进行中的、出错被叫停留着的那一轮记在扩展端,页面重新加载后随 qa 消息的 rewriteCurrent 接上。profile 下拉是自己的,和问 AI 的互不影响。
*/
export function useRewrite(): RewriteTree {
const [state, dispatch] = useReducer(chatReducer<ChatState>, EMPTY_CHAT)
const [{ tree, dispatch }] = useState(() => {
const store = createStore<ChatState>(EMPTY_CHAT)
const dispatch = (action: ChatAction): void => store.set(state => chatReducer(state, action))
const tree: RewriteTree = {
store,
onSelectProfile: id => postMessage({ type: 'selectProfile', kind: 'rewrite', id }),
onRewrite: (anchor) => {
if (store.get().asks.some(ask => ask.anchor === anchor && unfinished(ask)))
return
const askId = newAskId()
// 那句技能命令由扩展端拼好,开始后随 rewriteProgress 报上来
dispatch({ type: 'ask', askId, anchor, question: '', images: [] })
postMessage({ type: 'rewriteComment', askId, anchor, images: [] })
},
onAsk: (anchor, request, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question: request, images: images.map(image => image.url) })
postMessage({ type: 'rewriteComment', askId, anchor, request, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopRewrite', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteRewrite', anchor, entryId }),
onOpen: anchor => dispatch({ type: 'open', anchor }),
onClose: (anchor) => {
dispatch({ type: 'close', anchor })
postMessage({ type: 'forgetRound', kind: 'rewrite', anchor })
},
}
return { tree, dispatch }
})
useEffect(() => {
/** 攒着没画的进展:askId → 最新的一份 */
@@ -46,34 +75,7 @@ export function useRewrite(): RewriteTree {
clearTimeout(timer)
off()
}
}, [])
}, [dispatch])
/** 动作里读眼下的状态(这块是不是已经在写或排着队) */
const current = useRef(state)
current.current = state
return useMemo((): RewriteTree => ({
state,
onSelectProfile: id => postMessage({ type: 'selectProfile', kind: 'rewrite', id }),
onRewrite: (anchor) => {
if (current.current.asks.some(ask => ask.anchor === anchor && unfinished(ask)))
return
const askId = newAskId()
// 那句技能命令由扩展端拼好,开始后随 rewriteProgress 报上来
dispatch({ type: 'ask', askId, anchor, question: '', images: [] })
postMessage({ type: 'rewriteComment', askId, anchor, images: [] })
},
onAsk: (anchor, request, images) => {
const askId = newAskId()
dispatch({ type: 'ask', askId, anchor, question: request, images: images.map(image => image.url) })
postMessage({ type: 'rewriteComment', askId, anchor, request, images: images.map(toAskImage) })
},
onStop: askId => postMessage({ type: 'stopRewrite', askId }),
onDelete: (anchor, entryId) => postMessage({ type: 'deleteRewrite', anchor, entryId }),
onOpen: anchor => dispatch({ type: 'open', anchor }),
onClose: (anchor) => {
dispatch({ type: 'close', anchor })
postMessage({ type: 'forgetRound', kind: 'rewrite', anchor })
},
}), [state])
return tree
}
@@ -1,7 +1,7 @@
import type { BlockKind, BlockView } from './types'
import type { ViewStateStorage } from './viewState'
import { describe, expect, it } from 'vitest'
import { collapsedIds, createViewStateStore, propertyItemId, propertyRowId, unfoldToShow } from './viewState'
import { collapsedIds, createViewStateStore, foldsByCard, propertyItemId, propertyRowId, unfoldToShow } from './viewState'
/** 模拟 VS Code 的 webview 状态:存进去的要能序列化,页面重新加载后原样取回;也能塞进旧版本存的东西 */
function memoryStorage(initial?: unknown): ViewStateStorage {
@@ -166,3 +166,24 @@ describe('眼下折叠着哪些', () => {
expect([...collapsedIds(BLOCKS, { [outer]: false, [inner]: true })].sort()).toEqual(['0/p2/0/3', '1', '1.0.0', '2', '2.0'])
})
})
describe('折叠着的 id 按卡片分', () => {
it('属性行、子行归它那张卡片;块归它自己和它的父卡片(父卡片画挂在子行上的子块)', () => {
const folds = foldsByCard(new Set(['0/p2', '0/p2/1', '1', '1.0', '1.0.3', 'removed-2/p0']))
expect([...folds.get('0')!]).toEqual(['0/p2', '0/p2/1'])
expect([...folds.get('1')!]).toEqual(['1', '1.0'])
expect([...folds.get('1.0')!]).toEqual(['1.0', '1.0.3'])
expect([...folds.get('1.0.3')!]).toEqual(['1.0.3'])
expect([...folds.get('removed-2')!]).toEqual(['removed-2/p0'])
expect(folds.has('2')).toBe(false)
})
it('和上一次一样的那几张卡片沿用上一次的那份:别的卡片折叠、展开时它订阅的还是同一个对象', () => {
const before = foldsByCard(new Set(['0/p2', '1', '2/p0']))
const after = foldsByCard(new Set(['0/p2', '1', '2/p0', '2/p1']), before)
expect(after.get('0')).toBe(before.get('0'))
expect(after.get('1')).toBe(before.get('1'))
expect(after.get('2')).not.toBe(before.get('2'))
expect([...after.get('2')!]).toEqual(['2/p0', '2/p1'])
})
})
@@ -98,6 +98,39 @@ export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Recor
return collapsed
}
/**
* 折叠着的 id 按卡片分好,卡片只订阅自己那份:属性行、子行的 id(`<块 id>/…`)归它那张卡片;块 id 归它自己,
* 也归它的父卡片(挂在父卡片子行上的子块由父卡片的属性表画、点子行开关)。
* 和 previous 里同一张卡片的那份一样时沿用那一份:别的卡片折叠、展开时,这张卡片订阅的还是原来那个对象,不重画。
*/
export function foldsByCard(collapsed: ReadonlySet<string>, previous?: ReadonlyMap<string, ReadonlySet<string>>): ReadonlyMap<string, ReadonlySet<string>> {
const groups = new Map<string, Set<string>>()
const add = (card: string, id: string): void => {
const group = groups.get(card)
if (group)
group.add(id)
else
groups.set(card, new Set([id]))
}
for (const id of collapsed) {
const slash = id.indexOf('/')
if (slash >= 0) {
add(id.slice(0, slash), id)
continue
}
add(id, id)
const dot = id.lastIndexOf('.')
if (dot >= 0)
add(id.slice(0, dot), id)
}
if (!previous)
return groups
return new Map([...groups].map(([card, ids]) => {
const before = previous.get(card)
return [card, before && before.size === ids.size && [...ids].every(id => before.has(id)) ? before : ids]
}))
}
/**
* 属性表里带子行的那一行(输入参数、导入项、字典 / 对象的值)的折叠 id,和块 id 放在同一份 folds 里:
* `<块 id>/p<行序号>`,例如 '6.1.0/p3'。块 id 只由数字和点组成,带 '/' 的不会和任何块 id 撞上。