✨ feat(vscode): 代码阅读认出代码里起的中文名(Annotated[…, Comment("…")]、@Comment 装饰器、行尾 # @Comment、docstring / JSDoc 的参数说明),预览里定义和用到它的各处都写中文名,悬停看原名,定义处后面淡淡写原名

This commit is contained in:
cruldra committed 2026-09-27 00:56:39 +08:00
1 parent 1b1299dc1f
commit 44c221ea2b
45 files changed
+1685 -128

No files matched your search

+38 -27
View File
@@ -1,11 +1,13 @@
import type { ReactElement } from 'react'
import type { BlockTreeState } from './BlockTree'
import type { NameMap, VersionNames } from './displayNames'
import type { 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 { BlockLevel } from './BlockTree'
import { acceptNames, DisplayNameContext, documentNames } from './displayNames'
import { coveredByEditing, startEditing } from './editing'
import { applyFilter, keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar'
@@ -20,6 +22,7 @@ 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()
@@ -38,6 +41,8 @@ export function App(): ReactElement | null {
const [excluded, setExcluded] = useState<Excluded>({})
/** 跳到定义时临时露出来的块(目标和它的各层祖先),筛选一变、文档一刷新就回到筛选状态 */
const [keep, setKeep] = useState<ReadonlySet<string>>(NONE)
/** 页面上这个文档版本的中文名:文档带来的,扩展端查到了再换 */
const [names, setNames] = useState<VersionNames | undefined>()
/** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */
const editing = useRef(view.editing)
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds])
@@ -69,6 +74,7 @@ export function App(): ReactElement | null {
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)
@@ -76,6 +82,9 @@ export function App(): ReactElement | null {
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)
setKeep(NONE)
@@ -119,35 +128,37 @@ export function App(): ReactElement | null {
return (
<HighlightContext value={highlight}>
<FilterBar
attributes={doc.attributes}
excluded={excluded}
view={filter}
highlight={highlight}
onChange={next => postMessage({ type: 'setFilter', excluded: next })}
translators={doc.translators}
translatorId={doc.translatorId}
onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })}
/>
<div className="mx-auto box-content max-w-[880px] px-6 py-5">
{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}
<DisplayNameContext value={names?.names ?? NO_NAMES}>
<FilterBar
attributes={doc.attributes}
excluded={excluded}
view={filter}
highlight={highlight}
onChange={next => postMessage({ type: 'setFilter', excluded: next })}
translators={doc.translators}
translatorId={doc.translatorId}
onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })}
/>
<div className="mx-auto box-content max-w-[880px] px-6 py-5">
{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 className="mt-0.5 text-xs text-[var(--vscode-descriptionForeground)]">点编辑器右上角的「编辑」查看源码。</div>
</div>
</div>
)}
{doc.blocks.length === 0
? doc.error === undefined && <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>
)}
{doc.blocks.length === 0
? doc.error === undefined && <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>
</DisplayNameContext>
</HighlightContext>
)
}
@@ -3,31 +3,40 @@ import type { ReactElement } from 'react'
import type { GroupStyle, Inline, SegmentGroup } from './types'
import { Repeat } from 'lucide-react'
import { useContext } from 'react'
import { DisplayNameContext, shownName } from './displayNames'
import { isCurrent, segmentOccurrence } from './occurrences'
import { HighlightContext } from './useOccurrences'
/**
* 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上,
* 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色,是当前那一处(点中的、用键盘跳到的)时
* 再标 data-current。框起来的一组词画成小框,框里的词同样处理。
* 再标 data-current。起了中文名的名字显示中文名(data-name),悬停看原名,定义处本身在后面淡淡写原名;下标照旧,跳到定义、高亮照常。
* 框起来的一组词画成小框,框里的词同样处理。
*/
export function SegmentText({ segments }: { segments: Inline[] }): ReactElement {
const highlight = useContext(HighlightContext)
const names = useContext(DisplayNameContext)
return (
<>
{segments.map((piece, index) => 'role' in piece
? (
<span
key={index}
className={`cr-role-${piece.role}`}
data-offset={piece.offset}
data-occurrence={highlight && segmentOccurrence(piece, highlight.occurrences)}
data-current={highlight && isCurrent(piece, highlight) ? '' : undefined}
>
{piece.text}
</span>
)
: <GroupBox key={index} group={piece} />)}
{segments.map((piece, index) => {
if (!('role' in piece))
return <GroupBox key={index} group={piece} />
const shown = shownName(piece, names)
return (
<span
key={index}
className={`cr-role-${piece.role}`}
data-offset={piece.offset}
data-occurrence={highlight && segmentOccurrence(piece, highlight.occurrences)}
data-current={highlight && isCurrent(piece, highlight) ? '' : undefined}
data-name={shown?.name}
title={shown && `原名 ${piece.text}`}
>
{shown ? shown.name : piece.text}
{shown?.definition && <span className="cr-original">{piece.text}</span>}
</span>
)
})}
</>
)
}
@@ -283,6 +283,13 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
border-top: 1px solid color-mix(in srgb, currentColor 30%, transparent);
}
/* 起了中文名的定义处:中文名后面淡淡写原名,字小一号,悬停看完整的「原名 x」 */
.cr-original {
margin-left: 0.4em;
font-size: 0.86em;
color: var(--vscode-descriptionForeground);
}
/* 按住 ⌘ / Ctrl 停在查得到定义的名字上:和编辑器里的链接一样 */
.cr-code [data-link] {
color: var(--vscode-editorLink-activeForeground);
@@ -0,0 +1,81 @@
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 { OccurrenceHighlight } from './occurrences'
import { SegmentText } from './SegmentText'
// 高亮的上下文所在的模块连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着
vi.mock('./useOccurrences', async () => {
const { createContext } = await import('react')
return { HighlightContext: createContext(undefined) }
})
const CURRENT_USER: ShownName = { offset: 40, length: 12, name: '当前用户', definition: true }
const USE: ShownName = { offset: 90, length: 12, name: '当前用户' }
const ADMIN: ShownName = { offset: 130, length: 5, name: '管理员' }
function render(segments: Inline[], names: ShownName[]): string {
return renderToStaticMarkup(
<DisplayNameContext value={nameMap(names)}>
<SegmentText segments={segments} />
</DisplayNameContext>,
)
}
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('只认下标和长度都对得上的名字:没下标的译词、长度不一样的(名字改长了)不换', () => {
const names = nameMap([USE])
expect(shownName({ text: 'current_user', role: 'variable', offset: 90 }, names)).toBe(USE)
expect(shownName({ text: 'current_userx', role: 'variable', offset: 90 }, names)).toBeUndefined()
expect(shownName({ text: 'current_user', role: 'variable' }, names)).toBeUndefined()
expect(shownName({ text: 'current_user', role: 'variable', offset: 91 }, names)).toBeUndefined()
})
it('用到的地方显示中文名,悬停写「原名 x」,下标照旧(⌘ 点击跳到定义、单击高亮都认它)', () => {
const html = render([
{ text: '如果', role: 'keyword' },
{ text: ' ', role: 'plain' },
{ text: 'current_user', role: 'variable', offset: 90 },
{ text: '.', role: 'plain' },
{ text: 'role', role: 'variable', offset: 103 },
{ text: ' 不等于 ', role: 'operator' },
{ text: 'UserRole', role: 'variable', offset: 120 },
{ text: '.', role: 'plain' },
{ text: 'ADMIN', role: 'variable', offset: 130 },
], [USE, ADMIN])
expect(html.replace(/<[^>]*>/g, '')).toBe('如果 当前用户.role 不等于 UserRole.管理员')
expect(html).toContain('<span class="cr-role-variable" data-offset="90" data-name="当前用户" title="原名 current_user">当前用户</span>')
expect(html).toContain('<span class="cr-role-variable" data-offset="103">role</span>')
expect(html).not.toContain('cr-original')
})
it('定义处本身(名称格子、名称行):中文名后面跟着淡淡的原名;框里的名字同样换', () => {
const html = render([
{ text: 'current_user', role: 'variable', offset: 40 },
{ style: 'iteration', label: '列表', segments: [{ text: 'ADMIN', role: 'variable', offset: 130 }] },
], [CURRENT_USER, ADMIN])
expect(html).toContain('data-offset="40" data-name="当前用户" title="原名 current_user">当前用户<span class="cr-original">current_user</span></span>')
expect(html).toContain('data-offset="130" data-name="管理员" title="原名 ADMIN">管理员</span>')
})
it('单击显示成中文名的名字:筛选栏写中文名,当前那一处按点中的下标认', () => {
const sent: WebviewToExtension[] = []
let shown: string | undefined
const highlight = new OccurrenceHighlight(msg => sent.push(msg), next => shown = next && `${next.name} ${next.current}`)
highlight.setDocument(1, 'zh')
highlight.click({ kind: 'find', offset: 90, name: '当前用户' })
const asked = sent.at(-1)
expect(asked).toMatchObject({ type: 'findOccurrences', offset: 90 })
highlight.answer(asked?.type === 'findOccurrences' ? asked.id : -1, [{ start: 40, end: 52, kind: 'write' }, { start: 90, end: 102, kind: 'read' }])
expect(shown).toBe('当前用户 1')
})
})
@@ -0,0 +1,34 @@
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
}
@@ -65,7 +65,7 @@ describe('occurrences:哪些段画高亮', () => {
' return "".join(secrets.choice(ALPHABET) for _ in range(8))',
].join('\n')
const py = createPythonAdapter(WASM_DIR)
const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator))
const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator))
const highlighted = (occurrences: Occurrence[]): string[] => allSegments(views).flatMap((segment) => {
const style = segmentOccurrence(segment, occurrences)
return style ? [`${segment.text}:${style}`] : []
@@ -42,7 +42,7 @@ export interface Click {
count: number
/** 点完页面上有选中的文字:这是拖着选字,不是点 */
selecting: boolean
/** 点中的带下标的名字 */
/** 点中的带下标的名字:源码下标,页面上显示的字(起了中文名的是中文名) */
name?: { offset: number, text: string }
/** 点在自己管点击的东西上:按钮、链接、输入框、下拉列表、筛选栏、属性名 */
control: boolean
@@ -175,8 +175,8 @@ export class OccurrenceHighlight {
private translatorId: string | undefined
/** 最近一次询问的 id;别的回答(点了别处、清掉之后才到的)都丢掉 */
private request = 0
/** 最近一次询问点中的那段名字;答过或清掉后没有 */
private asked: Segment | undefined
/** 最近一次询问点中的名字:源码下标和页面上显示的字;答过或清掉后没有 */
private asked: { offset: number, name: string } | undefined
/** 高亮着的名字没有当前那一处时,从这个源码下标(点中的名字)数起跳到哪一处 */
private from = 0
private current: Highlight | undefined
@@ -213,9 +213,9 @@ export class OccurrenceHighlight {
return
this.asked = undefined
const sorted = [...occurrences].sort((a, b) => a.start - b.start || a.end - b.end)
const current = sorted.findIndex(occurrence => overlaps(asked, occurrence))
this.from = asked.offset ?? 0
this.show(sorted.length > 0 ? { name: asked.text, occurrences: sorted, ...(current >= 0 && { current }) } : undefined)
const current = sorted.findIndex(occurrence => occurrence.start <= asked.offset && asked.offset < occurrence.end)
this.from = asked.offset
this.show(sorted.length > 0 ? { name: asked.name, occurrences: sorted, ...(current >= 0 && { current }) } : undefined)
}
/**
@@ -240,7 +240,7 @@ export class OccurrenceHighlight {
private find(offset: number, name: string): void {
if (this.version === undefined)
return
this.asked = { text: name, role: 'plain', offset }
this.asked = { offset, name }
this.post({ type: 'findOccurrences', id: ++this.request, version: this.version, offset })
}
@@ -235,7 +235,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => {
async function jump(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, needle: string): Promise<{ plan: RevealPlan | undefined, flashed: string }> {
const offset = source.indexOf(needle)
expect(offset, needle).toBeGreaterThanOrEqual(0)
const views: BlockView[] = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, translator))
const views: BlockView[] = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator))
const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset)
return { plan, flashed: flashed(views, plan) }
}
@@ -323,7 +323,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => {
expect(await at('T>')).toEqual({ plan: { id: '0', row: '0/p3', expand: [] }, flashed: '类型参数: T' })
expect(await at('load')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'load(id: T) {' })
expect(await at('id: T')).toEqual({ plan: { id: '0.0', row: '0.0/p3/0', expand: ['0', '0.0/p3'] }, flashed: 'id | T | ' })
const views = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, source, ts, typescriptZhTranslator))
const views = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator))
expect([...collapsedIds(views, {})].sort()).toEqual(['0', '0.0'])
const pySource = ['class NotFound(AppException):', ' """找不到。"""', ' def run(self):', ' pass'].join('\n')
@@ -344,7 +344,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => {
' return self.name',
].join('\n')
const views = await withBlocks(py, 'python', source, blocks =>
toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [sqlalchemyRule], report: () => {} }))
toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: [sqlalchemyRule], report: () => {} }))
const at = (needle: string): { plan: RevealPlan | undefined, flashed: string } => {
const offset = source.indexOf(needle)
const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset)
@@ -34,7 +34,7 @@ describe('属性行下面的子行怎么排', () => {
'D = {"k": 1}',
].join('\n')
const py = createPythonAdapter(WASM_DIR)
const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator))
const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator))
const layouts = views.flatMap(view => 'properties' in view ? view.properties.flatMap((row: PropertyRow) => subRowsLayout(row) ? [[row.label, subRowsLayout(row)]] : []) : [])
expect(layouts).toEqual([['导入项', 'list'], ['输入参数', 'table'], ['值', 'list']])
})
+15
View File
@@ -154,6 +154,19 @@ export interface DocumentMessage {
error?: string
/** 只在新预览的第一份 document 里带:从文本编辑器切过来时是它顶部那一行,跳到定义时是定义处 */
reveal?: Reveal
/** 这个版本眼下已知的中文名;查完扩展端再发 displayNames 换掉 */
names: ShownName[]
}
/**
* 显示成中文名的一个名字:源码下标 [offset, offset + length) 的名字显示成 name,悬停看原名;
* definition 是定义处本身,中文名后面淡淡写原名。页面只按下标认,不认识任何语言。
*/
export interface ShownName {
offset: number
length: number
name: string
definition?: true
}
/** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、用哪个 profile 答的 */
@@ -235,6 +248,7 @@ export interface AnswerEnd {
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了,见 AnswerEnd
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现
* displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理
*/
export type ExtensionToWebview
= | DocumentMessage
@@ -242,6 +256,7 @@ export type ExtensionToWebview
| { type: 'definitionProbed', id: number, found: boolean }
| { type: 'occurrencesFound', id: number, occurrences: Occurrence[] }
| { type: 'moveOccurrence', step: 1 | -1 }
| { type: 'displayNames', version: number, names: ShownName[] }
| { type: 'filter', excluded: Excluded }
| QaMessage
| ({ type: 'askProfiles' } & ProfileChoice)
@@ -34,7 +34,8 @@ function clickOf(event: MouseEvent): Click {
modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey,
count: event.detail,
selecting: selection !== null && !selection.isCollapsed,
name: name?.dataset.offset === undefined ? undefined : { offset: Number(name.dataset.offset), text: name.textContent ?? '' },
// 起了中文名的名字写的是中文名(定义处后面还跟着原名),筛选栏上写中文名
name: name?.dataset.offset === undefined ? undefined : { offset: Number(name.dataset.offset), text: name.dataset.name ?? name.textContent ?? '' },
control: target?.closest(CONTROLS) != null,
}
}