249 lines
12 KiB
TypeScript
249 lines
12 KiB
TypeScript
import type { ReactElement } from 'react'
|
|
import type { BlockTreeState } from './BlockTree'
|
|
import type { NameMap, VersionNames } from './displayNames'
|
|
import type { DocumentDiff, DocumentMessage, Excluded, Reveal } 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 } from './AskThread'
|
|
import { BlockLevel } from './BlockTree'
|
|
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 { FilterBar } from './FilterBar'
|
|
import { OverviewRuler } from './OverviewRuler'
|
|
import { asksOf, historyOf, PATH_ASK_ANCHOR } from './qa'
|
|
import { useChangeNav } from './useChangeNav'
|
|
import { useDefinitionLinks } from './useDefinitionLinks'
|
|
import { useDefinitionPeek } from './useDefinitionPeek'
|
|
import { HighlightContext, 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 { 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 }
|
|
}
|
|
|
|
export function App(): ReactElement | null {
|
|
// 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答
|
|
const qa = useQa()
|
|
/** 侧栏「问 AI」要聚焦顶部输入框时加一 */
|
|
const [pathFocus, setPathFocus] = useState(0)
|
|
/** 按注释规范重写的过程,只在这一页上 */
|
|
const rewrites = 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 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 setFolded = useCallback((ids: string[], folded: boolean) => setView(prev => ({
|
|
...prev,
|
|
folds: { ...prev.folds, ...Object.fromEntries(ids.map(id => [id, folded])) },
|
|
})), [])
|
|
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), [])
|
|
useScrollPosition(doc)
|
|
useReveal(doc, reveal, collapsed, unshown, covered, show, expand, revealed)
|
|
useDefinitionLinks(doc?.version)
|
|
const peek = useDefinitionPeek(doc?.version)
|
|
useRepoLinks()
|
|
useRepoImages()
|
|
const nav = useChangeNav(stops, collapsed, expand)
|
|
const occurrencePage = useMemo((): OccurrencePage | undefined => doc
|
|
? {
|
|
blocks: doc.blocks,
|
|
skip: id => unshown.has(id) || covered.has(id) || view.editing.includes(id),
|
|
collapsed,
|
|
expand,
|
|
}
|
|
: undefined, [doc, unshown, covered, view.editing, collapsed, expand])
|
|
const { highlight, moveOccurrence } = useOccurrences(doc?.version, doc?.translatorId, occurrencePage)
|
|
|
|
useEffect(() => {
|
|
const off = onMessage((msg) => {
|
|
switch (msg.type) {
|
|
case 'document':
|
|
setDoc(msg)
|
|
setNames(documentNames(msg.version, msg.names))
|
|
setKeep(prev => keptWhileEditing(prev, editing.current))
|
|
if (msg.reveal)
|
|
setReveal(msg.reveal)
|
|
return
|
|
case 'reveal':
|
|
setReveal({ line: msg.line, definition: msg.definition })
|
|
return
|
|
case 'displayNames':
|
|
setNames(prev => acceptNames(prev, msg.version, msg.names))
|
|
return
|
|
case 'filter':
|
|
setExcluded(msg.excluded)
|
|
setOnlyChanges(msg.onlyChanges)
|
|
setKeep(NONE)
|
|
setOpenedRuns(NONE)
|
|
return
|
|
case 'diff':
|
|
setDiff(msg.diff)
|
|
return
|
|
case 'focusPathAsk':
|
|
setPathFocus(n => n + 1)
|
|
}
|
|
})
|
|
postMessage({ type: 'ready' })
|
|
return off
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
viewState.update(view)
|
|
editing.current = view.editing
|
|
}, [view])
|
|
|
|
const onFocused = useCallback(() => setFocusId(null), [])
|
|
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),
|
|
}))
|
|
},
|
|
rewrites: rewrites.runs,
|
|
onRewriteComment: rewrites.onRewrite,
|
|
onStopRewrite: rewrites.onStop,
|
|
},
|
|
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 }),
|
|
...(changes && { diff: changes }),
|
|
...(folded && { folded }),
|
|
onOpenRun: id => setOpenedRuns(prev => new Set([...prev, id])),
|
|
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewrites, qa, changes, folded])
|
|
|
|
if (!doc || !tree)
|
|
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 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' ? '这个文件夹' : '这个文件'}
|
|
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 !== 'folder' && <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>
|
|
<DefinitionPeekPopup shown={peek.shown} onJump={peek.jump} />
|
|
</DisplayNameContext>
|
|
</HighlightContext>
|
|
)
|
|
}
|