diff --git a/vscode/webview-ui/src/codeReader/App.test.tsx b/vscode/webview-ui/src/codeReader/App.test.tsx index de70f1b..a7e3894 100644 --- a/vscode/webview-ui/src/codeReader/App.test.tsx +++ b/vscode/webview-ui/src/codeReader/App.test.tsx @@ -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() + 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 => { + 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 = (value: T): T => JSON.parse(JSON.stringify(value)) as T + + async function rendered(msg: ExtensionToWebview): Promise> { + 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('[data-offset="30"]')!.click()) + const asked = [...host.posted].reverse().find((msg): msg is Extract => (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('[data-id="1"] [aria-label="问 AI"]')!.click()) + const counts = renders.stop() + expect(counts.BlockItem).toBe(1) + expect(container.querySelectorAll('.cr-qa')).toHaveLength(2) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index ee58fb5..8ea4c17 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -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 - const NONE: ReadonlySet = 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(null) - const [view, setView] = useState(savedBlockModes) - const [focusId, setFocusId] = useState(null) - /** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */ - const [reveal, setReveal] = useState(null) - /** 扩展端发来的块筛选(整个工作区共用);本地不改,改了发给扩展端、等它发回来 */ - const [excluded, setExcluded] = useState({}) - /** 跳到定义时临时露出来的块(目标和它的各层祖先),筛选一变、文档一刷新就回到筛选状态 */ - const [keep, setKeep] = useState>(NONE) - /** 页面上这个文档版本的中文名:文档带来的,扩展端查到了再换 */ - const [names, setNames] = useState() - /** 这份文件相比对比基准改了什么:审查工作副本里的、从「未提交的更改」点开的有改动的文件扩展端算好了补来,不比时是 null */ - const [diff, setDiff] = useState(null) - /** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */ - const editing = useRef(view.editing) - /** 页面上这份文档的版本:调用树的回答不是这一版就丢掉 */ - const docVersion = useRef(undefined) - const [callCounts, setCallCounts] = useState<{ version: number, counts: Record }>() - const [openCall, setOpenCall] = useState(null) + const [page] = useState(() => createPageStore(viewState.get())) + /** 页面上文字的标记:这个文档版本的中文名(文档带来的,扩展端查到了再换)和单击高亮的名字;各段文字按自己那几段订阅 */ + const [marks] = useState(() => createStore({ names: new Map() })) /** 页面重新加载前开着的调用树弹框:第一份文档到了,那块还在就重新打开;到之前记着的不动 */ const restoreCall = useRef(viewState.get().callTree) /** 块树所在的页面内容:钉住卡片时在这里面量 */ const content = useRef(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>(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 ( - - - postMessage({ type: 'setFilter', excluded: next })} - translators={doc.translators} - translatorId={doc.translatorId} - onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })} - below={diff && doc.error === undefined && ( - postMessage({ type: 'setOnlyChanges', on: !onlyChanges })} - canMove={stops.length > 0} - onMove={nav.move} - /> - )} - /> - {changes && diff && } -
- {qa.state.available && ( -
- qa.onAsk(PATH_ASK_ANCHOR, question, images)} - onStop={qa.onStop} - onDelete={entryId => qa.onDelete(PATH_ASK_ANCHOR, entryId)} - onClose={() => undefined} - /> -
- )} - {diff?.newFile !== undefined && doc.error === undefined && } - {doc.error !== undefined && ( -
- -
-
- 解析失败: - {doc.error} -
-
点编辑器右上角的「编辑」查看源码。
-
-
- )} - {doc.blocks.length === 0 - ? doc.error === undefined && qa.state.subject === 'file' &&
这个文件里没有代码。
- : doc.blocks.some(block => !filter.hidden.has(block.id)) - ? - :
所有块都被筛选隐藏了。
} -
- {sticky?.enabled && } - - {openCall && ( - setOpenCall(prev => prev && { ...prev, entry })} - onClose={() => setOpenCall(null)} - onJump={(jump) => { - setOpenCall(null) - postMessage({ type: 'goToCallNode', ...jump }) - }} + + postMessage({ type: 'setFilter', excluded: next })} + translators={doc.translators} + translatorId={doc.translatorId} + onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })} + below={diff && doc.error === undefined && ( + postMessage({ type: 'setOnlyChanges', on: !onlyChanges })} + canMove={stops.length > 0} + onMove={nav.move} /> )} -
-
+ /> + {changes && diff && } +
+ {qaAvailable && ( +
+ qa.onAsk(PATH_ASK_ANCHOR, question, images)} + onStop={qa.onStop} + onDelete={entryId => qa.onDelete(PATH_ASK_ANCHOR, entryId)} + onClose={() => undefined} + /> +
+ )} + {diff?.newFile !== undefined && doc.error === undefined && } + {doc.error !== undefined && ( +
+ +
+
+ 解析失败: + {doc.error} +
+
点编辑器右上角的「编辑」查看源码。
+
+
+ )} + {doc.blocks.length === 0 + ? doc.error === undefined && qaSubject === 'file' &&
这个文件里没有代码。
+ : doc.blocks.some(block => !filter.hidden.has(block.id)) + ? + :
所有块都被筛选隐藏了。
} +
+ {sticky?.enabled && } + + {openCall && ( + page.update(({ openCall: prev }) => ({ openCall: prev && { ...prev, entry } }))} + onClose={() => page.update({ openCall: null })} + onJump={(jump) => { + page.update({ openCall: null }) + postMessage({ type: 'goToCallNode', ...jump }) + }} + /> + )} + ) } diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index 1991a25..be4bf5c 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -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, rewrite: ChatState = { ...EMPTY_CHAT, available: true }): BlockTreeState { +/** 能编辑、能重写注释的块树,动作什么都不做;view 盖在默认的状态上(什么都没折、没筛掉) */ +function tree(view: Partial> = {}, 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() +} + function render(folds: Record): string { - return renderToStaticMarkup() + 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() + 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() + 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() - expect(markup).toMatch(/
]*data-anchor="class AgentService > function get_default_agent"[\s\S]*href="symbol:get_agent_admitted"/) + expect(markup([method], tree())).toMatch(/
]*data-anchor="class AgentService > function get_default_agent"[\s\S]*href="symbol:get_agent_admitted"/) }) }) describe('「注释」一行和「重写注释」按钮', () => { const comment = { label: '注释', value: { markdown: '按名字找' } } - const html = (block: BlockView): string => renderToStaticMarkup() + 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() + const collapsedMarkup = markup([block], tree({}, closed)) expect(collapsedMarkup.replace(/<[^>]*>/g, '')).toContain('重写过 1 次') inToolbar(collapsedMarkup, '展开重写记录') - const opened = renderToStaticMarkup() + const opened = markup([block], tree({}, { ...closed, open: [block.anchor] })) expect(opened).toMatch(/role="group" aria-label="块操作">]*aria-label="按注释规范重写"/) expect(opened).toContain('
') 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() - return /]*aria-label="按注释规范重写"[^>]*>/.exec(markup)?.[0] ?? '' + return /]*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 = new Set(), diff?: BlockTreeState['diff']): HTMLElement { const root = document.createElement('div') - root.innerHTML = renderToStaticMarkup() + root.innerHTML = markup([block], tree({ collapsed, ...(diff && { diff }) })) return root } diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index dffc090..dcbd6c1 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -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 /** 刚点了「编辑」、要把光标放进去的块 */ focusId: string | null - onFocused: () => void - onToggleEdit: (id: string) => void } -/** 函数、类落在几棵调用树里。定义浮窗不传,跟问 AI 一样不画这排按钮。 */ -export interface CallTreeTools { +/** 函数、类落在几棵调用树里,开着哪一块的弹框。定义浮窗没有,跟问 AI 一样不画这排按钮。 */ +export interface CallTreeState { counts: ReadonlyMap 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 + /** collapsed 按卡片分好的(见 foldsByCard):卡片和它的属性表只看自己那份 */ + folds: ReadonlyMap> /** 按筛选不画的块(连同子孙),见 applyFilter */ hidden: ReadonlySet /** 本来被筛掉、跳到定义时临时显示的块 */ temporary: ReadonlySet /** 被正在编辑的祖先盖住、不画的块(它们的代码在祖先的编辑框里),见 coveredByEditing */ covered: ReadonlyMap - /** 种类叫什么(种类图标的悬停提示和 aria-label):翻译插件给种类起的名字(库规则的种类是规则给的 badge.label),没给就是 id */ - kindName: (kind: string) => string + /** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */ + diff?: DiffTree + /** 「只看改动」开着时折起来的连着没变的块(见 foldUnchanged) */ + folded?: FoldedRuns +} + +/** + * 一棵块树:会变的状态(state,卡片按自己那部分订阅)和动作。整页一份、不跟着状态变,卡片拿到的回调一直是同一个, + * 卡片照 memo 跳过重画。 + */ +export interface BlockTreeHandle { + state: ReadableStore onToggleFold: (id: string) => void /** 一次记下几块、几行折叠还是展开(点子行展开一块、同一行里别的收起) */ onSetFolds: (folds: Record) => 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 /** 点了块右上角的按钮:按技能命令重写这块(开新会话),展开它的重写记录 */ 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 onToggle: (anchor: string) => void onClose: (anchor: string) => void /** images 是贴进输入框的图片,随问题一起发 */ @@ -95,19 +108,47 @@ export interface QaTree { onSelectProfile: (id: string) => void } +const BlockTreeContext = createContext(null) + +function useBlockTree(): BlockTreeHandle { + const tree = useContext(BlockTreeContext) + if (!tree) + throw new Error('块树外面没有 BlockTree') + return tree +} + +/** 一棵块树:blocks 是顶层的块,tree 是它的状态和动作(见 BlockTreeHandle)。 */ +export function BlockTree({ blocks, tree }: { blocks: BlockView[], tree: BlockTreeHandle }): ReactElement { + return ( + + + + ) +} + +/** 块树的状态:folds 由 collapsed 按卡片分好(见 foldsByCard),和 previous 里一样的那几份沿用。 */ +export function treeState(state: Omit, previous?: BlockTreeState): BlockTreeState { + return { ...state, folds: foldsByCard(state.collapsed, previous?.folds) } +} + +const NO_FOLDS: ReadonlySet = new Set() +const NO_REMOVED: readonly PlacedRemoved[] = [] +/** 没有问 AI、重写注释的块树(定义卡片浮窗):一直不能用 */ +const NO_CHAT: ReadableStore = staticStore(EMPTY_CHAT) + /** * 块下面有没有画出来的子块(删掉的子块也算):子块都被筛掉、被盖住时不留空的一层、不给折叠箭头。 * 挂在卡片子行上的子块(BlockSlot)画在卡片属性表里,不算。 */ -function hasShownChildren(block: BlockView, 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 (
@@ -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 ( - +
@@ -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 ( - +
diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx index ef8616c..bd84207 100644 --- a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx +++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx @@ -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(), - 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(), 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( - {} })} />, @@ -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( {} }, false)} />) + await render( {} }, 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 ( <> - setOpen(true) })} /> diff --git a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx index e3b1472..9858d94 100644 --- a/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx +++ b/vscode/webview-ui/src/codeReader/DefinitionPeekPopup.tsx @@ -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 = new Set() @@ -19,28 +20,30 @@ const NOT_COVERED: ReadonlyMap = new Map() * 名字的下标是那个文件的:中文名换成卡片带来的,不画页面上的高亮;Markdown 不认不带路径的引用。 */ export function PeekTree({ peek }: { peek: DefinitionPeek }): ReactElement { - const [folds, setFolds] = useState>({}) - 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, previous?: BlockTreeState): BlockTreeState => + treeState({ collapsed: collapsedIds([peek.block], folds), hidden: NONE, temporary: NONE, covered: NOT_COVERED }, previous) + let folds: Record = {} + const state = createStore(viewOf(folds)) + const setFolds = (next: Record): 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 ( - - - - - - - + + + + + ) } diff --git a/vscode/webview-ui/src/codeReader/SegmentText.test.tsx b/vscode/webview-ui/src/codeReader/SegmentText.test.tsx index 66b2226..4dd1293 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.test.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.test.tsx @@ -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( diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index dfa10fb..1f8d962 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -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>(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 - const shown = shownName(piece, names) + const mark = marks[index] + const shown = mark?.shown return ( @@ -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) + }) } /** diff --git a/vscode/webview-ui/src/codeReader/StickyLines.test.tsx b/vscode/webview-ui/src/codeReader/StickyLines.test.tsx index a1322c2..a36c8c4 100644 --- a/vscode/webview-ui/src/codeReader/StickyLines.test.tsx +++ b/vscode/webview-ui/src/codeReader/StickyLines.test.tsx @@ -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 { function render(view: BlockView): string { return renderToStaticMarkup( - + - , + , ) } diff --git a/vscode/webview-ui/src/codeReader/StickyLines.tsx b/vscode/webview-ui/src/codeReader/StickyLines.tsx index fa95fe8..ce9bde2 100644 --- a/vscode/webview-ui/src/codeReader/StickyLines.tsx +++ b/vscode/webview-ui/src/codeReader/StickyLines.tsx @@ -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 ( - +
{lines.scopes.map((scope, index) => { const block = byId.get(scope.id) @@ -96,6 +99,6 @@ export function StickyLines({ content, blocks, settings }: { content: RefObject< ) })}
-
+ ) } diff --git a/vscode/webview-ui/src/codeReader/TextPreview.tsx b/vscode/webview-ui/src/codeReader/TextPreview.tsx index 1aa624e..9233c3c 100644 --- a/vscode/webview-ui/src/codeReader/TextPreview.tsx +++ b/vscode/webview-ui/src/codeReader/TextPreview.tsx @@ -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>('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 @@ -19,7 +19,8 @@ export function TextPreview({ text, className }: { text: PreviewText, className?

) -} +}) +TextPreview.displayName = 'TextPreview' export function MarkdownPreview({ html, className }: { html: string, className?: string }): ReactElement { const content = useMemo(() => ({ __html: html }), [html]) diff --git a/vscode/webview-ui/src/codeReader/displayNames.test.tsx b/vscode/webview-ui/src/codeReader/displayNames.test.tsx index 1d12a24..22ffff9 100644 --- a/vscode/webview-ui/src/codeReader/displayNames.test.tsx +++ b/vscode/webview-ui/src/codeReader/displayNames.test.tsx @@ -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( - + - , + , ) } 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('只认下标和长度都对得上的名字:没下标的译词、长度不一样的(名字改长了)不换', () => { diff --git a/vscode/webview-ui/src/codeReader/displayNames.ts b/vscode/webview-ui/src/codeReader/displayNames.ts index f68a2f6..6c79a8c 100644 --- a/vscode/webview-ui/src/codeReader/displayNames.ts +++ b/vscode/webview-ui/src/codeReader/displayNames.ts @@ -1,34 +1,19 @@ import type { Segment, ShownName } from './types' -import { createContext } from 'react' /** 页面上这个版本的中文名:下标 → 显示成什么。 */ export type NameMap = ReadonlyMap -/** 眼下页面上显示成中文名的名字,SegmentText 按它换字。 */ -export const DisplayNameContext = createContext(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) +} diff --git a/vscode/webview-ui/src/codeReader/kindIcons.tsx b/vscode/webview-ui/src/codeReader/kindIcons.tsx index 2e2e293..efc7fb1 100644 --- a/vscode/webview-ui/src/codeReader/kindIcons.tsx +++ b/vscode/webview-ui/src/codeReader/kindIcons.tsx @@ -43,6 +43,7 @@ import { Variable, Zap, } from 'lucide-react' +import { memo } from 'react' /** 插件定义的这组种类图标,每个名字一个 lucide 图标(逐个 import,打包只带这些)。 */ export const KIND_ICON_COMPONENTS: Record = { @@ -93,7 +94,7 @@ export const KIND_ICON_COMPONENTS: Record = { * 没有颜色的是语言的种类,按 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 ( concept.label).join(',')}> {concepts.map((concept) => { @@ -109,4 +110,5 @@ export function KindIcon({ concepts }: { concepts: readonly BlockConcept[] }): R })} ) -} +}) +KindIcon.displayName = 'KindIcon' diff --git a/vscode/webview-ui/src/codeReader/occurrences.test.ts b/vscode/webview-ui/src/codeReader/occurrences.test.ts index 45848ba..0b2db0f 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.test.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.test.ts @@ -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') diff --git a/vscode/webview-ui/src/codeReader/occurrences.ts b/vscode/webview-ui/src/codeReader/occurrences.ts index 68aaa28..d2b57e4 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.ts @@ -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() + +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 + 字数);没有下标的段(译词、提示词)没有。跳到定义、单击高亮、各处出现画在哪都按它。 diff --git a/vscode/webview-ui/src/codeReader/pageState.ts b/vscode/webview-ui/src/codeReader/pageState.ts new file mode 100644 index 0000000..c7ada57 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/pageState.ts @@ -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 + /** 刚点了「编辑」、要把光标放进去的块 */ + focusId: string | null + /** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */ + reveal: Reveal | null + /** 扩展端发来的块筛选(整个工作区共用);本地不改,改了发给扩展端、等它发回来 */ + excluded: Excluded + /** 跳到定义时临时露出来的块(目标和它的各层祖先),筛选一变、文档一刷新就回到筛选状态 */ + keep: ReadonlySet + /** 这份文件相比对比基准改了什么:审查工作副本里的、从「未提交的更改」点开的有改动的文件扩展端算好了补来,不比时是 null */ + diff: DocumentDiff | null + /** 「只看改动」开着(整个工作区共用,扩展端记着);本地不改,改了发给扩展端、等它发回来 */ + onlyChanges: boolean + /** 「只看改动」时点开过的几段没变的块(按第一块的 id),筛选一变就重新折 */ + openedRuns: ReadonlySet + /** 后台数出的各块落在几棵调用树里,是哪一版文档的 */ + callCounts: { version: number, counts: Record } | undefined + openCall: OpenCall | null +} + +/** 整页的状态:输入的那些,和由它们算出来的;块树要的那部分在 tree 上,卡片按自己那部分订阅。 */ +export interface Page extends PageInputs { + /** 标差异时各块改了什么;解析失败、不比时没有 */ + changes: DiffTree | undefined + collapsed: ReadonlySet + filter: FilterView + covered: ReadonlyMap + /** 「只看改动」折起来的块 */ + folded: FoldedRuns | undefined + /** 页面上没画出来的块:被筛掉的,和「只看改动」折起来的 */ + unshown: ReadonlySet + stops: ChangeStop[] + tree: BlockTreeState +} + +/** 整页状态的 store:update 改输入,算出来的部分只在它依赖的输入换了时重算,没换的还是原来那个对象。 */ +export interface PageStore extends ReadableStore { + update: (patch: Partial | ((page: Page) => Partial)) => void +} + +const NONE: ReadonlySet = new Set() +const NO_BLOCKS: BlockView[] = [] +const NO_COUNTS: ReadonlyMap = new Map() + +/** 只记上一次:参数(逐个同一个对象)和上次一样就交回上次的结果,同 memoize-one */ +function memoizeOne(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): 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, 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, openedRuns: ReadonlySet, keep: ReadonlySet) => 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> | undefined + const foldsOf = memoizeOne((collapsed: ReadonlySet) => { + 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, + focusId: string | null, + counts: ReadonlyMap, + openAnchor: string | undefined, + collapsed: ReadonlySet, + folds: ReadonlyMap>, + filter: FilterView, + covered: ReadonlyMap, + 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) })), + } +} diff --git a/vscode/webview-ui/src/codeReader/qa.ts b/vscode/webview-ui/src/codeReader/qa.ts index d48381b..fe0bfdf 100644 --- a/vscode/webview-ui/src/codeReader/qa.ts +++ b/vscode/webview-ui/src/codeReader/qa.ts @@ -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(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 diff --git a/vscode/webview-ui/src/codeReader/sharing.test.ts b/vscode/webview-ui/src/codeReader/sharing.test.ts new file mode 100644 index 0000000..460ef07 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/sharing.test.ts @@ -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) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/sharing.ts b/vscode/webview-ui/src/codeReader/sharing.ts new file mode 100644 index 0000000..15ab93a --- /dev/null +++ b/vscode/webview-ui/src/codeReader/sharing.ts @@ -0,0 +1,37 @@ +/** + * 新收到的一份数据里,和上一份内容一样的部分换回上一份里的那个对象(整份一样就交回上一份本身), + * 同 TanStack Query 的 replaceEqualDeep:扩展端每次发来的都是新对象,换回去之后没变的块、行还是原来那个,按卡片订阅的组件看得出没变、不重画。 + * 只认消息里那样的数据:普通对象、数组、原始值。 + */ +export function shareUnchanged(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 = {} + 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 { + if (typeof value !== 'object' || value === null) + return false + const proto = Object.getPrototypeOf(value) + return proto === Object.prototype || proto === null +} diff --git a/vscode/webview-ui/src/codeReader/store.test.tsx b/vscode/webview-ui/src/codeReader/store.test.tsx new file mode 100644 index 0000000..073b0a1 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/store.test.tsx @@ -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()) + 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()) + await act(async () => root.render()) + 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) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/store.ts b/vscode/webview-ui/src/codeReader/store.ts new file mode 100644 index 0000000..0b025c8 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/store.ts @@ -0,0 +1,84 @@ +import { useRef, useSyncExternalStore } from 'react' + +/** 放在 React 外面的一份状态:能读、能订阅变化。组件按自己用到的那部分订阅(useStore)。 */ +export interface ReadableStore { + get: () => T + subscribe: (listener: () => void) => () => void +} + +/** 能改的一份状态:换成新的一份(不可变),和原来不是同一个对象时通知订阅的组件。 */ +export interface Store extends ReadableStore { + set: (next: T | ((prev: T) => T)) => void +} + +export function createStore(initial: T): Store { + 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(state: T): ReadableStore { + return { get: () => state, subscribe: () => () => {} } +} + +/** 两个值浅比较相等:同一个值,或同样的键(数组是同样的项)各自是同一个值。 */ +export function shallowEqual(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)[key], (b as Record)[key])) +} + +/** + * 订阅 store 里的一部分:select 从整份状态里选出这个组件要的值,状态变了、选出来的值按 isEqual 也变了才重画, + * 一样时交回上次那个值。同 React 官方 use-sync-external-store 的 useSyncExternalStoreWithSelector。 + */ +export function useStore(store: ReadableStore, 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(store: ReadableStore, select: (state: T) => S): ReadableStore { + 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, + } +} diff --git a/vscode/webview-ui/src/codeReader/useOccurrences.ts b/vscode/webview-ui/src/codeReader/useOccurrences.ts index 2ca1a52..cd48b1e 100644 --- a/vscode/webview-ui/src/codeReader/useOccurrences.ts +++ b/vscode/webview-ui/src/codeReader/useOccurrences.ts @@ -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(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() - const [occurrences] = useState(() => new OccurrenceHighlight(postMessage, setHighlight)) +export function useOccurrences(version: number | undefined, translatorId: string | undefined, page: OccurrencePage | undefined, marks: Store): { 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()) /** 用键盘或筛选栏按钮跳过几次:每跳一次,画完后把当前那一处滚进视口 */ diff --git a/vscode/webview-ui/src/codeReader/useQa.ts b/vscode/webview-ui/src/codeReader/useQa.ts index 0e1faa6..ab8d9ad 100644 --- a/vscode/webview-ui/src/codeReader/useQa.ts +++ b/vscode/webview-ui/src/codeReader/useQa.ts @@ -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(state) - current.current = state + const [{ tree, dispatch }] = useState(() => { + const store = createStore(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 } diff --git a/vscode/webview-ui/src/codeReader/useRewrite.ts b/vscode/webview-ui/src/codeReader/useRewrite.ts index bdb77c4..4ca2b18 100644 --- a/vscode/webview-ui/src/codeReader/useRewrite.ts +++ b/vscode/webview-ui/src/codeReader/useRewrite.ts @@ -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, EMPTY_CHAT) + const [{ tree, dispatch }] = useState(() => { + const store = createStore(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 } diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index 4762bc5..7d3b442 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -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']) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index 159c1ff..96c2f3b 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -98,6 +98,39 @@ export function collapsedIds(blocks: readonly BlockView[], folds: Readonly/…`)归它那张卡片;块 id 归它自己, + * 也归它的父卡片(挂在父卡片子行上的子块由父卡片的属性表画、点子行开关)。 + * 和 previous 里同一张卡片的那份一样时沿用那一份:别的卡片折叠、展开时,这张卡片订阅的还是原来那个对象,不重画。 + */ +export function foldsByCard(collapsed: ReadonlySet, previous?: ReadonlyMap>): ReadonlyMap> { + const groups = new Map>() + 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 撞上。