diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 7c25190..87d2832 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -310,18 +310,21 @@ export function toBlockViews( } /** * 块先问启用的库规则,没认领就照它自己的样子。认领了的:规则讲的是整条语句,头部就是整个范围; - * 规则没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序留作它的子块。 + * 规则没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序留作它的子块。规则略去的块(讲在了别的卡片里)不出块。 */ - function build(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView { + function build(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView | undefined { const claim = active.length > 0 ? claimBlock(block, active, context, layerRules.report) : undefined if (!claim) return plain(block, span, id, scope) - const kept = block.children.filter(child => !claim.block.absorbs.includes(child)) - if ('as' in claim.block) { + const replacement = claim.block + if ('omit' in replacement) + return undefined + const kept = block.children.filter(child => !replacement.absorbs.includes(child)) + if ('as' in replacement) { const { attributes } = block - return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope) + return plain({ ...replacement.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope) } - const { kind, badge, rows, defines, attributes } = claim.block + const { kind, badge, rows, defines, attributes } = replacement const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(span)) return { id, @@ -336,9 +339,16 @@ export function toBlockViews( properties: rows, } } + /** 一串兄弟块转成视图数据;略去的块不占位置,id 按出了块的往下数。 */ function list(siblings: readonly CodeBlock[], prefix: string, scope: string, from = 0): BlockView[] { const spans = blockSpans(siblings, source) - return siblings.map((block, i) => build(block, spans[i], `${prefix}${from + i}`, scope)) + const views: BlockView[] = [] + siblings.forEach((block, i) => { + const view = build(block, spans[i], `${prefix}${from + views.length}`, scope) + if (view) + views.push(view) + }) + return views } return list } diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 8d6819c..1a92df2 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -545,18 +545,30 @@ export type TokenRole */ export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor, refers?: SourceRange } -/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块。 */ -export type GroupStyle = 'iteration' +/** + * 框的样式,决定框头的图标和框的颜色: + * - iteration:写在表达式里的遍历(渲染大纲里 `.map` 的「对 … 的每一项」也是),图标、颜色同「循环」块; + * - condition:按条件(渲染大纲里 `cond &&` 的「当 … 时」、三元的「如果 …」),图标、颜色同「条件」块; + * - branch:条件的一支(「是」「否」「否则」),颜色同「条件」块,不带图标; + * - skip:这个条件下不渲染(提前 `return null`),图标、颜色同「返回」块; + * - tag:小标签(名称后面的「具名导出」、属性名后面的「回调」),只有框头的字,颜色是 SegmentGroup.color; + * - classes:收起来的一串类名(「样式 N 个类」),中性色,点开、悬停看 SegmentGroup.detail。 + */ +export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'skip' | 'tag' | 'classes' /** - * 框起来的一组词:译文里自成一体的一段(写在表达式里的遍历),预览把它画成嵌在前后文字里的一个小框,框头是图标和 label。 - * 框里的词照样带源码下标,还能再套框。 + * 框起来的一组词:译文里自成一体的一段(写在表达式里的遍历、渲染大纲一行前面的条件),预览把它画成嵌在前后文字里的一个小框, + * 框头是图标和 label。框里的词照样带源码下标,还能再套框。 */ export interface SegmentGroup { style: GroupStyle - /** 框头的文字(遍历的结果:生成器 / 列表 …),翻译插件给。 */ + /** 框头的文字(遍历的结果:生成器 / 列表 …,小标签的字),翻译插件、库规则给;只有图标、没有字时是空的。 */ label: string segments: Inline[] + /** 这个框自己的颜色(暗色、亮色主题各一个),盖过样式的颜色:tag 按标签的意思上色。 */ + color?: ThemeColor + /** 点开、悬停时看的全文,一行一条:classes 的全部类名(按条件切换的写成「条件 时:类」)。 */ + detail?: string } /** 译文里的一段:着色的词,或框起来的一组词。 */ @@ -722,7 +734,7 @@ export interface ThemeColor { dark: string, light: string } /** * 种类图标:插件自己定义的一组 lucide 图标(https://lucide.dev/icons 上的 kebab-case 名字)。前 26 个给内置种类(几种共用一个: * 命令、管道、执行共用 square-terminal,解释器、解析指令共用 terminal,选项、设置共用 sliders-horizontal,字段、标签共用 tag), - * 后面的给库规则的种类;库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标), + * 后面的给库规则的种类(React 组件的原子、副作用的闪电、事件处理的点击手势也在里面);库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标), * 没有按任意名字找图标的路。 */ export const KIND_ICON_NAMES = [ @@ -758,6 +770,9 @@ export const KIND_ICON_NAMES = [ 'calendar-clock', 'globe', 'network', + 'atom', + 'zap', + 'mouse-pointer-click', ] as const export type KindIconName = (typeof KIND_ICON_NAMES)[number] @@ -767,9 +782,16 @@ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconNam /** * 库规则认领一块后给出的替身,预览里它代替原来那块,下面挂着原块留下的子块: - * 规则自己的卡片(RuleCard),或认成契约里现成的一种属性表块(RuleFacts)。 + * 规则自己的卡片(RuleCard),或认成契约里现成的一种属性表块(RuleFacts); + * 这块讲在了别的卡片里时是略去它(RuleOmit)。 */ -export type RuleBlock = RuleCard | RuleFacts +export type RuleBlock = RuleCard | RuleFacts | RuleOmit + +/** + * 这块讲在了别的卡片里,不再单独成块:React 文件开头的 "use client" 讲成了组件卡片上的「客户端组件」标签, + * 组件上面的 JSDoc 讲成了卡片的「说明」。预览不画它;它不能有子块(有子块时宿主当规则没认)。 + */ +export interface RuleOmit { omit: true } /** 规则自己的卡片:一张属性表,种类的图标、颜色、名字和每一行都由规则给(用词归规则)。 */ export interface RuleCard { @@ -884,7 +906,8 @@ export interface LibraryRule { languageIds: string[] /** * 关心的库(模块名):这份源码里有一条导入(ImportFacts.sources,去掉引号)等于它、或以「它.」「它/」开头(它的子模块)才启用。 - * 宿主按语言插件给的导入判断,规则不用自己读导入语句。 + * 宿主按语言插件给的导入判断,规则不用自己读导入语句。空数组是不看导入、languageIds 里这几种语言的源码都启用 + * (React 组件:新的 JSX 写法不用导入 react,TSX / JSX 文件里就是 React 组件)。 */ libraries: string[] /** diff --git a/vscode/src/codeReader/diff/viewDiff.ts b/vscode/src/codeReader/diff/viewDiff.ts index 549b3b6..77ba18a 100644 --- a/vscode/src/codeReader/diff/viewDiff.ts +++ b/vscode/src/codeReader/diff/viewDiff.ts @@ -114,9 +114,11 @@ export function removedView(view: BlockView): BlockView { } } -/** 一个词比较用的样子:词性和字(框是样式、框头和框里的词);源码下标不算,两个版本里同样的词下标不一样。 */ +/** 一个词比较用的样子:词性和字(框是样式、框头、点开看的全文和框里的词);源码下标不算,两个版本里同样的词下标不一样。 */ function pieceKey(piece: Inline): string { - return 'role' in piece ? `${piece.role}\u0000${piece.text}` : `${piece.style}\u0000${piece.label}\u0000[${piece.segments.map(pieceKey).join('\u0001')}]` + if ('role' in piece) + return `${piece.role}\u0000${piece.text}` + return `${piece.style}\u0000${piece.label}\u0000${piece.detail ?? ''}\u0000[${piece.segments.map(pieceKey).join('\u0001')}]` } /** 着色的词拼成的字(框只算框里的词)。 */ diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index d854f20..914f31f 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -29,6 +29,7 @@ import { celeryRule } from './rules/celery' import { dataclassRule } from './rules/dataclass' import { enumRule } from './rules/enum' import { fastapiRule } from './rules/fastapi' +import { reactRule } from './rules/react' import { sqlalchemyRule } from './rules/sqlalchemy' import { bundledWasmDir, TreeSitterRuntime } from './runtime' import { bashZhTranslator } from './translators/bashZh' @@ -143,6 +144,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { api.registerLibraryRule(enumRule), api.registerLibraryRule(dataclassRule), api.registerLibraryRule(fastapiRule), + api.registerLibraryRule(reactRule), ) return api } diff --git a/vscode/src/codeReader/libraryRules.test.ts b/vscode/src/codeReader/libraryRules.test.ts index d56012d..87d498b 100644 --- a/vscode/src/codeReader/libraryRules.test.ts +++ b/vscode/src/codeReader/libraryRules.test.ts @@ -88,6 +88,11 @@ describe('库规则:启用', () => { expect(list.map(view => view.kind)).toEqual(['import', 'class']) expect(reports).toEqual([]) }) + + it('libraries 是空的规则不看导入:这种语言的文件都启用', async () => { + expect(await activates('import os', { ...cardRule('any'), libraries: [] })).toBe(true) + expect(await activates('x = 1', { ...cardRule('any'), libraries: [] })).toBe(true) + }) }) describe('库规则:认领', () => { @@ -177,6 +182,17 @@ describe('库规则:认领', () => { ]) }) + it('略去的块不出块,后面的块 id 接着往下数;还有子块的块不能略去,记下来、照常显示', async () => { + const omitting = (kind: string): LibraryRule => ({ ...cardRule('omit'), claim: block => block.kind === kind ? { omit: true } : undefined }) + const list = await views(MODEL, [omitting('import')]) + expect(list.map(view => [view.id, view.kind])).toEqual([['0', 'class']]) + expect(list[0].children.map(child => child.id)).toEqual(['0.0', '0.1', '0.2', '0.3']) + + const reports: string[] = [] + expect((await views(MODEL, [omitting('class')], reports)).map(view => view.kind)).toEqual(['import', 'class']) + expect(reports).toEqual(['库规则 omit 给第 3 行的 class 块的替身不合约定,这块照常显示:要略去的块还有子块']) + }) + it('认成契约里现成的属性表块:种类、属性表、筛选属性照语言插件报的这种块,编辑范围是整个类;并进来的不是子块时照常显示', async () => { const asEnum = (absorbs: (block: CodeBlock) => CodeBlock[]): LibraryRule => ({ ...cardRule('lib'), diff --git a/vscode/src/codeReader/libraryRules.ts b/vscode/src/codeReader/libraryRules.ts index fdf5894..97571fc 100644 --- a/vscode/src/codeReader/libraryRules.ts +++ b/vscode/src/codeReader/libraryRules.ts @@ -26,10 +26,10 @@ export function fromLibrary(module: string, library: string): boolean { return module === library || module.startsWith(`${library}.`) || module.startsWith(`${library}/`) } -/** 这份源码启用的规则,保持注册顺序:导入了规则关心的某个库。 */ +/** 这份源码启用的规则,保持注册顺序:导入了规则关心的某个库,或规则不看导入(libraries 是空的)。 */ export function activeRules(rules: readonly LibraryRule[], imports: readonly ImportFacts[]): LibraryRule[] { const modules = imports.flatMap(facts => facts.sources.map(moduleName)) - return rules.filter(rule => rule.libraries.some(library => modules.some(module => fromLibrary(module, library)))) + return rules.filter(rule => rule.libraries.length === 0 || rule.libraries.some(library => modules.some(module => fromLibrary(module, library)))) } /** 一块被哪个规则认领、替身是什么。 */ @@ -40,6 +40,8 @@ export interface Claim { /** 替身不合 RuleBlock 的约定时说哪儿不对。 */ function problemOf(rule: LibraryRule, block: CodeBlock, claimed: RuleBlock): string | undefined { + if ('omit' in claimed) + return block.children.length > 0 ? '要略去的块还有子块' : undefined if (claimed.absorbs.some(child => !block.children.includes(child))) return '要并进来的块不是这块的子块' if ('as' in claimed) diff --git a/vscode/src/codeReader/rules/react.test.ts b/vscode/src/codeReader/rules/react.test.ts new file mode 100644 index 0000000..c7f380b --- /dev/null +++ b/vscode/src/codeReader/rules/react.test.ts @@ -0,0 +1,153 @@ +import type { BlockView } from '../blockView' +import type { PropertyRow, RuleContext } from '../contract' +import { describe, expect, it } from 'vitest' +import { toBlockViews } from '../blockView' +import { createTypeScriptAdapter } from '../languages/typescript' +import { propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport' +import { typescriptZhTranslator } from '../translators/typescriptZh' +import { reactRule } from './react' + +const ts = createTypeScriptAdapter(WASM_DIR) + +const TITLEBAR = readFixture('desktopTitlebar.tsx.txt') +const LEGAL = readFixture('legalDocPage.tsx.txt') +const POSTER = readFixture('posterDialog.tsx.txt') +const SHARE = readFixture('shareButton.tsx.txt') + +/** 规则查名字的定义时查到的值(名字原文 → IndexEntry.value),别的没查到。 */ +function known(values: Record): RuleContext['lookup'] { + return (_index, node) => values[node.text] === undefined ? undefined : { key: node.text, name: { startIndex: 0, endIndex: 0 }, value: values[node.text] } +} + +async function views(source: string, lookup?: RuleContext['lookup'], reports: string[] = []): Promise { + return withBlocks(ts, 'typescriptreact', source, blocks => + toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator, { rules: [reactRule], report: message => reports.push(message), lookup })) +} + +function flatten(list: readonly BlockView[]): BlockView[] { + return list.flatMap(view => [view, ...flatten(view.children)]) +} + +function properties(view: BlockView | undefined): PropertyRow[] { + if (!view || !('properties' in view)) + throw new Error(`块 ${view?.id} 不是属性表`) + return view.properties +} + +/** 组件名是 name 的组件卡片。 */ +async function card(source: string, name: string, lookup?: RuleContext['lookup']): Promise { + const found = flatten(await views(source, lookup)).find(view => view.kind === 'react.component' && view.defines && source.slice(view.defines.startIndex, view.defines.endIndex) === name) + if (!found) + throw new Error(`没有 ${name} 的组件卡片`) + return found +} + +/** 卡片里 label 那一行(连子行)写成的文字,见 propertyLines。 */ +function rowLines(view: BlockView, label: string): string[] { + const row = properties(view).find(item => item.label === label) + return row ? propertyLines([row]) : [] +} + +describe('react 库规则:认领', () => { + it('只管 TSX / JSX,不看导入(新的 JSX 写法不用导入 react)', () => { + expect(reactRule.languageIds).toEqual(['typescriptreact', 'javascriptreact']) + expect(reactRule.libraries).toEqual([]) + }) + + it('luffy 的 DesktopTitlebar:五条导入后面是组件卡片,"use client" 和文档注释不再单独成块', async () => { + const blocks = await views(TITLEBAR) + expect(blocks.map(view => `${view.kind} ${view.startLine + 1}`)).toEqual(['import 3', 'import 5', 'import 6', 'import 7', 'import 9', 'react.component 11']) + const view = blocks[5] + expect(view.badge).toEqual({ label: 'React 组件', color: { dark: '#61dafb', light: '#0a7ea4' }, icon: 'atom' }) + expect(view.attributes).toMatchObject({ kind: 'react.component', access: 'public' }) + expect(propertyLines(properties(view)).slice(0, 4)).toEqual([ + '名称: DesktopTitlebar ⟦具名导出|⟧ ⟦客户端组件|⟧', + '说明: 窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region="deep"), 按钮除外. 侧栏开关只在侧栏顶部.', + '属性: 1 个', + ' platform | DesktopShell["platform"] | — | 是', + ]) + }) + + it('文件里没有组件时 "use client" 照常成块;组件上面没并进块头部的 JSDoc 讲成说明、不单独成块', async () => { + expect((await views('"use client";\nexport const useX = () => 1;')).map(view => view.kind)).toEqual(['statement', 'const']) + const source = ['import { memo } from "react";', '/** 一行标签 */', 'export const Label = memo(({ text }: { text: string }) => {text});'].join('\n') + const blocks = await views(source) + expect(blocks.map(view => view.kind)).toEqual(['import', 'react.component']) + expect(propertyLines(properties(blocks[1])).slice(0, 2)).toEqual(['名称: Label ⟦具名导出|⟧ ⟦记忆化|⟧', '说明: 一行标签']) + }) + + it('小写开头的工具函数、不渲染 JSX 的大写函数照常是函数块', async () => { + const blocks = await views(['const slugify = (raw: string) => raw.trim();', 'function Upper() { return 1 }', 'function lower() { return }'].join('\n')) + expect(blocks.map(view => view.kind)).toEqual(['const', 'function', 'function']) + }) +}) + +describe('react 库规则:属性', () => { + it('luffy 的 LegalDocPage:来自 LegalDocPageProps(名字能跳到定义),字段带说明时多一列说明', async () => { + const view = await card(LEGAL, 'LegalDocPage') + const row = properties(view).find(item => item.label === '属性') + expect(row?.columns).toEqual(['名称', '类型', '默认值', '必填', '说明']) + expect(rowLines(view, '属性')).toEqual([ + '属性: 1 个 · 来自 LegalDocPageProps', + ' doc | "terms" | "privacy" | — | 是 | terms = 用户服务协议; privacy = 隐私政策', + ]) + const from = row && !('markdown' in row.value) ? row.value.at(-1) : undefined + expect(from && 'role' in from && LEGAL.slice(from.offset, (from.offset ?? 0) + from.text.length)).toBe('LegalDocPageProps') + expect(row?.items?.[0].defines && LEGAL.slice(row.items[0].defines.startIndex, row.items[0].defines.endIndex)).toBe('doc') + }) + + it('luffy 的 ShareButton:回调标「回调」、类型写成函数签名;StepLabel 的 children 标「子元素」', async () => { + expect(rowLines(await card(SHARE, 'ShareButton'), '属性')).toEqual([ + '属性: 5 个', + ' open | 布尔 | — | 是 | ', + ' onOpenChange ⟦回调|⟧ | (next: 布尔) => 无 | — | 是 | ', + ' deployUrl | 字符串 | 空 | — | 是 | 已发布的持久 URL; null = 尚未发布 (点击后由父组件驱动发布)。', + ' sessionId | 字符串 | — | 是 | ', + ' title | 字符串 | — | 是 | ', + ]) + expect(rowLines(await card(POSTER, 'StepLabel'), '属性')).toEqual([ + '属性: 2 个', + ' n | 数值 | — | 是', + ' children ⟦子元素|⟧ | ReactNode | — | 是', + ]) + }) + + it('默认值、`?` 不必填、改了名、其余属性;方法签名写成函数签名', async () => { + const source = [ + 'interface Props { size?: number; onPick(v: string): void; label: string }', + 'export const A = ({ size = 16, label: text, ...rest }: Props) => ;', + ].join('\n') + expect(rowLines(await card(source, 'A'), '属性')).toEqual([ + '属性: 4 个 · 来自 Props', + ' size | 数值 | 16 | 否', + ' onPick ⟦回调|⟧ | (v: 字符串) => 无 | — | 是', + ' label(组件里叫 text) | 字符串 | — | 是', + ' ...rest | 其余的属性 | — | 否', + ]) + }) + + it('导入的属性类型:工作区索引查到了就展开字段,没查到时照解构的写、类型未标注;没写参数是无', async () => { + const source = [ + 'import type { CardProps } from "./types";', + 'export const Card = ({ title, onClose = noop }: CardProps) =>
;', + 'export function Empty() { return }', + ].join('\n') + expect(rowLines(await card(source, 'Card'), '属性')).toEqual([ + '属性: 2 个 · 来自 CardProps', + ' title | 未标注 | — | —', + ' onClose | 未标注 | noop | 否', + ]) + const fields = JSON.stringify([ + { name: 'title', type: 'string', optional: false, callback: false, doc: '标题' }, + { name: 'onClose', type: '() => void', optional: true, callback: true }, + { name: 'size', type: 'number', optional: true, callback: false }, + ]) + expect(rowLines(await card(source, 'Card', known({ CardProps: fields })), '属性')).toEqual([ + '属性: 3 个 · 来自 CardProps', + ' title | string | — | 是 | 标题', + ' onClose ⟦回调|⟧ | () => void | noop | 否 | ', + ' size | number | — | 否 | ', + ]) + expect(rowLines(await card(source, 'Empty'), '属性')).toEqual(['属性: 无']) + }) +}) diff --git a/vscode/src/codeReader/rules/react.ts b/vscode/src/codeReader/rules/react.ts new file mode 100644 index 0000000..6007e56 --- /dev/null +++ b/vscode/src/codeReader/rules/react.ts @@ -0,0 +1,278 @@ +import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, SegmentGroup, ThemeColor, WorkspaceIndex } from '../contract' +import type { BindingFacts, ComponentFacts, FieldFacts, FieldText, PropsFacts } from './reactTree' +import { clientDirective, commentText, componentAt, componentOf, componentsIn, declaredTypes, propsOf, sameNode } from './reactTree' +import { hint, name, plain, range } from './segments' + +/** + * React 库规则:TSX / JSX 里的 React 函数组件(见 reactTree.ts 的 componentAt)整块换成「React 组件」卡片,不再是普通函数卡片的输入参数 / 输出参数。 + * 卡片:名称(后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签)/ 说明 / 属性(解构的每个属性一行,类型取属性类型里对应的字段; + * 属性类型是接口、类型别名时写来自哪个类型,导入的类型经工作区索引查它的字段),再按钩子分区。 + * 文件开头的 "use client" 讲成了「客户端组件」、组件上面的 JSDoc 讲成了「说明」,都不再单独成块。 + * 新的 JSX 写法不用导入 react,规则不看导入:TSX / JSX 文件都启用。用词都在词表里,按翻译插件的显示语言挑。 + */ + +const RULE_ID = 'react' +const KIND = 'react.component' + +/** 组件色:React 的青色,渲染大纲里的组件、「客户端组件」标签也用它;白底上照原色看不清,亮色主题取同色相深一些的。 */ +const COMPONENT_COLOR: ThemeColor = { dark: '#61dafb', light: '#0a7ea4' } +/** 导出方式的标签:同 import / export 关键字的颜色。 */ +const EXPORT_COLOR: ThemeColor = { dark: '#c586c0', light: '#af00db' } +/** 记忆化、转发 ref 的标签:同 const / function 关键字的颜色。 */ +const WRAPPER_COLOR: ThemeColor = { dark: '#569cd6', light: '#0000ff' } + +interface Words { + kinds: Record<'component', string> + labels: Record<'name' | 'doc' | 'props', string> + tags: Record<'named' | 'default' | 'client' | 'memo' | 'forwardRef', string> + /** 属性名后面的标签:函数类型的属性、children */ + roles: Record<'callback' | 'children', string> + /** 属性表的列:名称 | 类型 | 默认值 | 必填,有属性带说明时多一列说明 */ + propHeads: string[] + propDoc: string + yes: string + no: string + /** 表里没有的值 */ + nothing: string + /** 没写类型 */ + unmarked: string + count: (n: number) => string + /** 属性来自哪个类型:「 · 来自 」 */ + from: string + /** `...rest` 的类型:其余的属性 */ + rest: string + /** 解构时改了名:「(组件里叫 x)」 */ + renamed: [string, string] +} + +const ZH: Words = { + kinds: { component: 'React 组件' }, + labels: { name: '名称', doc: '说明', props: '属性' }, + tags: { named: '具名导出', default: '默认导出', client: '客户端组件', memo: '记忆化', forwardRef: '转发 ref' }, + roles: { callback: '回调', children: '子元素' }, + propHeads: ['名称', '类型', '默认值', '必填'], + propDoc: '说明', + yes: '是', + no: '否', + nothing: '—', + unmarked: '未标注', + count: n => n > 0 ? `${n} 个` : '无', + from: ' · 来自 ', + rest: '其余的属性', + renamed: ['(组件里叫 ', ')'], +} + +const WORDS: Record = { zh: ZH } + +interface Env { + context: RuleContext + words: Words +} + +/** 小标签(见 GroupStyle 的 tag)。 */ +function tag(label: string, color: ThemeColor): SegmentGroup { + return { style: 'tag', label, segments: [], color } +} + +/** 规则自己的用词里当关键字着色的:是、否。 */ +function yesNo(value: boolean, words: Words): Inline[] { + return [{ text: value ? words.yes : words.no, role: 'storage' }] +} + +// ─── 名称、说明 ─────────────────────────────────────────────────────────────── + +/** 名称:组件名用组件色,后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签。 */ +function nameRow(component: ComponentFacts, { words }: Env): PropertyRow { + const tags = [ + ...(component.exported ? [tag(words.tags[component.exported], EXPORT_COLOR)] : []), + ...(component.client ? [tag(words.tags.client, COMPONENT_COLOR)] : []), + ...component.wrappers.map(wrapper => tag(words.tags[wrapper], WRAPPER_COLOR)), + ] + return { + label: words.labels.name, + value: [{ ...name(component.name, 'function'), color: COMPONENT_COLOR }, ...tags.flatMap(item => [plain(' '), item])], + } +} + +/** 说明:组件上面的 JSDoc。 */ +function docRows(component: ComponentFacts, { context, words }: Env): PropertyRow[] { + const doc = component.doc && context.doc(component.doc) + return doc ? [{ label: words.labels.doc, value: doc }] : [] +} + +// ─── 属性 ───────────────────────────────────────────────────────────────────── + +/** 工作区里各文件顶层声明的接口、类型别名:名字 → 各字段的文字(JSON,见 FieldText),组件的属性类型是导入的时候查它。 */ +const PROP_TYPES_INDEX: WorkspaceIndex = { + id: `${RULE_ID}.propTypes`, + languageId: 'typescriptreact', + include: '**/*.{ts,tsx}', + mentions: source => /\b(?:interface|type)\s+[A-Z]/.test(source), + entries: root => declaredTypes(root).map(({ name: typeName, fields }) => ({ key: typeName.text, name: range(typeName), value: JSON.stringify(fields) })), +} + +/** 属性表的一行讲的一个属性:这个文件里的字段、查索引得到的别的文件里的字段,和解构出来的那一项(都可能没有)。 */ +interface PropEntry { + key: string + field?: FieldFacts + text?: FieldText + binding?: BindingFacts +} + +/** 查索引得到的别的文件里的字段;值不是这个规则存的样子时当没查到。 */ +function importedFields(from: Node | undefined, { context }: Env): FieldText[] | undefined { + const value = from && context.lookup(PROP_TYPES_INDEX, from)?.value + if (!value) + return undefined + try { + const parsed: unknown = JSON.parse(value) + return Array.isArray(parsed) ? parsed as FieldText[] : undefined + } + catch { + return undefined + } +} + +/** + * 属性表的各行:属性类型的字段照类型里的顺序(解构出来的那项给默认值、定义处),类型里没有的解构项接在后面,`...rest` 最后; + * 类型查不到字段、又没解构时是参数本身一行。 + */ +function propEntries(props: PropsFacts, env: Env): PropEntry[] { + const texts = props.fields ? undefined : importedFields(props.from, env) + const keys = props.fields?.map(field => field.name.text) ?? texts?.map(text => text.name) + const bindingOf = (key: string): BindingFacts | undefined => props.bindings.find(binding => !binding.rest && binding.key === key) + const declared: PropEntry[] = props.fields + ? props.fields.map(field => ({ key: field.name.text, field, ...withBinding(bindingOf(field.name.text)) })) + : (texts ?? []).map(text => ({ key: text.name, text, ...withBinding(bindingOf(text.name)) })) + const extra = props.bindings.filter(binding => binding.rest || !keys?.includes(binding.key)).map(binding => ({ key: binding.key, binding })) + const entries = [...declared, ...extra.filter(entry => !entry.binding.rest), ...extra.filter(entry => entry.binding.rest)] + if (entries.length === 0 && props.parameter && !keys) + return [{ key: props.parameter.text }] + return entries +} + +function withBinding(binding: BindingFacts | undefined): { binding?: BindingFacts } { + return binding ? { binding } : {} +} + +/** 名称一格:属性名(定义处能跳),children、回调后面跟小标签,解构时改了名写出组件里叫什么。 */ +function propName(entry: PropEntry, env: Env): Inline[] { + const { words } = env + const { binding, field, text } = entry + const renamed = binding && !binding.rest && binding.name.text !== entry.key + const head: Inline[] = field + ? [name(field.name, 'variable')] + : binding && !renamed ? [...(binding.rest ? [plain('...')] : []), name(binding.name, 'variable')] : [{ text: entry.key, role: 'variable' }] + const role = entry.key === 'children' ? words.roles.children : (field?.callback ?? text?.callback) ? words.roles.callback : undefined + return [ + ...head, + ...(role ? [plain(' '), tag(role, COMPONENT_COLOR)] : []), + ...(renamed ? [hint(words.renamed[0]), name(binding.name, 'variable'), hint(words.renamed[1])] : []), + ] +} + +/** 类型一格:字段的类型照常翻,方法签名写成函数签名;别的文件里的字段照写文字;`...rest` 是其余的属性。 */ +function propType(entry: PropEntry, props: PropsFacts, { context, words }: Env): Inline[] { + const { field, text, binding } = entry + if (field?.type?.type === 'formal_parameters') + return [...context.translate(field.type), plain(' => '), ...(field.returns ? context.translate(field.returns) : [hint(words.unmarked)])] + if (field?.type) + return context.translate(field.type) + if (text?.type) + return [{ text: text.type, role: 'type' }] + if (binding?.rest) + return [hint(words.rest)] + if (!binding && props.type) + return context.translate(props.type) + return [hint(words.unmarked)] +} + +/** 必填一格:类型里写了 `?` 的不必填;没有类型可看时解构写了默认值的不必填,否则不知道。 */ +function propRequired(entry: PropEntry, words: Words): Inline[] { + const optional = entry.field?.optional ?? entry.text?.optional + if (optional !== undefined) + return yesNo(!optional, words) + if (entry.binding?.default || entry.binding?.rest) + return yesNo(false, words) + return [hint(words.nothing)] +} + +/** 说明一格:字段上面的 JSDoc / 注释。 */ +function propDoc(entry: PropEntry): Inline[] { + const text = entry.field ? commentText(entry.field.docs) : entry.text?.doc + return text ? [{ text: text.replace(/\s*\n\s*/g, ' '), role: 'comment' }] : [] +} + +/** 属性:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明时多一列说明);属性类型是一个名字时「属性」后面写来自哪里。 */ +function propsRow(component: ComponentFacts, env: Env): PropertyRow { + const { context, words } = env + const props = propsOf(component) + const entries = propEntries(props, env) + const docs = entries.map(propDoc) + const described = docs.some(doc => doc.length > 0) + const items = entries.map((entry, index): PropertyItem => { + const defines = entry.binding?.name ?? entry.field?.name + return { + cells: [ + propName(entry, env), + propType(entry, props, env), + entry.binding?.default ? context.translate(entry.binding.default) : [hint(words.nothing)], + propRequired(entry, words), + ...(described ? [docs[index]] : []), + ], + ...(defines && { defines: range(defines) }), + } + }) + return { + label: words.labels.props, + value: [hint(words.count(items.length)), ...(props.from ? [hint(words.from), name(props.from, 'type')] : [])], + ...(items.length > 0 && { items, columns: described ? [...words.propHeads, words.propDoc] : words.propHeads }), + } +} + +// ─── 认领 ───────────────────────────────────────────────────────────────────── + +/** 组件卡片。 */ +function componentCard(component: ComponentFacts, env: Env): RuleCard { + return { + kind: KIND, + badge: badge(env.words), + rows: [nameRow(component, env), ...docRows(component, env), propsRow(component, env)], + defines: range(component.name), + absorbs: [], + } +} + +function badge(words: Words): KindBadge { + return { label: words.kinds.component, color: COMPONENT_COLOR, icon: 'atom' } +} + +/** + * 讲在了组件卡片里、不再单独成块的:文件开头的 "use client"(文件里有组件时,讲成「客户端组件」标签), + * 紧贴在组件前面、没并进组件块头部的 JSDoc(值是 memo(…)、直接返回 JSX 的箭头函数这类常量块,讲成「说明」)。 + */ +function omitted(block: CodeBlock): boolean { + const root = block.node.tree.rootNode + if (block.kind === 'statement') + return sameNode(clientDirective(root), block.node) && componentsIn(root).length > 0 + if (block.kind === 'comment') { + const next = block.node.nextSibling + const component = next ? componentAt(next) : undefined + return !!component?.doc && sameNode(component.doc, block.node) + } + return false +} + +export const reactRule: LibraryRule = { + id: RULE_ID, + label: 'React 组件', + languageIds: ['typescriptreact', 'javascriptreact'], + libraries: [], + claim: (block, context) => { + const env: Env = { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH } + const component = componentOf(block) + if (component) + return componentCard(component, env) + return omitted(block) ? { omit: true } : undefined + }, +} diff --git a/vscode/src/codeReader/rules/reactTree.ts b/vscode/src/codeReader/rules/reactTree.ts index 3ef13ed..704d713 100644 --- a/vscode/src/codeReader/rules/reactTree.ts +++ b/vscode/src/codeReader/rules/reactTree.ts @@ -182,7 +182,12 @@ export function directives(root: Node): Node[] { /** 文件开头写了 "use client":这个文件里的组件在浏览器里渲染(Next.js 的客户端组件)。 */ export function isClientFile(root: Node): boolean { - return directives(root).some(statement => stringContent(syntax(statement)[0]) === 'use client') + return !!clientDirective(root) +} + +/** 文件开头那句 "use client";没写时没有。 */ +export function clientDirective(root: Node): Node | undefined { + return directives(root).find(statement => stringContent(syntax(statement)[0]) === 'use client') } /** 名字怎么导出的:声明前面写了 export(带 default 是默认导出),或文件里别处 `export default 名字` / `export { 名字 }`。 */ diff --git a/vscode/webview-ui/src/codeReader/SegmentText.test.tsx b/vscode/webview-ui/src/codeReader/SegmentText.test.tsx index b84b172..66b2226 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.test.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.test.tsx @@ -31,3 +31,19 @@ describe('着色的词', () => { expect(html).toBe('/users/user_id') }) }) + +describe('框起来的一组词', () => { + it('小标签只有框头的字,颜色写在 style 上;按条件的前缀只有图标、框里的词照常上色', () => { + const tag = renderToStaticMarkup() + expect(tag).toBe('具名导出') + const condition = renderToStaticMarkup() + expect(condition).toMatch(/^]*>.*<\/svg><\/span>当<\/span><\/span>$/) + const branch = renderToStaticMarkup() + expect(branch).toBe('是') + }) + + it('收起来的类名是个按钮:悬停提示是全部类名,收着时不弹层', () => { + const html = renderToStaticMarkup() + expect(html).toBe('') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index 07e43b4..1ff770b 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -1,7 +1,7 @@ import type { LucideIcon } from 'lucide-react' import type { CSSProperties, ReactElement } from 'react' import type { GroupStyle, Inline, SegmentGroup } from './types' -import { useContext } from 'react' +import { useContext, useState } from 'react' import { DisplayNameContext, shownName } from './displayNames' import { KIND_ICON_COMPONENTS, KIND_ICONS } from './kindIcons' import { isCurrent, segmentOccurrence, sourceOf } from './occurrences' @@ -42,19 +42,62 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement ) } -/** 框的样式 → 框头图标:遍历同「循环」块的图标;颜色在 codeReader.css 的 .cr-group[data-style] 里。 */ -const GROUP_ICONS: Record = { iteration: KIND_ICON_COMPONENTS[KIND_ICONS.loop] } +/** + * 框的样式 → 框头图标:遍历同「循环」块、按条件同「条件」块、不渲染同「返回」块的图标;条件的一支、小标签、类名不带图标。 + * 颜色在 codeReader.css 的 .cr-group[data-style] 里,框自己给了颜色(color)时用它。 + */ +const GROUP_ICONS: Record = { + iteration: KIND_ICON_COMPONENTS[KIND_ICONS.loop], + condition: KIND_ICON_COMPONENTS[KIND_ICONS.if], + branch: undefined, + skip: KIND_ICON_COMPONENTS[KIND_ICONS.return], + tag: undefined, + classes: undefined, +} -/** 框起来的一组词:嵌在前后文字里的小框,放不下时整个挪到下一行,比一行还长时在框里换行。 */ +/** 框自己的颜色,暗色、亮色两个都写在 style 上,CSS 按主题挑一个(见 .cr-group[data-colored])。 */ +function groupColor(group: SegmentGroup): CSSProperties | undefined { + return group.color && { '--group-dark': group.color.dark, '--group-light': group.color.light } as CSSProperties +} + +/** + * 框起来的一组词:嵌在前后文字里的小框,放不下时整个挪到下一行,比一行还长时在框里换行。框头是图标和 label,只有图标、只有字、都没有都行; + * 收起来的类名另见 ClassesChip。 + */ function GroupBox({ group }: { group: SegmentGroup }): ReactElement { + if (group.style === 'classes') + return const Icon = GROUP_ICONS[group.style] return ( - - - - {group.label} - + + {(Icon || group.label) && ( + + {Icon && } + {group.label} + + )} ) } + +/** 收起来的一串类名(「样式 N 个类」):悬停看全部类名,点一下在它下面弹出、盖住后面的字,再点收起。 */ +function ClassesChip({ group }: { group: SegmentGroup }): ReactElement { + const [open, setOpen] = useState(false) + return ( + + + {open && {group.detail}} + + ) +} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 2b86561..3057f67 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -381,6 +381,73 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c color: var(--group); white-space: nowrap; } +/* 只有图标的框头(渲染大纲一行前面的「当 … 时」):图标和后面的字贴近一点;框里没有词时(小标签、类名)框头后面不留空 */ +.cr-group-head[data-bare] { + margin-right: 4px; +} +.cr-group-head:last-child { + margin-right: 0; +} + +/* + * 渲染大纲一行前面的小框(React 组件卡片):按条件、条件的一支同「条件」块的颜色,不渲染同「返回」块; + * 小标签(名称后面的「具名导出」、属性名后面的「回调」)用规则给的颜色(暗色、亮色各一个,按主题挑) + */ +.cr-group:is([data-style="condition"], [data-style="branch"]) { --group: var(--cr-kind-branch); } +.cr-group[data-style="skip"] { --group: var(--cr-kind-return); } +.cr-group[data-colored] { --group: var(--group-dark); } +body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { --group: var(--group-light); } +.cr-group[data-style="tag"] { + line-height: 16px; + margin-top: 1px; +} + +/* + * 收起来的一串类名(「样式 N 个类」):中性色的小框,是个按钮;点开时换成焦点色的边、在下面弹出全部类名, + * 弹层盖住后面的字,不挤开它们 + */ +.cr-group[data-style="classes"] { + margin: 0; + font: inherit; + color: inherit; + cursor: pointer; + background: var(--vscode-input-background); + border-color: var(--vscode-editorWidget-border, var(--vscode-contrastBorder, transparent)); +} +.cr-group[data-style="classes"] .cr-group-head { + font-weight: 400; + color: var(--vscode-descriptionForeground); +} +.cr-group[data-style="classes"]:hover, +.cr-group[data-style="classes"][data-open] { + border-color: var(--vscode-focusBorder); +} +.cr-group[data-style="classes"][data-open] .cr-group-head { + color: var(--vscode-foreground); +} +.cr-classes { + position: relative; + display: inline-block; + vertical-align: top; +} +.cr-classes-popup { + position: absolute; + top: calc(100% + 2px); + left: 0; + z-index: 20; + width: max-content; + max-width: min(460px, 70vw); + padding: 6px 8px; + border: 1px solid var(--vscode-editorHoverWidget-border, var(--vscode-editorWidget-border)); + border-radius: 6px; + background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background)); + box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgb(0 0 0 / 36%)); + color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground)); + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; + line-height: 18px; + white-space: pre-wrap; +} /* * 属性表里带列名的子行(输入参数、数据表的字段)排成的表格:外框圆角,格子之间、行之间一条主题的表格分隔线; diff --git a/vscode/webview-ui/src/codeReader/kindIcons.tsx b/vscode/webview-ui/src/codeReader/kindIcons.tsx index 5577849..a025063 100644 --- a/vscode/webview-ui/src/codeReader/kindIcons.tsx +++ b/vscode/webview-ui/src/codeReader/kindIcons.tsx @@ -3,6 +3,7 @@ import type { ReactElement } from 'react' import type { BlockKind, BlockView, KindIconName } from './types' import { Activity, + Atom, Box, Braces, CalendarClock, @@ -17,6 +18,7 @@ import { ListOrdered, Lock, MessageSquareText, + MousePointerClick, Network, Package, Play, @@ -34,6 +36,7 @@ import { Terminal, TriangleAlert, Variable, + Zap, } from 'lucide-react' /** 插件定义的这组种类图标,每个名字一个 lucide 图标(逐个 import,打包只带这些)。 */ @@ -70,6 +73,9 @@ export const KIND_ICON_COMPONENTS: Record = { 'calendar-clock': CalendarClock, 'globe': Globe, 'network': Network, + 'atom': Atom, + 'zap': Zap, + 'mouse-pointer-click': MousePointerClick, } /** diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 1e415ef..5aaff89 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -28,14 +28,23 @@ export interface Segment { text: string, role: TokenRole, offset?: number, refer /** 规则自己查得到定义的一个名字:哪个规则、规则自己认的键 */ export interface RuleReference { rule: string, key: string } -/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块 */ -export type GroupStyle = 'iteration' +/** + * 框的样式,决定框头的图标和框的颜色:iteration 写在表达式里的遍历(同「循环」块),condition 按条件(同「条件」块), + * branch 条件的一支(同「条件」块的颜色、不带图标),skip 这个条件下不渲染(同「返回」块),tag 小标签(颜色是 SegmentGroup.color), + * classes 收起来的一串类名(中性色,点开、悬停看 detail) + */ +export type GroupStyle = 'iteration' | 'condition' | 'branch' | 'skip' | 'tag' | 'classes' -/** 框起来的一组词:画成嵌在前后文字里的一个小框,框头是图标和 label;框里的词照样带源码下标,还能再套框 */ +/** + * 框起来的一组词:画成嵌在前后文字里的一个小框,框头是图标和 label(只有图标时 label 是空的);框里的词照样带源码下标,还能再套框。 + * color 盖过样式的颜色(tag 按标签的意思上色),detail 是点开、悬停时看的全文(classes 的全部类名,一行一条) + */ export interface SegmentGroup { style: GroupStyle label: string segments: Inline[] + color?: ThemeColor + detail?: string } /** 译文里的一段:着色的词,或框起来的一组词 */ @@ -90,7 +99,7 @@ export type KindIconName = | 'import' | 'square-function' | 'box' | 'braces' | 'list-ordered' | 'triangle-alert' | 'tag' | 'lock' | 'variable' | 'split' | 'corner-down-right' | 'repeat' | 'shield' | 'shield-alert' | 'shield-check' | 'corner-down-left' | 'code' | 'message-square-text' | 'terminal' | 'sliders-horizontal' | 'signpost' | 'square-terminal' | 'layers' | 'copy' | 'activity' | 'play' - | 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' + | 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' | 'atom' | 'zap' | 'mouse-pointer-click' /** 库规则的种类:名字(悬停提示、读屏念的文字)、颜色和图标(从 KindIconName 里挑);块左边只画图标 */ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName }