diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 9432cd1..40092b4 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,5 +1,5 @@ import type { Tree } from '@vscode/tree-sitter-wasm' -import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, HttpEndpoint, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, NameBinding, Node, PreviewTable, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract' +import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, HttpEndpoint, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, NameBinding, Node, PreviewTable, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, ThemeColor, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import type { ImportedName } from './symbolLinks' import { blockAttributes } from './attributes' @@ -38,6 +38,8 @@ interface BlockViewBase { commentTarget?: CommentTarget /** 库规则的卡片才有:筛选栏里这个种类的名字,卡片边框的颜色(RuleCard.badge)。图标在 concepts 里。预览同类块一样默认收起它的子块。 */ badge?: KindBadge + /** 库规则的卡片给了淡底的颜色才有([RuleCard.tint]):卡片的淡底用它,边框仍是 `badge` 的颜色。 */ + tint?: ThemeColor /** 库规则挂在父卡片子行上的子块才有(见 RuleSlot):展开时画在父卡片属性表第 row 行下面,不排在父卡片的子块里。 */ slot?: BlockSlot /** 父卡片链式调用里收起来的闭包、async 块的子块才有:挂在那一步下面(见 ChainSlot),不排在父卡片的子块里。 */ @@ -47,8 +49,13 @@ interface BlockViewBase { children: BlockView[] } -/** 一块能看调用树:有名字的函数、类是 `{}`;讲 HTTP 接口的库规则卡片带上接口(RuleCard.endpoint),调用者里另外并上前端发这个请求的地方。 */ +/** + * 一块能看调用树:有名字的函数、类是 `{}`,查块自己的名字([BlockViewBase.defines]);讲 HTTP 接口的库规则卡片带上接口([RuleCard.endpoint]), + * 调用者里另外并上前端发这个请求的地方;库规则卡片查的不是自己的名字时带上那个名字([RuleCard.calls])。 + */ export interface CallTreeTarget { + /** 查这个名字被谁调用(React 钩子卡片返回的 `setOpen`);不填就查块自己的名字 */ + name?: SourceRange endpoint?: HttpEndpoint } @@ -389,7 +396,8 @@ export function toBlockViews( } /** * 这块的概念,从左到右,同一个 id 只留第一个:语言的种类(图标、名字用内置种类和翻译插件起的种类名), - * 语言插件另认的,库规则的卡片再加规则的种类和规则另认的。card 不填就是没有规则卡片(认成现成块的也算)。 + * 语言插件另认的,库规则的卡片再加规则的种类和规则另认的。card 不填就是没有规则卡片(认成现成块的也算); + * 规则卡片说了不带语言的概念([RuleCard.languageConcepts])时从规则的种类画起。 */ function blockConcepts(block: CodeBlock, card?: RuleCard): BlockConcept[] { const seen = new Set() @@ -400,13 +408,15 @@ export function toBlockViews( seen.add(concept.id) concepts.push(concept) } - add({ - id: block.kind, - label: layerTranslator?.attributeNames?.values.kind?.[block.kind] ?? block.kind, - icon: KIND_ICONS[block.kind], - }) - for (const concept of layerDocument.language.concepts?.(block) ?? []) - add(concept) + if (card?.languageConcepts !== false) { + add({ + id: block.kind, + label: layerTranslator?.attributeNames?.values.kind?.[block.kind] ?? block.kind, + icon: KIND_ICONS[block.kind], + }) + for (const concept of layerDocument.language.concepts?.(block) ?? []) + add(concept) + } if (card) { add({ id: card.kind, label: card.badge.label, icon: card.badge.icon, color: card.badge.color }) for (const concept of card.concepts ?? []) @@ -498,7 +508,7 @@ export function toBlockViews( const { attributes, comments } = block return plain({ ...replacement.as, node: block.node, header: statement, children: kept, ...(attributes && { attributes }), ...(comments && { comments }) }, span, id, scope) } - const { kind, badge, rows, defines, attributes, endpoint } = replacement + const { kind, badge, tint, rows, defines, attributes, endpoint, calls } = replacement const card = cardRows(block, rows) const places = chainPlaces(kept, card.properties) for (const { block: child, row, item } of replacement.slots ?? []) @@ -511,11 +521,12 @@ export function toBlockViews( ...spanLines(span), headerEndLine: statement.endRow, ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), - ...(defines && endpoint && { callTree: { endpoint } }), + ...(calls ? { callTree: { name: { startIndex: calls.startIndex, endIndex: calls.endIndex } } } : defines && endpoint && { callTree: { endpoint } }), attributes: { ...blockAttributes(block), ...attributes, kind }, concepts: blockConcepts(block, replacement), ...commentTarget(block), badge, + ...(tint && { tint }), children: list(kept, `${id}.`, inner, 0, places), ...card, } diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index d793a5a..32cbbe2 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -1043,7 +1043,8 @@ export interface ThemeColor { dark: string, light: string } * 种类图标:插件自己定义的一组 lucide 图标(https://lucide.dev/icons 上的 kebab-case 名字)。前 26 个给内置种类(几种共用一个: * 命令、管道、执行共用 square-terminal,解释器、解析指令共用 terminal,选项、设置、编译属性共用 sliders-horizontal,字段、标签共用 tag), * 宿主标的入口块也用这组里的 play(entries/entryMarks.ts 的 ENTRY_CONCEPT), - * 后面的给库规则的种类(React 的组件、状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出也在里面);库规则的种类(KindBadge.icon)只能从这组里挑,要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标), + * 后面的给库规则的种类和规则另认的概念(React 的组件、钩子、钩子的七种作用、事件处理函数、渲染输出也在里面);库规则的种类(KindBadge.icon)、概念(BlockConcept.icon)只能从这组里挑, + * 要新图标就加进这组(webview 的 kindIcons.tsx 同时给它配上图标), * 没有按任意名字找图标的路。 */ export const KIND_ICON_NAMES = [ @@ -1084,9 +1085,11 @@ export const KIND_ICON_NAMES = [ 'mouse-pointer-click', 'toggle-right', 'anchor', - 'download', 'sigma', 'layout-template', + 'fishing-hook', + 'share-2', + 'hourglass', ] as const export type KindIconName = (typeof KIND_ICON_NAMES)[number] @@ -1179,6 +1182,18 @@ export interface RuleCard { * 不改筛选的种类,也不改卡片边框色(仍是 badge)。 */ concepts?: BlockConcept[] + /** + * 填 `false` 时卡片左边不带语言认的概念(常量、语句这些种类的图标),从这个种类([RuleCard.badge])画起: + * React 钩子卡片左边第一个是钩子图标,不是它所在那句常量、语句的图标。不填就带上。 + */ + languageConcepts?: false + /** 卡片的淡底用这个颜色,不填就用种类色([RuleCard.badge] 的颜色);边框仍是种类色。React 钩子卡片的描边都是钩子紫,淡底按钩子从哪来分。 */ + tint?: ThemeColor + /** + * 卡片能看调用树,查的是源码里这个名字被谁调用:钩子卡片查返回的函数(`const [open, setOpen] = useState()` 的 `setOpen`), + * 自定义钩子的卡片查钩子自己。不填就没有调用树按钮(讲 HTTP 接口的卡片另见 [RuleCard.endpoint])。 + */ + calls?: SourceRange rows: PropertyRow[] /** 块自己的名字(类名)在源码里的范围:跳到的定义就是它时闪整块。 */ defines?: SourceRange diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 7680e1b..a23b006 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -7,7 +7,7 @@ import type { FileAttribute } from '../attributes' import type { BlockSlot, CallTreeTarget, ChainSlot, CommentTarget, SourceColors } from '../blockView' import type { ContractMiss } from '../callTree/endpoint' import type { CallTree } from '../callTree/model' -import type { BlockAttributes, BlockConcept, KindBadge, Markdown, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from '../contract' +import type { BlockAttributes, BlockConcept, KindBadge, Markdown, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange, ThemeColor } from '../contract' import type { OpenedFrom } from '../diff/diffBase' import type { DocumentDiff } from '../diff/previewDiff' import type { ShownName } from '../nameResolution' @@ -84,6 +84,7 @@ interface PageCardBase { concepts: BlockConcept[] commentTarget?: CommentTarget['kind'] badge?: KindBadge + tint?: ThemeColor slot?: BlockSlot step?: ChainSlot callTree?: CallTreeTarget diff --git a/vscode/src/codeReader/editor/pageBlocks.test.ts b/vscode/src/codeReader/editor/pageBlocks.test.ts index e2c5447..f99b40f 100644 --- a/vscode/src/codeReader/editor/pageBlocks.test.ts +++ b/vscode/src/codeReader/editor/pageBlocks.test.ts @@ -4,9 +4,12 @@ import { describe, expect, it } from 'vitest' import { toBlockViews } from '../blockView' import { createPythonAdapter } from '../languages/python' import { createRustAdapter } from '../languages/rust' +import { createTypeScriptAdapter } from '../languages/typescript' +import { reactRule } from '../rules/react' import { WASM_DIR, withBlocks } from '../testSupport' import { pythonZhTranslator } from '../translators/pythonZh' import { rustZhTranslator } from '../translators/rustZh' +import { typescriptZhTranslator } from '../translators/typescriptZh' import { pageBlocks } from './pageBlocks' const py = createPythonAdapter(WASM_DIR) @@ -88,3 +91,16 @@ describe('链式调用卡片发给页面', () => { expect(statement?.children[0]?.step).toEqual({ row: 1, step: 1 }) }) }) + +describe('react 钩子卡片发给页面', () => { + it('带着按来源分的淡底;调用树查的名字(返回的 setN)同别的下标从块第一行的行首算', async () => { + const ts = createTypeScriptAdapter(WASM_DIR) + const source = ['import { useState } from "react";', 'export function Box() {', ' const [n, setN] = useState(0);', ' return {n};', '}'].join('\n') + const views = await withBlocks(ts, 'typescriptreact', source, blocks => + toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator, { rules: [reactRule], report: () => {} })) + const hook = pageBlocks(views, source)[1].children[0] + expect(hook.kind).toBe('react.hook') + expect(hook.tint).toEqual({ dark: '#61dafb', light: '#0a7ea4' }) + expect(hook.callTree).toEqual({ name: { startIndex: ' const [n, '.length, endIndex: ' const [n, setN'.length } }) + }) +}) diff --git a/vscode/src/codeReader/editor/pageBlocks.ts b/vscode/src/codeReader/editor/pageBlocks.ts index e25f132..ff79b78 100644 --- a/vscode/src/codeReader/editor/pageBlocks.ts +++ b/vscode/src/codeReader/editor/pageBlocks.ts @@ -97,9 +97,10 @@ export function pageCard(block: BlockView, base: number): PageCard { concepts: block.concepts, ...(block.commentTarget && { commentTarget: block.commentTarget.kind }), ...(block.badge && { badge: block.badge }), + ...(block.tint && { tint: block.tint }), ...(block.slot && { slot: block.slot }), ...(block.step && { step: block.step }), - ...(block.callTree && { callTree: block.callTree }), + ...(block.callTree && { callTree: block.callTree.name ? { ...block.callTree, name: shiftRange(block.callTree.name, base) } : block.callTree }), } if ('properties' in block) return { ...card, properties: block.properties.map(row => shiftRow(row, base)) } diff --git a/vscode/src/codeReader/fixtures/hooksPanel.tsx.txt b/vscode/src/codeReader/fixtures/hooksPanel.tsx.txt new file mode 100644 index 0000000..8ea0ab6 --- /dev/null +++ b/vscode/src/codeReader/fixtures/hooksPanel.tsx.txt @@ -0,0 +1,60 @@ +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' + +const ThemeContext = createContext('light') + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'add': return { ...state, count: state.count + 1 } + default: return state + } +} + +export function useChatSession(id: string) { + const [messages, setMessages] = useState([]) + useEffect(() => { + const ws = connect(id) + ws.onmessage = e => setMessages(m => [...m, e.data]) + return () => ws.close() + }, [id]) + return { messages } +} + +export function Panel({ id, onClose }: Props) { + const router = useRouter() + const theme = useContext(ThemeContext) + const { messages } = useChatSession(id) + const [open, setOpen] = useState(() => readOpen(id)) + const [state, dispatch] = useReducer(reducer, { count: 0 }) + const [isPending, startTransition] = useTransition() + const boxRef = useRef(null) + const timer = useRef() + const total = useMemo(() => { + const sum = messages.length + return sum * 2 + }, [messages]) + const toggle = useCallback((next: boolean) => { + setOpen(next) + dispatch({ type: 'add' }) + }, []) + useEffect(() => { + timer.current = window.setTimeout(() => setOpen(false), 1000) + return () => clearTimeout(timer.current) + }, [open]) + useEffect(() => { + document.title = String(total) + }) + useLayoutEffect(() => { + boxRef.current?.scrollTo(0, 0) + }, []) + function handleClick() { + startTransition(() => toggle(!open)) + } + return ( +
+ {isPending && } + {messages.map(m => )} + {state.count} +
+ ) +} diff --git a/vscode/src/codeReader/rules/react.test.ts b/vscode/src/codeReader/rules/react.test.ts index e44a32d..cf0aeb0 100644 --- a/vscode/src/codeReader/rules/react.test.ts +++ b/vscode/src/codeReader/rules/react.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest' import { toBlockViews } from '../blockView' import { diffBlockTrees } from '../diff/blockDiff' import { createTypeScriptAdapter } from '../languages/typescript' -import { inlineOf, previewText, propertyLines, readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport' +import { inlineOf, previewText, propertyLines, propertyRow, readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport' import { typescriptZhTranslator } from '../translators/typescriptZh' import { reactRule } from './react' @@ -17,8 +17,18 @@ const SHARE = readFixture('shareButton.tsx.txt') const SETTINGS = readFixture('desktopSettingsDialog.tsx.txt') const PLAN = readFixture('planChecklist.tsx.txt') const QUEUED = readFixture('queuedBubble.tsx.txt') +const HOOKS = readFixture('hooksPanel.tsx.txt') -const DATA = { dark: '#9cdcfe', light: '#001080' } +/** 钩子卡片的描边、钩子图标:所有钩子一样的紫 */ +const HOOK = { dark: '#b180d7', light: '#7f3fa6' } +/** 钩子卡片的淡底按钩子从哪来分 */ +const TINT = { + react: { dark: '#61dafb', light: '#0a7ea4' }, + library: { dark: '#e5c07b', light: '#9a6a00' }, + own: { dark: '#89d185', light: '#388a34' }, +} +const HOOK_BADGE = { label: '钩子', color: HOOK, icon: 'fishing-hook' } +const HOOK_CONCEPT = { id: 'react.hook', label: '钩子', icon: 'fishing-hook', color: HOOK } /** 规则查名字的定义时查到的值(名字原文 → IndexEntry.value),别的没查到。 */ function known(values: Record): RuleContext['lookup'] { @@ -90,12 +100,12 @@ describe('react 库规则:认领', () => { '构造参数: 1 个', ' platform | DesktopShell["platform"] | — | 是', ]) - expect(kinds(view)).toEqual(['react.source 13', 'react.source 14', 'variable 15', 'react.render 17']) + expect(kinds(view)).toEqual(['react.hook 13', 'react.hook 14', 'variable 15', 'react.render 17']) expect(view.children.map(child => child.slot)).toEqual([undefined, undefined, undefined, undefined]) }) it('文件里没有组件时 "use client" 照常成块;组件上面没并进块头部的 JSDoc 讲成说明、不单独成块', async () => { - expect((await views('"use client";\nexport const useX = () => 1;')).map(view => view.kind)).toEqual(['statement', 'const']) + expect((await views('"use client";\nexport const useX = () => 1;')).map(view => view.kind)).toEqual(['statement', 'react.customHook']) 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']) @@ -257,9 +267,9 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis expect(properties(view).map(row => row.label)).toEqual(['名称', '说明', '构造参数']) expect(rowLines(view, '构造参数')[0]).toBe('构造参数: 2 个 · 类型 PlanChecklistProps') expect(kinds(view)).toEqual([ - 'react.state 35', - 'react.effect 37', - 'react.derived 47', + 'react.hook 35', + 'react.hook 37', + 'react.hook 47', 'if 57', 'variable 58', 'variable 59', @@ -270,26 +280,34 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis ]) }) - it('状态:名称 / 初始值 / 更新位置,更新位置只列名字和位置,名字能跳到定义,不带函数体', async () => { + it('useState:钩子 / 参数 / 返回,返回表的名称能跳到定义,改状态的函数用函数色', async () => { const state = childAt(await card(PLAN, 'PlanChecklist'), 35) - expect(state.badge).toEqual({ label: '状态', color: DATA, icon: 'toggle-right' }) - expect(lines(state)).toEqual(['名称: collapsed', '初始值: 假', '更新位置: 副作用、toggle']) - const updates = properties(state)[2] - expect(updates.items).toBeUndefined() - const toggle = !('markdown' in updates.value) ? updates.value.find(piece => 'role' in piece && piece.text === 'toggle') : undefined - const offset = toggle && 'role' in toggle ? toggle.offset ?? -1 : -1 - expect(PLAN.slice(offset, offset + 6)).toBe('toggle') - expect(PLAN.slice(0, offset).split('\n').length).toBe(47) + expect(state.badge).toEqual(HOOK_BADGE) + expect(lines(state)).toEqual([ + '钩子: useState', + '参数: 1 个', + ' initialState | 假', + '返回: 2 个', + ' collapsed | 当前值', + ' setCollapsed | 修改函数', + ]) + const returns = propertyRow(state, '返回') + expect(returns.columns).toEqual(['名称', '是什么']) + expect(returns.items?.map(item => item.defines && PLAN.slice(item.defines.startIndex, item.defines.endIndex))).toEqual(['collapsed', 'setCollapsed']) + expect(returns.items?.map(item => inlineOf(item.cells[0])[0])).toMatchObject([{ role: 'variable' }, { role: 'function' }]) + expect(propertyRow(state, '参数').columns).toEqual(['参数', '值']) expect(state.children).toEqual([]) }) - it('副作用、useCallback 的派生值:只留各自的几行,函数里的 if / 调用 / 尝试是下面的子卡片', async () => { + it('useEffect、useCallback:函数参数收成小框,函数体照常是子卡片', async () => { const view = await card(PLAN, 'PlanChecklist') const effect = childAt(view, 37) - expect(effect.badge).toEqual({ label: '副作用', color: { dark: '#e5c07b', light: '#9a6a00' }, icon: 'zap' }) expect(viewLines([effect])).toEqual([ - 'react.effect 37', - ' 触发时机: 仅挂载时', + 'react.hook 37', + ' 钩子: useEffect', + ' 参数: 2 个', + ' effect | ⟦函数|· 1 句⟧', + ' deps | 仅挂载时', ' try 38: 尝试', ' if 39: 如果 (localStorage.getItem(PLAN_CHECKLIST_COLLAPSED_KEY) 全等 "1")', ' statement 40: queueMicrotask(() => setCollapsed(真));', @@ -297,12 +315,14 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis ' 注释: localStorage 不可用 (隐私模式等) 时静默用默认', ]) const toggle = childAt(view, 47) - expect(toggle.badge).toEqual({ label: '派生值', color: DATA, icon: 'sigma' }) expect(viewLines([toggle])).toEqual([ - 'react.derived 47', - ' 名称: toggle', - ' 计算表达式: 函数(3 条语句)', - ' 依赖项: collapsed(useCallback)', + 'react.hook 47', + ' 钩子: useCallback', + ' 参数: 2 个', + ' callback | ⟦函数|· 3 句⟧', + ' deps | collapsed 变化时', + ' 返回: 1 个', + ' toggle | 记住的函数', ' variable 48', ' 名称: next', ' 初始值: collapsed 不成立', @@ -331,23 +351,32 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis }) describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」)', () => { - it('原地修改框 QueuedEditor:状态、引用、变量、副作用、两个事件处理函数、渲染输出照源码顺序,函数体是子卡片', async () => { + it('原地修改框 QueuedEditor:三个钩子、变量、两个事件处理函数、渲染输出照源码顺序,函数体是子卡片', async () => { const view = await card(QUEUED, 'QueuedEditor') expect(lines(view).slice(0, 1)).toEqual(['名称: QueuedEditor ⟦客户端组件|⟧']) expect(viewLines(view.children)).toEqual([ - 'react.state 171', - ' 名称: draft', - ' 初始值: item.text', - ' 更新位置: