- markdown.ts:mermaid 代码块出占位(源码转义后记在 data-source,里面先放原样的代码块);带增删的说明、逐字生成中的回答仍是普通代码块 - useMermaidDiagrams:页面上有占位才按需加载 mermaid(strict、跟 VS Code 字体),按源码 + 配色缓存 SVG,一次只画一张,换主题重画;画不出来留代码块、下面一行原因 - 打包:Vite 的按需加载辅助函数单独成块,加构建检查禁止别的块 import 入口(入口带 ?v= 加载,被 import 会整页再跑一遍、第二次拿 VS Code API 报错) - 主题明暗判断抽到 lib/vscodeTheme,看板的改动面板共用
316 lines
15 KiB
TypeScript
316 lines
15 KiB
TypeScript
import type { ReactElement } from 'react'
|
|
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 { TriangleAlert } from 'lucide-react'
|
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
import { AskThread, FEATURE_THREAD } from './AskThread'
|
|
import { BlockTree } from './BlockTree'
|
|
import { CallTreeDialog } from './CallTreeDialog'
|
|
import { applyCodeColors } from './codeColors'
|
|
import { DefinitionPeekPopup } from './DefinitionPeekPopup'
|
|
import { DiffBar, NewFileBanner } from './DiffBar'
|
|
import { nameMap } from './displayNames'
|
|
import { startEditing } from './editing'
|
|
import { keptWhileEditing } from './filter'
|
|
import { FilterBar } from './FilterBar'
|
|
import { OverviewRuler } from './OverviewRuler'
|
|
import { pageLayout } from './pageLayout'
|
|
import { createPageStore } from './pageState'
|
|
import { NO_PLACEMENT, placementOf } from './placement'
|
|
import { chatOf, PATH_ASK_ANCHOR, sameChat } from './qa'
|
|
import { TextMarksContext } from './SegmentText'
|
|
import { shareBlocks, 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 { useMermaidDiagrams } from './useMermaidDiagrams'
|
|
import { useOccurrences } from './useOccurrences'
|
|
import { useQa } from './useQa'
|
|
import { useRepoImages } from './useRepoImages'
|
|
import { useRepoLinks } from './useRepoLinks'
|
|
import { useReveal } from './useReveal'
|
|
import { useRewrite } from './useRewrite'
|
|
import { useScrollPosition } from './useScrollPosition'
|
|
import { useStickySettings } from './useStickyScroll'
|
|
import { onMessage, postMessage, viewState } from './vscode'
|
|
|
|
const NONE: ReadonlySet<string> = new Set()
|
|
|
|
/** 打开 doc 里 anchor 那块的调用树弹框,先是「正在查」,等扩展端发树来;这块不在了、或者它看不了调用树时是 undefined。 */
|
|
function openingCall(doc: DocumentMessage, anchor: string, entry: number): OpenCall | undefined {
|
|
const block = blockAt(doc.blocks, anchor)
|
|
if (!block?.callTree)
|
|
return undefined
|
|
return { anchor, version: doc.version, entry, status: 'loading', name: '', where: '', kind: block.kind, ...(block.badge && { badge: block.badge }), trees: [] }
|
|
}
|
|
|
|
function blockAt(blocks: readonly BlockView[], anchor: string): BlockView | undefined {
|
|
for (const block of blocks) {
|
|
if (block.anchor === anchor)
|
|
return block
|
|
const inner = blockAt(block.children, anchor)
|
|
if (inner)
|
|
return inner
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* 代码阅读预览页。页面状态放在一份 store 里(createPageStore),扩展端的消息、用户的点击都改它;
|
|
* 块树的卡片各自订阅自己那部分(见 BlockTree),一条消息只重画受影响的卡片;这一层重画时,块树顶层各块照 memo 跳过。
|
|
*/
|
|
export function App(): ReactElement | null {
|
|
// 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答
|
|
const qa = useQa()
|
|
/** 侧栏「问 AI」要聚焦顶部输入框时加一 */
|
|
const [pathFocus, setPathFocus] = useState(0)
|
|
/** 按注释规范重写 */
|
|
const rewrite = useRewrite()
|
|
const [page] = useState(() => createPageStore(viewState.get()))
|
|
/** 页面上文字的标记:这个文档版本的中文名(文档带来的,扩展端查到了再换)和单击高亮的名字;各段文字按自己那几段订阅 */
|
|
const [marks] = useState(() => createStore<TextMarks>({ names: new Map(), placement: NO_PLACEMENT }))
|
|
/** 页面重新加载前开着的调用树弹框:第一份文档到了,那块还在就重新打开;到之前记着的不动 */
|
|
const restoreCall = useRef(viewState.get().callTree)
|
|
/** 块树所在的页面内容:钉住卡片时在这里面量 */
|
|
const content = useRef<HTMLDivElement>(null)
|
|
/** 页面内容挂上时,卡片的位置从它量(pageLayout):筛选栏和它在同一次提交里画出来 */
|
|
const attachContent = useCallback((el: HTMLDivElement | null) => {
|
|
content.current = el
|
|
return el ? pageLayout.attach(el, document.querySelector<HTMLElement>('.cr-filter-bar'), () => page.get().placement) : undefined
|
|
}, [])
|
|
// 这一层重画了:同一段脚本里接着要量位置的(滚到某一行、跳到改动、滚到当前那一处)不能用重画前量的,先作废;放在它们前面
|
|
useLayoutEffect(() => pageLayout.invalidate())
|
|
const { doc, editing, folds, reveal, excluded, diff, onlyChanges, openCall, changes, collapsed, filter, covered, placement, unshown, stops } = useStore(page, current => current)
|
|
/** 眼下这一版文档里各块在文件里的位置:事件里读名字的下标时现取 */
|
|
const currentPlacement = useCallback(() => page.get().placement, [page])
|
|
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) => 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[]) => 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, currentPlacement)
|
|
const peek = useDefinitionPeek(doc?.version, currentPlacement)
|
|
const sticky = useStickySettings()
|
|
useRepoLinks()
|
|
useRepoImages()
|
|
useMermaidDiagrams()
|
|
const nav = useChangeNav(stops, collapsed, expand, sticky)
|
|
const occurrencePage = useMemo((): OccurrencePage | undefined => doc
|
|
? {
|
|
blocks: doc.blocks,
|
|
placement,
|
|
skip: id => unshown.has(id) || covered.has(id) || editing.includes(id),
|
|
collapsed,
|
|
expand,
|
|
}
|
|
: undefined, [doc, placement, 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.placement, 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) => {
|
|
switch (msg.type) {
|
|
case 'document': {
|
|
const restoring = restoreCall.current
|
|
restoreCall.current = undefined
|
|
// 指纹没变的块还是原来的对象:没变的卡片不重画
|
|
const next = { ...msg, blocks: shareBlocks(page.get().doc?.blocks, msg.blocks) }
|
|
const reopened = restoring && openingCall(next, restoring.anchor, restoring.entry)
|
|
marks.set(current => ({ ...current, names: nameMap(msg.names), placement: placementOf(next.blocks) }))
|
|
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 })
|
|
return
|
|
}
|
|
case 'callTreeCounts':
|
|
if (msg.version !== page.get().doc?.version)
|
|
return
|
|
page.update({ callCounts: { version: msg.version, counts: msg.counts } })
|
|
return
|
|
case 'callTree':
|
|
if (msg.version !== page.get().doc?.version)
|
|
return
|
|
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':
|
|
page.update({ reveal: { line: msg.line, definition: msg.definition } })
|
|
return
|
|
case 'displayNames':
|
|
// 查到的是页面上这个版本的才换上,迟到的旧版本的不理
|
|
if (msg.version === page.get().doc?.version)
|
|
marks.set(current => ({ ...current, names: nameMap(msg.names) }))
|
|
return
|
|
case 'filter':
|
|
page.update({ excluded: msg.excluded, onlyChanges: msg.onlyChanges, keep: NONE, openedRuns: NONE })
|
|
return
|
|
case 'codeColors':
|
|
applyCodeColors(document.body.style, msg)
|
|
return
|
|
case 'diff':
|
|
page.update(current => ({ diff: shareUnchanged(current.diff, msg.diff) }))
|
|
return
|
|
case 'openedFrom':
|
|
viewState.update({ openedFrom: msg.from })
|
|
return
|
|
case 'focusPathAsk':
|
|
setPathFocus(n => n + 1)
|
|
}
|
|
})
|
|
// 重启 VS Code 后恢复的标签页,扩展端不知道它是从哪儿打开的,按页面记着的
|
|
const { openedFrom } = viewState.get()
|
|
postMessage({ type: 'ready', ...(openedFrom && { openedFrom }) })
|
|
return off
|
|
}, [page, marks])
|
|
|
|
useEffect(() => {
|
|
viewState.update({ editing, folds })
|
|
}, [editing, folds])
|
|
|
|
useEffect(() => {
|
|
if (restoreCall.current)
|
|
return
|
|
viewState.update({ callTree: openCall ? { anchor: openCall.anchor, entry: openCall.entry } : undefined })
|
|
}, [openCall])
|
|
|
|
if (!doc)
|
|
return null
|
|
|
|
return (
|
|
<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}
|
|
/>
|
|
)}
|
|
/>
|
|
{changes && diff && <OverviewRuler stops={stops} lineCount={diff.lineCount} onPick={nav.goTo} />}
|
|
<div ref={attachContent} 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} items />
|
|
: <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>
|
|
)
|
|
}
|