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