diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 0532647..9c09ce7 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,9 +1,9 @@ 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, ThemeColor, TokenRole, Translator } from './contract' +import type { BlockAttributes, BlockConcept, BlockKind, CallTreeAction, 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' -import { KIND_ICONS } from './contract' +import { actionKey, KIND_ICONS } from './contract' import { commonIndent, splitLines } from './editText' import { explanationSpan, isCommentBlock } from './languages/shared' import { activeRules, claimBlock, importsIn } from './libraryRules' @@ -50,12 +50,11 @@ interface BlockViewBase { } /** - * 一块能看调用树:有名字的函数、类是 `{}`,查块自己的名字([BlockViewBase.defines]);讲 HTTP 接口的库规则卡片带上接口([RuleCard.endpoint]), - * 调用者里另外并上前端发这个请求的地方;库规则卡片查的不是自己的名字时带上那个名字([RuleCard.calls])。 + * 一块能看调用树,查块自己的名字([BlockViewBase.defines])被谁调用:有名字的函数、类,说了能看调用树的库规则卡片([RuleCard.calls])是 `{}`; + * 讲 HTTP 接口的库规则卡片带上接口([RuleCard.endpoint]),调用者里另外并上前端发这个请求的地方。 + * 卡片属性表里各行自己查的名字是那一行的操作([CallTreeAction]),不在这里。 */ export interface CallTreeTarget { - /** 查这个名字被谁调用(React 钩子卡片返回的 `setOpen`);不填就查块自己的名字 */ - name?: SourceRange endpoint?: HttpEndpoint } @@ -323,6 +322,38 @@ function withCommentRow(rows: readonly PropertyRow[], row: PropertyRow): Propert return [...rows.slice(0, at), row, ...rows.slice(at)] } +/** + * 属性表里的各个操作([PropertyRow.actions]、[PropertyItem.actions])按先后挨个改:从上往下一行一行,一行里先是它自己的,再是它的子行, + * 子行里先是自己的,再是下一层子行、套着的表。没有操作的行、子行原样留着。 + */ +export function mapActions(rows: readonly PropertyRow[], map: (action: CallTreeAction) => CallTreeAction): PropertyRow[] { + const item = (entry: PropertyItem): PropertyItem => ({ + ...entry, + ...(entry.actions && { actions: entry.actions.map(map) }), + ...(entry.items && { items: entry.items.map(item) }), + ...(entry.table && { table: { ...entry.table, items: entry.table.items.map(item) } }), + }) + return rows.map(row => row.actions || row.items + ? { ...row, ...(row.actions && { actions: row.actions.map(map) }), ...(row.items && { items: row.items.map(item) }) } + : row) +} + +/** 属性表里的各个操作,顺序同 [mapActions]。 */ +export function rowActions(rows: readonly PropertyRow[]): CallTreeAction[] { + const found: CallTreeAction[] = [] + mapActions(rows, (action) => { + found.push(action) + return action + }) + return found +} + +/** 卡片属性表里的操作挨个(顺序同 [mapActions])起好键(见 [actionKey]):页面按键对上次数、点开调用树。 */ +function keyedActions(rows: readonly PropertyRow[], anchor: string): PropertyRow[] { + let index = 0 + return mapActions(rows, action => ({ ...action, key: actionKey(anchor, index++) })) +} + /** 子块挂在父卡片里的哪儿:库规则挂在属性表子行上的(slot),或挂在链式调用某一步下面的(step)。 */ type CardPlace = Pick @@ -398,9 +429,9 @@ export function toBlockViews( return layer(steps, part.document, part.translator, NO_RULES, false)(steps.length === 1 && only ? only.children : steps, `${id}.`, scope, from) } /** - * 这块的概念,从左到右,同一个 id 只留第一个:语言的种类(图标、名字用内置种类和翻译插件起的种类名), - * 语言插件另认的,库规则的卡片再加规则的种类和规则另认的。card 不填就是没有规则卡片(认成现成块的也算); - * 规则卡片说了不带语言的概念([RuleCard.languageConcepts])时从规则的种类画起。 + * 这块的概念,从左到右,同一个 id 只留第一个:语言的种类(图标、名字用内置种类和翻译插件起的种类名;规则卡片说了照哪种内置种类画的照它, + * 见 [RuleCard.languageKind]),语言插件另认的,库规则的卡片再加规则的种类(悬停的字、图标颜色另给了的照给的,见 [KindBadge])和规则另认的。 + * card 不填就是没有规则卡片(认成现成块的也算)。 */ function blockConcepts(block: CodeBlock, card?: RuleCard): BlockConcept[] { const seen = new Set() @@ -411,17 +442,17 @@ export function toBlockViews( seen.add(concept.id) concepts.push(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) - } + const kind = card?.languageKind ?? block.kind + add({ + id: kind, + label: layerTranslator?.attributeNames?.values.kind?.[kind] ?? kind, + icon: KIND_ICONS[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 }) + const { badge } = card + add({ id: card.kind, label: badge.tooltip ?? badge.label, icon: badge.icon, color: badge.iconColor ?? badge.color }) for (const concept of card.concepts ?? []) add(concept) } @@ -512,11 +543,12 @@ export function toBlockViews( return plain({ ...replacement.as, node: block.node, header: statement, children: kept, ...(attributes && { attributes }), ...(comments && { comments }) }, span, id, scope) } const { kind, badge, tint, rows, defines, attributes, endpoint, calls } = replacement - const card = cardRows(block, rows) - const places = chainPlaces(kept, card.properties) + const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(statement)) + const card = cardRows(block, keyedActions(rows, anchor)) + const children = withExpanded(kept, replacement.expands ?? []) + const places = chainPlaces(children, card.properties) for (const { block: child, row, item } of replacement.slots ?? []) places.set(child, { slot: { row, item } }) - const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(statement)) return { id, anchor, @@ -524,16 +556,27 @@ export function toBlockViews( ...spanLines(span), headerEndLine: statement.endRow, ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), - ...(calls ? { callTree: { name: { startIndex: calls.startIndex, endIndex: calls.endIndex } } } : defines && endpoint && { callTree: { endpoint } }), + ...(defines && (calls ? { callTree: {} } : endpoint && { callTree: { endpoint } })), attributes: { ...blockAttributes(block), ...attributes, kind }, concepts: blockConcepts(block, replacement), ...commentTarget(block), badge, ...(tint && { tint }), - children: list(kept, `${id}.`, inner, 0, places), + children: list(children, `${id}.`, inner, 0, places), ...card, } } + /** + * 规则卡片的子块并上要展开的函数的主体([RuleCard.expands],见 [LanguageAdapter.functionBody]):语言插件已经把主体排成了子块的函数不重复排, + * 并进来的按源码先后排。 + */ + function withExpanded(kept: CodeBlock[], expands: readonly Node[]): CodeBlock[] { + const body = layerDocument.language.functionBody + const extra = body + ? expands.filter(fn => !kept.some(child => child.node.startIndex >= fn.startIndex && child.node.endIndex <= fn.endIndex)).flatMap(fn => body(fn, layerRules.languageId)) + : [] + return extra.length > 0 ? [...kept, ...extra].sort((a, b) => a.header.startIndex - b.header.startIndex) : kept + } /** * 一串兄弟块转成视图数据;略去的块不占位置,id 按出了块的往下数。places 是挂在父卡片里的那几块挂在哪: * 属性表的子行上(见 BlockSlot),或链式调用的某一步下面(见 ChainSlot)。 diff --git a/vscode/src/codeReader/callTree/query.ts b/vscode/src/codeReader/callTree/query.ts index 5c6a7fe..2c6f1c0 100644 --- a/vscode/src/codeReader/callTree/query.ts +++ b/vscode/src/codeReader/callTree/query.ts @@ -4,63 +4,69 @@ */ import type { CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, DocumentSymbol, Location, SymbolInformation, TextDocument } from 'vscode' -import type { RuleLocation } from '../contract' +import type { RuleLocation, SourceRange } from '../contract' import type { IndexedEntry, IndexFind } from '../workspaceIndex' import type { CallEdge, CallRange, CallSymbol } from './model' -import type { OutlineSymbol } from './referenceCalls' +import type { OutlineSymbol, ReferenceSite } from './referenceCalls' import type { RequestEntry } from './requests' -import type { CallTreeBlock, LoadedCalls } from './session' +import type { CallTarget, LoadedCalls } from './session' import type { IncomingHit } from './walk' import * as path from 'node:path' -import { commands, Position, SymbolKind, Uri, workspace } from 'vscode' +import { commands, Position, Range, SymbolKind, Uri, workspace } from 'vscode' import { endpointCalls } from './endpoint' import { symbolKey } from './model' -import { referenceCalls } from './referenceCalls' -import { nameOffset } from './session' +import { countedSites, referenceCalls } from './referenceCalls' import { collectCallEdges } from './walk' /** - * 问一块的调用关系,按它的调用树查的那个名字(见 [nameOffset])。接口卡片(callTree.endpoint)先到契约里找前端发这个请求的地方(见 endpoint.ts), - * 并进起点的调用者;契约里对不上时带上 contractMiss,Python 里的调用者照常问。 + * 问一个调用树(见 [CallTarget])的调用关系。查引用的(references)起点的调用者是各处引用(见 [loadCallEdges] 的 `references`); + * 接口卡片(endpoint)先到契约里找前端发这个请求的地方(见 endpoint.ts),并进起点的调用者;契约里对不上时带上 contractMiss,Python 里的调用者照常问。 */ -export async function loadBlockCalls(document: TextDocument, block: CallTreeBlock, find: IndexFind, cancelled: () => boolean): Promise { - const offset = nameOffset(block) - const endpoint = block.callTree?.endpoint - if (offset === undefined) - return undefined +export async function loadTargetCalls(document: TextDocument, target: CallTarget, find: IndexFind, cancelled: () => boolean): Promise { + const { offset, endpoint } = target + if (target.mode === 'references') + return loadCallEdges(document, offset, cancelled, { references: target.except ?? [] }) if (!endpoint) return loadCallEdges(document, offset, cancelled) const found = await endpointCalls(find, endpoint) if (cancelled()) return undefined if ('requests' in found) - return loadCallEdges(document, offset, cancelled, found.requests) - const loaded = await loadCallEdges(document, offset, cancelled, []) + return loadCallEdges(document, offset, cancelled, { requests: found.requests }) + const loaded = await loadCallEdges(document, offset, cancelled, { requests: [] }) const files = found.contracts.map(uri => workspace.asRelativePath(Uri.parse(uri))) return loaded && { ...loaded, contractMiss: { files, ...(found.ambiguous && { ambiguous: found.ambiguous }) } } } +/** 起点的调用者另外从哪来:前端发这个请求的地方(requests),或者改查这个名字的所有引用(references 是不算的范围)。 */ +interface StartCallers { + requests?: readonly IndexedEntry[] + references?: readonly SourceRange[] +} + /** * 在 offset 这个名字起点上问调用关系。 * 先准备符号,再往上问谁调用了它;路径上的调用者再问它还调用了谁,用来收「另外 N 处」。 * 给了 requests(接口卡片,可以是空的)时,它们并进起点的调用者,从它们往上照常问编辑器; * 这时编辑器认不出起点也照样收前端这一路,起点按名字起点自己写一个。 - * 没给 `requests`、编辑器又认不出起点(数组解构出来的 `setOpen` 这类,见 [referenceCalls])时,起点的调用者改查引用, - * 起点同样按名字起点自己写一个,从查到的调用者往上照常问编辑器。 + * 给了 references 时查所有用到这个名字的地方:起点按名字起点自己写一个(变量),各处引用(名字自己的定义处、落在 references 这几段里的不算, + * 见 [countedSites])按所在的函数、组件并成起点的调用者,从它们往上照常问编辑器。 + * 都没给、编辑器又认不出起点(数组解构出来的 `setOpen` 这类,见 [referenceCalls])时,起点的调用者同样改查引用、只留调用处, + * 起点同样按名字起点自己写一个。 */ export async function loadCallEdges( document: TextDocument, offset: number, cancelled: () => boolean, - requests?: readonly IndexedEntry[], + { requests, references }: StartCallers = {}, ): Promise<{ symbol: CallSymbol, edges: CallEdge[] } | undefined> { if (cancelled()) return undefined const position = document.positionAt(offset) - const items = await prepare(document.uri, position) + const items = references ? [] : await prepare(document.uri, position) if (cancelled()) return undefined - const byReferences = items.length === 0 && !requests + const byReferences = references !== undefined || (items.length === 0 && !requests) const vscodeItems = new Map() const remember = (item: CallHierarchyItem): CallSymbol => { @@ -78,7 +84,7 @@ export async function loadCallEdges( seen.add(key) starts.push(symbol) } - const symbol = starts.find(item => item.line === position.line && item.character === position.character) ?? starts[0] ?? symbolAt(document, position) + const symbol = starts.find(item => item.line === position.line && item.character === position.character) ?? starts[0] ?? symbolAt(document, position, references ? 'variable' : 'function') const sent: IncomingHit[] = [] for (const { location, entry } of requests ?? []) { if (cancelled()) @@ -101,10 +107,17 @@ export async function loadCallEdges( return [...calls, ...sent] } - /** 起点这个名字的各处引用里的调用处,调用者是包着它、编辑器的调用关系认得的最内层符号(见 [referenceCalls])。 */ + /** + * 起点这个名字的各处引用,调用者是包着它、编辑器的调用关系认得的最内层符号(见 [referenceCalls]): + * 查所有引用时去掉不算的那几处,别的时候只留调用处。 + */ async function referenced(): Promise { const locations = await run('vscode.executeReferenceProvider', document.uri, position) ?? [] - return referenceCalls(locations.map(location => ({ uri: location.uri.toString(), range: toRange(location.range) })), { + const sites: ReferenceSite[] = locations.map(location => ({ uri: location.uri.toString(), range: toRange(location.range) })) + const counted = references + ? countedSites(sites, { uri: document.uri.toString(), line: position.line, character: position.character }, references.map(item => toRange(rangeOf(document, item)))) + : sites + return referenceCalls(counted, { after: site => textAfter(site.uri, site.range), outline: async uri => outlineOf(await run<(DocumentSymbol | SymbolInformation)[]>('vscode.executeDocumentSymbolProvider', Uri.parse(uri)) ?? []), prepare: async (uri, line, character) => { @@ -113,7 +126,7 @@ export async function loadCallEdges( }, file: fileSymbol, key: symbolKey, - }, cancelled) + }, cancelled, { callsOnly: !references }) } async function incoming(node: CallSymbol) { @@ -196,19 +209,24 @@ async function prepare(uri: Uri, position: Position): Promise item) } -/** 编辑器认不出起点时按名字起点自己写的符号。 */ -function symbolAt(document: TextDocument, position: Position): CallSymbol { +/** 编辑器认不出起点、或查的是引用时按名字起点自己写的符号;kind 只决定图标。 */ +function symbolAt(document: TextDocument, position: Position, kind: string): CallSymbol { const word = document.getWordRangeAtPosition(position) return { uri: document.uri.toString(), line: position.line, character: position.character, name: word ? document.getText(word) : '', - kind: 'function', + kind, path: workspace.asRelativePath(document.uri), } } +/** 源码里一段(下标)在这份文档里的起止位置。 */ +function rangeOf(document: TextDocument, range: SourceRange): CallHierarchyItem['range'] { + return new Range(document.positionAt(range.startIndex), document.positionAt(range.endIndex)) +} + function toSymbol(item: CallHierarchyItem): CallSymbol { const start = item.selectionRange.start return { diff --git a/vscode/src/codeReader/callTree/referenceCalls.test.ts b/vscode/src/codeReader/callTree/referenceCalls.test.ts index fa6311f..bf476cf 100644 --- a/vscode/src/codeReader/callTree/referenceCalls.test.ts +++ b/vscode/src/codeReader/callTree/referenceCalls.test.ts @@ -1,7 +1,7 @@ import type { CallRange } from './model' import type { OutlineSymbol, ReferenceLookup, ReferenceSite } from './referenceCalls' import { describe, expect, it } from 'vitest' -import { isCallSite, referenceCalls } from './referenceCalls' +import { countedSites, isCallSite, referenceCalls } from './referenceCalls' function range(line: number, character: number, length: number): CallRange { return { line, character, endLine: line, endCharacter: character + length } @@ -55,7 +55,7 @@ describe('调用关系认不出的名字改查引用', () => { { uri: 'file:///main.tsx', range: range(3, 0, 7) }, ] const asked: string[] = [] - const calls = await referenceCalls(sites, lookup({ '30:15': ', setOpen] =', '34:4': '(next)', '38:42': '(false), 1000)', '42:4': '(() => toggle(!open))', '3:0': '(true)' }, asked), () => false) + const calls = await referenceCalls(sites, lookup({ '30:15': ', setOpen] =', '34:4': '(next)', '38:42': '(false), 1000)', '42:4': '(() => toggle(!open))', '3:0': '(true)' }, asked), () => false, { callsOnly: true }) expect(calls).toEqual([ { from: 'Panel', ranges: [range(34, 4, 7), range(38, 42, 7)] }, { from: 'handleClick', ranges: [range(42, 4, 15)] }, @@ -72,7 +72,41 @@ describe('调用关系认不出的名字改查引用', () => { done = true return OUTLINE }, - }, () => done) + }, () => done, { callsOnly: true }) expect(calls).toEqual([]) }) }) + +describe('查所有引用(React 状态卡片上的「谁用了这个状态」)', () => { + it('不只调用处:各处引用都按所在的函数并起来,不问名字后面写了什么', async () => { + const sites: ReferenceSite[] = [ + { uri: URI, range: range(38, 20, 4) }, + { uri: URI, range: range(42, 30, 4) }, + { uri: URI, range: range(44, 10, 4) }, + ] + const after = async (): Promise => { + throw new Error('查所有引用不看名字后面') + } + const calls = await referenceCalls(sites, { ...lookup({}), after }, () => false, { callsOnly: false }) + expect(calls).toEqual([ + { from: 'Panel', ranges: [range(38, 20, 4), range(44, 10, 4)] }, + { from: 'handleClick', ranges: [range(42, 30, 4)] }, + ]) + }) + + it('去掉名字自己的定义处和依赖数组里的那几处,别的文件里的照留', () => { + const declaration = { uri: URI, line: 30, character: 9 } + const deps = [{ line: 36, character: 5, endLine: 36, endCharacter: 18 }] + const sites: ReferenceSite[] = [ + { uri: URI, range: range(30, 9, 4) }, + { uri: URI, range: range(36, 6, 4) }, + { uri: URI, range: range(36, 12, 4) }, + { uri: URI, range: range(38, 20, 4) }, + { uri: 'file:///other.tsx', range: range(30, 9, 4) }, + ] + expect(countedSites(sites, declaration, deps)).toEqual([ + { uri: URI, range: range(38, 20, 4) }, + { uri: 'file:///other.tsx', range: range(30, 9, 4) }, + ]) + }) +}) diff --git a/vscode/src/codeReader/callTree/referenceCalls.ts b/vscode/src/codeReader/callTree/referenceCalls.ts index 5937c29..805a09c 100644 --- a/vscode/src/codeReader/callTree/referenceCalls.ts +++ b/vscode/src/codeReader/callTree/referenceCalls.ts @@ -1,7 +1,8 @@ /** - * 编辑器的调用关系认不出的名字改查引用:TypeScript 的调用关系只认函数、类、方法这些声明, - * 数组解构出来的 `setOpen`(`const [open, setOpen] = useState()`)、`const toggle = useCallback(…)` 的 toggle 都准备不出符号。 - * 这时查这个名字的各处引用,只留调用处,每处往外找第一个调用关系认得的符号当调用者,再从它往上照常问编辑器。 + * 按引用找调用者。两种时候用:编辑器的调用关系认不出的名字改查引用(TypeScript 的调用关系只认函数、类、方法这些声明, + * 数组解构出来的 `setOpen`(`const [open, setOpen] = useState()`)、`const toggle = useCallback(…)` 的 toggle 都准备不出符号),只留调用处; + * 查的就是所有引用(React 状态卡片上「谁用了这个状态」),各处都算,名字自己的定义处、依赖数组里写的那几处除外(见 [countedSites])。 + * 每处往外找第一个调用关系认得的符号当调用者,再从它往上照常问编辑器。 * 问编辑器的动作由外面传入:测试喂假的,宿主去问编辑器命令(见 [查调用关系](vscode/src/codeReader/callTree/query.ts#loadCallEdges))。 */ @@ -45,17 +46,38 @@ export function isCallSite(after: string): boolean { return /^\s*(?:\?\.\s*)?(?:<[^<>()]*>\s*)?\(/.test(after) } +/** 名字在一个文件里的一处位置(名字起点),行和列从 0 起。 */ +export interface SitePoint { + uri: string + line: number + character: number +} + /** - * 各处引用里的调用处,按调用者并起来,照第一次出现的顺序:调用者是包着这处调用、调用关系认得的最内层符号, - * 都不认得时按文件算。cancelled 变成真就停,已经收到的也不要了。 + * 查所有引用时算数的几处:去掉名字自己的定义处(引用里带着声明,起点就在 declaration 的那一处), + * 和同一个文件里起点落在 except 这几段里的(React 钩子依赖数组里写的那一次)。 */ -export async function referenceCalls(sites: readonly ReferenceSite[], lookup: ReferenceLookup, cancelled: () => boolean): Promise[]> { +export function countedSites(sites: readonly ReferenceSite[], declaration: SitePoint, except: readonly CallRange[]): ReferenceSite[] { + return sites.filter(({ uri, range }) => { + if (uri !== declaration.uri) + return true + if (range.line === declaration.line && range.character === declaration.character) + return false + return !except.some(outer => contains(outer, range)) + }) +} + +/** + * 各处引用按调用者并起来,照第一次出现的顺序:调用者是包着这一处、调用关系认得的最内层符号,都不认得时按文件算。 + * callsOnly 时只留调用处(名字后面紧跟着实参,见 [isCallSite])。cancelled 变成真就停,已经收到的也不要了。 + */ +export async function referenceCalls(sites: readonly ReferenceSite[], lookup: ReferenceLookup, cancelled: () => boolean, { callsOnly }: { callsOnly: boolean }): Promise[]> { const outlines = new Map>() const calls = new Map>() for (const site of sites) { if (cancelled()) return [] - if (!isCallSite(await lookup.after(site))) + if (callsOnly && !isCallSite(await lookup.after(site))) continue const outline = outlines.get(site.uri) ?? lookup.outline(site.uri) outlines.set(site.uri, outline) diff --git a/vscode/src/codeReader/callTree/session.test.ts b/vscode/src/codeReader/callTree/session.test.ts index 1612c94..d09ef73 100644 --- a/vscode/src/codeReader/callTree/session.test.ts +++ b/vscode/src/codeReader/callTree/session.test.ts @@ -1,7 +1,7 @@ import type { CallEdge, CallSymbol } from './model' import type { CallTreeBlock, CallTreeSource } from './session' import { describe, expect, it } from 'vitest' -import { CallTreeSession, nameOffset } from './session' +import { CallTreeSession } from './session' function symbol(name: string): CallSymbol { return { uri: `file:///${name}`, line: 0, character: 0, name, kind: 'function', path: `${name}.py` } @@ -37,10 +37,10 @@ describe('调用树棵数在文档发出去之后算', () => { block('alone', 'function', 40), { anchor: 'bare', kind: 'function', defines: { startIndex: 10 } }, ], - load: async ({ defines }) => { - if (defines?.startIndex === 10) + load: async ({ offset }) => { + if (offset === 10) return { symbol: mask, edges } - if (defines?.startIndex === 30) + if (offset === 30) return { symbol: symbol('inner'), edges: [{ caller, callee: symbol('inner'), at: edges[0].at }] } return { symbol: symbol('nobody'), edges: [] } }, @@ -54,27 +54,45 @@ describe('调用树棵数在文档发出去之后算', () => { session.dispose() }) - it('卡片查的名字不是自己的名字时(钩子卡片查返回的 setOpen)按 callTree.name 问,没有 defines 也数,先数离视口近的', async () => { - const asked: (number | undefined)[] = [] + it('卡片属性表里各行的操作(React 状态卡片解构出的各项)各数各的,按操作的键记;查引用的次数是用到几处,先数离视口近的', async () => { + const asked: [number, string, unknown][] = [] + const at = (line: number) => ({ line, character: 0, endLine: line, endCharacter: 7 }) + const except = [{ startIndex: 400, endIndex: 410 }] const source: CallTreeSource = { version: 7, blocks: [ { anchor: 'far', kind: 'function', defines: { startIndex: 900 }, callTree: {} }, - { anchor: 'state', kind: 'react.hook', defines: { startIndex: 500 }, callTree: { name: { startIndex: 120, endIndex: 127 } } }, - { anchor: 'tuple', kind: 'react.hook', callTree: { name: { startIndex: 300, endIndex: 308 } } }, - { anchor: 'none', kind: 'react.hook', defines: { startIndex: 50 } }, + { + anchor: 'state', + kind: 'react.state', + properties: [{ + label: '名称', + value: [], + columns: ['第几项', '名称'], + items: [ + { cells: [], actions: [{ kind: 'callTree', name: { startIndex: 120, endIndex: 124 }, mode: 'references', label: '谁用了这个状态', except, key: 'state|0' }] }, + { cells: [], actions: [{ kind: 'callTree', name: { startIndex: 126, endIndex: 133 }, mode: 'references', label: '谁改了这个状态', except, key: 'state|1' }] }, + ], + }], + }, + { anchor: 'ref', kind: 'react.ref', properties: [{ label: '名称', value: [], actions: [{ kind: 'callTree', name: { startIndex: 300, endIndex: 308 }, mode: 'calls', label: '谁用了', key: 'ref|0' }] }] }, + { anchor: 'none', kind: 'react.hookValue', defines: { startIndex: 50 } }, ], - load: async (block) => { - asked.push(nameOffset(block)) - return { symbol: symbol('setOpen'), edges: [{ caller: symbol('Panel'), callee: symbol('setOpen'), at: { line: 1, character: 0, endLine: 1, endCharacter: 7 } }] } + load: async ({ offset, mode, except: skipped }) => { + asked.push([offset, mode, skipped]) + const callee = symbol(`at ${offset}`) + return { symbol: callee, edges: [at(1), at(2), at(3)].map(range => ({ caller: symbol('Panel'), callee, at: range })) } }, } const posted: unknown[] = [] const session = new CallTreeSession(message => posted.push(message), () => source, offset => offset) session.documentSent() await viFlush() - expect(asked).toEqual([120, 300, 900]) - expect(posted).toEqual([{ type: 'callTreeCounts', version: 7, counts: { far: 1, state: 1, tuple: 1 } }]) + expect(asked).toEqual([[120, 'references', except], [126, 'references', except], [300, 'calls', undefined], [900, 'calls', undefined]]) + expect(posted).toEqual([{ type: 'callTreeCounts', version: 7, counts: { 'far': 1, 'state|0': 3, 'state|1': 3, 'ref|0': 1 } }]) + posted.length = 0 + session.open(7, 'state|1') + expect(posted).toEqual([expect.objectContaining({ type: 'callTree', version: 7, key: 'state|1', name: 'at 126' })]) session.dispose() }) @@ -114,7 +132,7 @@ describe('调用树棵数在文档发出去之后算', () => { blocks: [block('fn', 'function', 1)], load: async () => ({ symbol: mask, edges: [edge] }), } - const posted: { type: string, version?: number, anchor?: string, trees?: { name: string }[] }[] = [] + const posted: { type: string, version?: number, key?: string, trees?: { name: string }[] }[] = [] const session = new CallTreeSession(message => posted.push(message), () => source, () => 0) session.open(3, 'fn') expect(posted).toEqual([]) @@ -125,7 +143,7 @@ describe('调用树棵数在文档发出去之后算', () => { expect(posted).toEqual([{ type: 'callTree', version: 4, - anchor: 'fn', + key: 'fn', name: 'mask_phone', where: 'mask_phone.py:1', trees: [expect.objectContaining({ name: 'get_profile', hits: 1 })], @@ -162,7 +180,7 @@ describe('调用树棵数在文档发出去之后算', () => { { type: 'callTree', version: 5, - anchor: 'fn', + key: 'fn', name: 'mask_phone', where: 'mask_phone.py:1', trees: [expect.objectContaining({ name: 'get_profile', hits: 1 })], @@ -171,7 +189,7 @@ describe('调用树棵数在文档发出去之后算', () => { posted.length = 0 session.open(5, 'fn') expect(asked).toBe(2) - expect(posted).toEqual([expect.objectContaining({ type: 'callTree', anchor: 'fn' })]) + expect(posted).toEqual([expect.objectContaining({ type: 'callTree', key: 'fn' })]) session.dispose() }) @@ -188,7 +206,7 @@ describe('调用树棵数在文档发出去之后算', () => { posted.length = 0 session.open(6, 'fn') await viFlush() - expect(posted).toEqual([{ type: 'callTree', version: 6, anchor: 'fn', name: '', where: '', trees: [] }]) + expect(posted).toEqual([{ type: 'callTree', version: 6, key: 'fn', name: '', where: '', trees: [] }]) session.dispose() }) @@ -229,7 +247,7 @@ describe('调用树棵数在文档发出去之后算', () => { version: 9, blocks: [{ anchor: 'route', kind: 'fastapi.route', defines: { startIndex: 5 }, callTree: { endpoint } }], load: async (target) => { - asked.push(target.callTree?.endpoint) + asked.push(target.endpoint) return { symbol: route, edges: [{ caller: page, callee: route, at: { line: 3, character: 2, endLine: 3, endCharacter: 9 }, request: 'GET /api/v1/agents' }] } }, } @@ -265,7 +283,7 @@ describe('调用树棵数在文档发出去之后算', () => { expect(posted).toEqual([{ type: 'callTree', version: 10, - anchor: 'route', + key: 'route', name: 'list_agents', where: 'list_agents.py:1', trees: [], @@ -293,11 +311,11 @@ describe('同一版文档又发一遍(页面重新加载、按新规则重发 const source: CallTreeSource = { version: 3, blocks: [block('fn', 'function', 1), block('alone', 'function', 2), block('unknown', 'function', 3)], - load: async ({ anchor }) => { - asked.push(anchor) - if (anchor === 'unknown') + load: async ({ key }) => { + asked.push(key) + if (key === 'unknown') return undefined - return { symbol: mask, edges: anchor === 'fn' ? called(mask) : [] } + return { symbol: mask, edges: key === 'fn' ? called(mask) : [] } }, } const posted: unknown[] = [] @@ -316,7 +334,7 @@ describe('同一版文档又发一遍(页面重新加载、按新规则重发 posted.length = 0 session.open(3, 'fn') - expect(posted).toEqual([expect.objectContaining({ type: 'callTree', version: 3, anchor: 'fn', trees: [expect.objectContaining({ name: 'get_profile' })] })]) + expect(posted).toEqual([expect.objectContaining({ type: 'callTree', version: 3, key: 'fn', trees: [expect.objectContaining({ name: 'get_profile' })] })]) expect(asked).toHaveLength(3) session.dispose() }) @@ -330,9 +348,9 @@ describe('同一版文档又发一遍(页面重新加载、按新规则重发 const source: CallTreeSource = { version: 4, blocks: [block('first', 'function', 1), block('second', 'function', 2)], - load: async ({ anchor }) => { - asked.push(anchor) - if (anchor === 'second') + load: async ({ key }) => { + asked.push(key) + if (key === 'second') await gate return { symbol: mask, edges: called(mask) } }, @@ -391,8 +409,8 @@ describe('同一版文档又发一遍(页面重新加载、按新规则重发 version: 7, blocks: [route('/agents'), block('fn', 'function', 1)], load: async (target) => { - asked.push(target.callTree?.endpoint?.path ?? target.anchor) - return { symbol: mask, edges: target.callTree?.endpoint?.path === '/api/v1/agents' ? called(mask) : [] } + asked.push(target.endpoint?.path ?? target.key) + return { symbol: mask, edges: target.endpoint?.path === '/api/v1/agents' ? called(mask) : [] } }, } const posted: unknown[] = [] @@ -418,8 +436,8 @@ describe('后台数的顺序', () => { const source: CallTreeSource = { version: 1, blocks: [block('a', 'function', 10), block('b', 'function', 500), block('c', 'class', 300)], - load: async ({ defines }) => { - asked.push(defines?.startIndex ?? -1) + load: async ({ offset }) => { + asked.push(offset) return { symbol: symbol('nobody'), edges: [] } }, } diff --git a/vscode/src/codeReader/callTree/session.ts b/vscode/src/codeReader/callTree/session.ts index 4768af7..0093934 100644 --- a/vscode/src/codeReader/callTree/session.ts +++ b/vscode/src/codeReader/callTree/session.ts @@ -1,26 +1,45 @@ /** - * 文档发出去之后,在后台数每个能看调用树的块(函数、类、接口卡片、返回里有函数的 React 钩子卡片)落在几棵调用树里,问到的都记下(没人调用的也记),数完把棵数发给页面。 - * 页面点开某一块时,问到过有调用者就把树发回去;没问过、或上回没人调用就当场问这一块,问到了补发棵数再发树,没问到发一棵空的; + * 文档发出去之后,在后台数各个调用树的次数:能看调用树的块(函数、类、接口卡片、自定义钩子)落在几棵调用树里, + * 卡片属性表里的操作(React 状态卡片解构出的各项)按它的查法数(见 [CallTarget.mode])。问到的都记下(没人调用的也记),数完把次数发给页面。 + * 页面点开某一个时,问到过有调用者就把树发回去;没问过、或上回没人调用就当场问这一个,问到了补发次数再发树,没问到发一棵空的; * 接口在契约文件里没对上时,发出去的树带着 contractMiss,页面照它说明,不当成没人调用。 - * 问到的结果跟着文档版本走:同一版文档又发一遍(页面重新加载、按新规则重发)时接着用,已知的棵数马上补发, - * 只问还没问过(或问法变了)的块;换了一版、预览关掉才丢掉。问的时候文档版本变了,结果也丢掉。 + * 问到的结果跟着文档版本走:同一版文档又发一遍(页面重新加载、按新规则重发)时接着用,已知的次数马上补发, + * 只问还没问过(或问法变了)的;换了一版、预览关掉才丢掉。问的时候文档版本变了,结果也丢掉。 */ import type { CallTreeTarget } from '../blockView' +import type { HttpEndpoint, PropertyRow, SourceRange } from '../contract' import type { ExtensionToWebview } from '../editor/messages' import type { ContractMiss } from './endpoint' import type { CallEdge, CallSymbol, CallTree } from './model' -import { buildCallTrees } from './model' +import { rowActions } from '../blockView' +import { buildCallTrees, symbolKey } from './model' -/** 页面上的一块里,数调用树用得到的那几项。 */ +/** 页面上的一块里,数调用树用得到的那几项:块自己的调用树(callTree),属性表里各行的操作(properties)。 */ export interface CallTreeBlock { anchor: string kind: string defines?: { startIndex: number } callTree?: CallTreeTarget + properties?: readonly PropertyRow[] children?: readonly CallTreeBlock[] } +/** + * 要数、要查的一个调用树:块自己的(键是块的记号,查块自己的名字),或卡片属性表里一个操作的(键是操作的键,查那一行的名字)。 + * calls 查谁调用了它,次数是落在几棵调用树里;references 查所有用到它的地方,次数是用到几处(落在 except 里的不算)。 + */ +export interface CallTarget { + key: string + /** 查的那个名字在源码里的起点 */ + offset: number + mode: 'calls' | 'references' + /** 块的种类:查的是类时,直接调用它的那一层写成「新建」 */ + kind: string + endpoint?: HttpEndpoint + except?: readonly SourceRange[] +} + /** 问到的一块的调用关系:起点、调用边;接口在契约文件里没对上时带 contractMiss。 */ export interface LoadedCalls { symbol: CallSymbol @@ -28,18 +47,18 @@ export interface LoadedCalls { contractMiss?: ContractMiss } -/** 眼下这份文档,以及去问某一块(按它的名字起点,接口卡片另带接口)的调用关系。问的时候可以取消。 */ +/** 眼下这份文档,以及去问一个调用树(见 [CallTarget])的调用关系。问的时候可以取消。 */ export interface CallTreeSource { version: number blocks: readonly CallTreeBlock[] - load: (block: CallTreeBlock, cancelled: () => boolean) => Promise + load: (target: CallTarget, cancelled: () => boolean) => Promise } interface Cached extends LoadedCalls { classTarget: boolean } -/** 问过的一块:按什么问的(见 targetOf)、落在几棵调用树里;语言不提供时没有 calls,棵数是 0。 */ +/** 问过的一个:按什么问的(见 targetOf)、次数;语言不提供时没有 calls,次数是 0。 */ interface Answer { target: string count: number @@ -50,7 +69,7 @@ interface Answer { export class CallTreeSession { /** 手上的结果是哪一版文档的;还没数过、预览关掉了是 undefined */ private version: number | undefined - /** 这一版里问过的块,按 anchor 记 */ + /** 这一版里问过的,按调用树的键记 */ private readonly answers = new Map() /** 后台正在数哪一版;没在数是 undefined */ private scanning: number | undefined @@ -73,18 +92,18 @@ export class CallTreeSession { this.restart(src.version) } - /** 页面要看某一块。不是这一版文档的不理;问到过有调用者就直接发;没问到过、或上回没人调用,就当场问这一块。 */ - open(version: number, anchor: string): void { + /** 页面要看键是 key 的调用树。不是这一版文档的不理;问到过有调用者就直接发;没问到过、或上回没人调用,就当场问这一个。 */ + open(version: number, key: string): void { const src = this.source() if (!src || src.version !== version) return - const block = callBlocks(src.blocks).find(item => item.block.anchor === anchor)?.block - const known = block && this.answerOf(block) + const target = callTargets(src.blocks).find(item => item.key === key) + const known = target && this.answerOf(target) if (known?.calls && known.count > 0) { - this.postTree(version, anchor, known.calls) + this.postTree(version, key, known.calls) return } - void this.askNow(version, anchor, block) + void this.askNow(version, key, target) } dispose(): void { @@ -92,7 +111,7 @@ export class CallTreeSession { this.answers.clear() } - /** 同一版文档又发了一遍(页面重新加载、按新规则重发):之前发的页面可能没收到,先把已知的棵数补过去;后台没在数就接着数还没问过的块。 */ + /** 同一版文档又发了一遍(页面重新加载、按新规则重发):之前发的页面可能没收到,先把已知的次数补过去;后台没在数就接着数还没问过的。 */ private resume(version: number): void { this.postCounts(version) if (this.scanning !== version) @@ -107,8 +126,8 @@ export class CallTreeSession { } /** - * 一块一块问还没问过的,先问离视口近的;每问完一块按眼下发出去的块树、眼下的视口再挑下一块(数的时候文档可能又按同一版重发了、页面可能滚了)。 - * 问了才在数完时发棵数。 + * 一个一个问还没问过的,先问离视口近的;每问完一个按眼下发出去的块树、眼下的视口再挑下一个(数的时候文档可能又按同一版重发了、页面可能滚了)。 + * 问了才在数完时发次数。 */ private async scan(version: number): Promise { this.scanning = version @@ -118,15 +137,15 @@ export class CallTreeSession { const src = this.source() if (!src || this.stale(version)) return - let next: CallBlock | undefined - for (const block of callBlocks(src.blocks)) { - if (!this.answerOf(block.block) && (!next || this.distance(block.offset) < this.distance(next.offset))) - next = block + let next: CallTarget | undefined + for (const target of callTargets(src.blocks)) { + if (!this.answerOf(target) && (!next || this.distance(target.offset) < this.distance(next.offset))) + next = target } if (!next) break asked = true - await this.collect(version, src, next.block) + await this.collect(version, src, next) } if (asked) this.postCounts(version) @@ -137,40 +156,40 @@ export class CallTreeSession { } } - /** 当场问页面点开的那一块:问到了先补发棵数再发树,没问到(找不到这块、没人调用)发一棵空的;接口在契约里没对上时发带 contractMiss 的树。 */ - private async askNow(version: number, anchor: string, block: CallTreeBlock | undefined): Promise { + /** 当场问页面点开的那一个:问到了先补发次数再发树,没问到(找不到它、没人调用)发一棵空的;接口在契约里没对上时发带 contractMiss 的树。 */ + private async askNow(version: number, key: string, target: CallTarget | undefined): Promise { const src = this.source() - const found = block && src && await this.collect(version, src, block) + const found = target && src && await this.collect(version, src, target) if (this.stale(version)) return if (!found?.calls || (found.count === 0 && !found.calls.contractMiss)) { - this.postEmpty(version, anchor) + this.postEmpty(version, key) return } if (found.count > 0) this.postCounts(version) - this.postTree(version, anchor, found.calls) + this.postTree(version, key, found.calls) } - /** 问一块有谁调用,记下问法和棵数再交回来;没人调用、语言不提供的也记下。问到一半作废了交回空、不记。 */ - private async collect(version: number, src: CallTreeSource, block: CallTreeBlock): Promise { - const loaded = await src.load(block, () => this.stale(version)) + /** 问一个调用树,记下问法和次数再交回来;没人调用、语言不提供的也记下。问到一半作废了交回空、不记。 */ + private async collect(version: number, src: CallTreeSource, target: CallTarget): Promise { + const loaded = await src.load(target, () => this.stale(version)) if (this.stale(version)) return undefined - const calls = loaded && { ...loaded, classTarget: block.kind === 'class' } + const calls = loaded && { ...loaded, classTarget: target.kind === 'class' } const answer: Answer = { - target: targetOf(block), - count: calls ? buildCallTrees(calls.edges, calls.symbol, { classTarget: calls.classTarget }).length : 0, + target: targetOf(target), + count: calls ? countOf(target, calls) : 0, ...(calls && { calls }), } - this.answers.set(block.anchor, answer) + this.answers.set(target.key, answer) return answer } - /** 这一块按眼下的问法问过的结果;没问过、问法变了(库规则查到的接口变了)是 undefined。 */ - private answerOf(block: CallTreeBlock): Answer | undefined { - const answer = this.answers.get(block.anchor) - return answer?.target === targetOf(block) ? answer : undefined + /** 这一个按眼下的问法问过的结果;没问过、问法变了(库规则查到的接口变了、依赖数组改了)是 undefined。 */ + private answerOf(target: CallTarget): Answer | undefined { + const answer = this.answers.get(target.key) + return answer?.target === targetOf(target) ? answer : undefined } /** 手上的结果不是 version 这一版的了,或者文档又改了:问到的不能再用。 */ @@ -178,23 +197,23 @@ export class CallTreeSession { return this.version !== version || this.source()?.version !== version } - /** 眼下这份块树里有调用者的块各落在几棵树里。 */ + /** 眼下这份块树里各个调用树大于 0 的次数。 */ private postCounts(version: number): void { const counts: Record = {} - for (const { block } of callBlocks(this.source()?.blocks ?? [])) { - const count = this.answerOf(block)?.count ?? 0 + for (const target of callTargets(this.source()?.blocks ?? [])) { + const count = this.answerOf(target)?.count ?? 0 if (count > 0) - counts[block.anchor] = count + counts[target.key] = count } this.post({ type: 'callTreeCounts', version, counts }) } - private postTree(version: number, anchor: string, cached: Cached): void { + private postTree(version: number, key: string, cached: Cached): void { const trees: CallTree[] = buildCallTrees(cached.edges, cached.symbol, { classTarget: cached.classTarget }) this.post({ type: 'callTree', version, - anchor, + key, name: cached.symbol.name, where: `${cached.symbol.path}:${cached.symbol.line + 1}`, trees, @@ -202,33 +221,35 @@ export class CallTreeSession { }) } - private postEmpty(version: number, anchor: string): void { - this.post({ type: 'callTree', version, anchor, name: '', where: '', trees: [] }) + private postEmpty(version: number, key: string): void { + this.post({ type: 'callTree', version, key, name: '', where: '', trees: [] }) } } +/** 次数:查调用的是落在几棵调用树里,查引用的是起点被用到几处(直接连着起点的调用边,一处一条)。 */ +function countOf(target: CallTarget, calls: Cached): number { + if (target.mode === 'references') { + const start = symbolKey(calls.symbol) + return calls.edges.filter(edge => symbolKey(edge.callee) === start).length + } + return buildCallTrees(calls.edges, calls.symbol, { classTarget: calls.classTarget }).length +} + /** - * 一块的调用树查哪个名字:名字起点在源码里的下标。卡片查的不是自己的名字时(钩子卡片查返回的 `setOpen`)是那个名字, - * 否则是块自己的名字;没标能看调用树、没有名字的块没有。 + * 块树里要数的调用树,含嵌在别的块里面的:宿主标了能看调用树(callTree)、有名字起点的块查自己的名字, + * 卡片属性表里的操作(见 [rowActions])查那一行的名字。 */ -export function nameOffset(block: CallTreeBlock): number | undefined { - return block.callTree ? block.callTree.name?.startIndex ?? block.defines?.startIndex : undefined -} - -/** 能看调用树、有名字起点的一块,和它的名字起点(见 [nameOffset])。 */ -interface CallBlock { - block: CallTreeBlock - offset: number -} - -/** 宿主标了能看调用树(callTree)、有名字起点的块,含嵌在别的块里面的。 */ -function callBlocks(blocks: readonly CallTreeBlock[]): CallBlock[] { - const found: CallBlock[] = [] +export function callTargets(blocks: readonly CallTreeBlock[]): CallTarget[] { + const found: CallTarget[] = [] const walk = (list: readonly CallTreeBlock[]) => { for (const block of list) { - const offset = nameOffset(block) - if (offset !== undefined) - found.push({ block, offset }) + const offset = block.callTree && block.defines?.startIndex + if (block.callTree && offset !== undefined) + found.push({ key: block.anchor, offset, mode: 'calls', kind: block.kind, ...(block.callTree.endpoint && { endpoint: block.callTree.endpoint }) }) + for (const action of rowActions(block.properties ?? [])) { + if (action.key !== undefined) + found.push({ key: action.key, offset: action.name.startIndex, mode: action.mode, kind: block.kind, ...(action.except && { except: action.except }) }) + } if (block.children?.length) walk(block.children) } @@ -237,7 +258,7 @@ function callBlocks(blocks: readonly CallTreeBlock[]): CallBlock[] { return found } -/** 一块怎么问:名字起点和接口。同一版文档按新规则重发时接口可能变了(查到了挂载前缀),变了就得重问。 */ -function targetOf(block: CallTreeBlock): string { - return JSON.stringify([nameOffset(block), block.callTree?.endpoint]) +/** 一个调用树怎么问:名字起点、查法、接口、不算的范围。同一版文档按新规则重发时接口可能变了(查到了挂载前缀),变了就得重问。 */ +function targetOf(target: CallTarget): string { + return JSON.stringify([target.offset, target.mode, target.endpoint, target.except]) } diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 2716151..c16408b 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -795,6 +795,39 @@ export interface PropertyItem extends Defines { * 那格文字下面,排成列表的子行把它排在自己下面。总是展开的;表里的行又可以带 items、table,一层层往下套。 */ table?: PreviewTable + /** 这一行能做的操作(见 [RowAction]):表格的行排在最后多出来的「操作」一列,从左往右;列表的子行没有这一列,不画 */ + actions?: RowAction[] +} + +/** + * 表格一行、属性一行上能做的一个操作,页面画成一个按钮。现在只有看调用树一种([CallTreeAction])。 + */ +export type RowAction = CallTreeAction + +/** + * 看调用树:查源码里一个名字被谁用、被谁调用,按钮上带次数(0 次只画图标),点开是调用树弹框。 + * 卡片上讲的不止一个名字时(React 状态卡片的解构表,一项一行)各行自己查自己的名字,不在卡片右上角放调用树按钮。 + */ +export interface CallTreeAction { + kind: 'callTree' + /** 查的那个名字在源码里的范围 */ + name: SourceRange + /** + * 怎么查:calls 是查谁调用了它(编辑器的调用关系,认不出这个名字时改查引用、只留调用处),次数是落在几棵调用树里; + * references 是查所有用到它的地方,按所在的函数、组件分组,次数是用到几处 + */ + mode: 'calls' | 'references' + /** 按钮的悬停文字:「谁用了这个状态」「谁改了这个状态」 */ + label: string + /** 不算的地方:落在这几段范围里的引用不算(React 钩子的依赖数组里写的那一次) */ + except?: SourceRange[] + /** 宿主排视图数据时填的键(见 [actionKey]),页面按它对次数、点开调用树;规则不填 */ + key?: string +} + +/** 宿主给卡片上第 index 个操作(属性表从上往下、一行里先行自己再子行)起的键:块的记号加序号,同一版文档里不重。 */ +export function actionKey(anchor: string, index: number): string { + return `${anchor}|${index}` } /** @@ -811,6 +844,8 @@ export interface PropertyRow extends Defines { columns?: string[] /** 值是一条链式调用的起点时,链的各步:排成一条竖轨道,接在值下面(见 [ChainStep])。 */ chain?: ChainStep[] + /** 这一行能做的操作(见 [RowAction]):按钮排在这一行最右边,和同一张卡片表格里「操作」一列对齐 */ + actions?: RowAction[] } /** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行。 */ @@ -942,6 +977,11 @@ export interface LanguageAdapter { * 种类那一个由宿主按内置种类的图标和翻译插件起的种类名补在最前面,这里不要再报种类。 */ concepts?: (block: CodeBlock) => BlockConcept[] + /** + * 一个函数值(箭头函数、函数表达式)的主体分成的块:主体是语句块时是各句,直接返回表达式的是一个返回块(源码里没写 return,节点是那个表达式)。 + * 库规则要把实参里函数的主体排成卡片的子块时用([RuleCard.expands]);不填就排不出来。 + */ + functionBody?: (fn: Node, languageId: string) => CodeBlock[] } // ── 中文名(见「中文名」)── @@ -1153,7 +1193,15 @@ export interface BlockConcept { } /** 库规则的种类:筛选栏里叫 label,卡片边框用 color,icon 进概念列表。卡片左边不只这一个图标(见 BlockConcept)。 */ -export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName } +export interface KindBadge { + label: string + color: ThemeColor + icon: KindIconName + /** 种类图标悬停的字,不填同 label(`useLayoutEffect` 的副作用图标悬停「副作用 · 浏览器绘制前执行」,筛选栏里还叫「副作用」) */ + tooltip?: string + /** 种类图标的颜色,不填同 color(React 的记忆函数描边是函数色,图标是记忆值的 sigma 色) */ + iconColor?: ThemeColor +} /** * 库规则认领一块后给出的替身,预览里它代替原来那块,下面挂着原块留下的子块: @@ -1183,17 +1231,19 @@ export interface RuleCard { */ concepts?: BlockConcept[] /** - * 填 `false` 时卡片左边不带语言认的概念(常量、语句这些种类的图标),从这个种类([RuleCard.badge])画起: - * React 钩子卡片左边第一个是钩子图标,不是它所在那句常量、语句的图标。不填就带上。 + * 卡片左边第一个图标(语言的种类)照这种内置种类画,不填就是原块自己的种类: + * React 的记忆函数(`const submit = useCallback(…)`)语言报的是变量,卡片讲的是一个函数。 */ - languageConcepts?: false - /** 卡片的淡底用这个颜色,不填就用种类色([RuleCard.badge] 的颜色);边框仍是种类色。React 钩子卡片的描边都是钩子紫,淡底按钩子从哪来分。 */ + languageKind?: BlockKind + /** 卡片的淡底用这个颜色,不填就用种类色([RuleCard.badge] 的颜色);边框仍是种类色。React 钩子返回值的卡片描边是钩子紫,淡底按钩子从哪来分。 */ tint?: ThemeColor + /** 卡片能看调用树,查卡片自己的名字([RuleCard.defines])被谁调用,按钮在卡片右上角(React 自定义钩子);讲 HTTP 接口的卡片另见 [RuleCard.endpoint]。 */ + calls?: true /** - * 卡片能看调用树,查的是源码里这个名字被谁调用:钩子卡片查返回的函数(`const [open, setOpen] = useState()` 的 `setOpen`), - * 自定义钩子的卡片查钩子自己。不填就没有调用树按钮(讲 HTTP 接口的卡片另见 [RuleCard.endpoint])。 + * 原块里的这几个函数值(实参里的箭头函数、函数表达式)的主体也排成卡片的子块(见 [LanguageAdapter.functionBody]): + * React 钩子收到的函数参数,直接返回表达式的也要有子块(一张「返回 …」卡片)。语言插件本来就把主体排成了子块的不重复排。 */ - calls?: SourceRange + expands?: Node[] rows: PropertyRow[] /** 块自己的名字(类名)在源码里的范围:跳到的定义就是它时闪整块。 */ defines?: SourceRange diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index a23b006..df4d227 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -226,8 +226,9 @@ export interface StickyScrollSettings { * repoLinkBuiltin:点的不带路径的引用是语言自带、又问不到定义的名字,没打开,页面当普通文字;symbol、block、file 同 openRepoLink * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了,block、file 同 openRepoLink * repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在 - * callTreeCounts:version 这份文档里,每个能看调用树的块(BlockView.callTree)落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理 - * callTree:anchor 那块的调用树。trees 空着、又没有 contractMiss 是没有调用它的地方;接口卡片在契约文件里没对上时带 contractMiss。 + * callTreeCounts:version 这份文档里各个调用树的次数(只包括大于 0 的),按调用树的键:能看调用树的块(BlockView.callTree)是块的记号, + * 落在几棵调用树里;卡片属性表里的操作([看调用树的操作](vscode/src/codeReader/contract.ts#CallTreeAction))是它的键,按它的查法算次数。不是页面上这个版本的不理 + * callTree:键是 key 的那个调用树(同 callTreeCounts)。trees 空着、又没有 contractMiss 是没有调用它的地方;接口卡片在契约文件里没对上时带 contractMiss。 * 不是页面上这个版本的不理 */ export type ExtensionToWebview @@ -259,7 +260,7 @@ export type ExtensionToWebview | { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] } | { type: 'focusPathAsk' } | { type: 'callTreeCounts', version: number, counts: Record } - | { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss } + | { type: 'callTree', version: number, key: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss } /** * ready:页面准备好了,可以收消息;openedFrom 是页面记着的这个标签页是从哪儿打开的,新开的页面没记过就没有 @@ -292,7 +293,7 @@ export type ExtensionToWebview * block 是它写在哪块的注释里(见 SymbolReference),从那块的声明起一层层往外找那份文件定义的,再是导入的(见 symbolLinks.ts) * checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(references)问哪些找不到。id 标出是哪一次问 * checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问 - * openCallTree:打开 anchor 那块的调用树。version 是这块所在的文档版本,对不上就不查 + * openCallTree:打开键是 key 的调用树(块的记号,或卡片上一个操作的键,同 callTreeCounts)。version 是它所在的文档版本,对不上就不查 * goToCallNode:点了调用树上的一个节点,打开 uri 那个文件,跳到 line 行 character 列(都从 0 起,见 CallTreeNode.jump) */ export type WebviewToExtension @@ -320,5 +321,5 @@ export type WebviewToExtension | { type: 'openRepoLink', path?: string, symbol?: string, block?: string, file?: string, lines?: LineRange } | { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] } | { type: 'checkRepoImages', id: number, paths: string[] } - | { type: 'openCallTree', version: number, anchor: string } + | { type: 'openCallTree', version: number, key: string } | { type: 'goToCallNode', uri: string, line: number, character: number } diff --git a/vscode/src/codeReader/editor/pageBlocks.test.ts b/vscode/src/codeReader/editor/pageBlocks.test.ts index 2c093cc..73971db 100644 --- a/vscode/src/codeReader/editor/pageBlocks.test.ts +++ b/vscode/src/codeReader/editor/pageBlocks.test.ts @@ -93,14 +93,29 @@ describe('链式调用卡片发给页面', () => { }) describe('react 钩子卡片发给页面', () => { - it('带着按来源分的淡底;调用树查的名字(返回的 setN)同别的下标从块第一行的行首算', async () => { + it('钩子返回值带着按来源分的淡底;属性表里各项的操作查的名字、不算的范围同别的下标从块第一行的行首算,键原样带着', 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 source = [ + 'import { useEffect, useState } from "react";', + 'export function Box() {', + ' const [n, setN] = useState(0);', + ' const id = useId();', + ' useEffect(() => {}, [n]);', + ' return {n}{id};', + '}', + ].join('\n') const views = await withBlocks(ts, 'typescriptreact', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator, { languageId: 'typescriptreact', 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 } }) + const [state, id] = pageBlocks(views, source)[1].children + expect(state.kind).toBe('react.state') + expect(state.callTree).toBeUndefined() + const base = source.indexOf(' const [n') + const deps = { startIndex: source.indexOf('[n]);') - base, endIndex: source.indexOf(');\n return') - base } + expect('properties' in state && state.properties[0].items?.map(item => item.actions)).toEqual([ + [{ kind: 'callTree', name: { startIndex: ' const ['.length, endIndex: ' const [n'.length }, mode: 'references', label: '谁用了这个状态', except: [deps], key: `${state.anchor}|0` }], + [{ kind: 'callTree', name: { startIndex: ' const [n, '.length, endIndex: ' const [n, setN'.length }, mode: 'references', label: '谁改了这个状态', except: [deps], key: `${state.anchor}|1` }], + ]) + expect(id.kind).toBe('react.hookValue') + expect(id.tint).toEqual({ dark: '#61dafb', light: '#0a7ea4' }) }) }) diff --git a/vscode/src/codeReader/editor/pageBlocks.ts b/vscode/src/codeReader/editor/pageBlocks.ts index ff79b78..1635c11 100644 --- a/vscode/src/codeReader/editor/pageBlocks.ts +++ b/vscode/src/codeReader/editor/pageBlocks.ts @@ -1,5 +1,5 @@ import type { BlockView } from '../blockView' -import type { ChainStep, Inline, PreviewTable, PreviewText, PropertyItem, PropertyRow, Segment, SourceRange } from '../contract' +import type { ChainStep, Inline, PreviewTable, PreviewText, PropertyItem, PropertyRow, RowAction, Segment, SourceRange } from '../contract' import type { BlockMarks, ItemDiff, ItemsDiff, RowDiff, RowsDiff, TextDiff } from '../diff/viewDiff' import type { BlockSpan, PageBlock, PageCard } from './messages' import { createHash } from 'node:crypto' @@ -50,6 +50,11 @@ function shiftText(text: PreviewText, base: number): PreviewText { return 'markdown' in text ? text : shiftInline(text, base) } +/** 一个操作查的名字、不算的范围按块的开头算;键原样留着(页面按它对次数)。 */ +function shiftAction(action: RowAction, base: number): RowAction { + return { ...action, name: shiftRange(action.name, base), ...(action.except && { except: action.except.map(range => shiftRange(range, base)) }) } +} + function shiftItem(item: PropertyItem, base: number): PropertyItem { return { ...item, @@ -57,6 +62,7 @@ function shiftItem(item: PropertyItem, base: number): PropertyItem { ...(item.defines && { defines: shiftRange(item.defines, base) }), ...(item.items && { items: item.items.map(inner => shiftItem(inner, base)) }), ...(item.table && { table: shiftTable(item.table, base) }), + ...(item.actions && { actions: item.actions.map(action => shiftAction(action, base)) }), } } @@ -71,6 +77,7 @@ function shiftRow(row: PropertyRow, base: number): PropertyRow { ...(row.defines && { defines: shiftRange(row.defines, base) }), ...(row.items && { items: row.items.map(item => shiftItem(item, base)) }), ...(row.chain && { chain: row.chain.map(step => shiftStep(step, base)) }), + ...(row.actions && { actions: row.actions.map(action => shiftAction(action, base)) }), } } @@ -100,7 +107,7 @@ export function pageCard(block: BlockView, base: number): PageCard { ...(block.tint && { tint: block.tint }), ...(block.slot && { slot: block.slot }), ...(block.step && { step: block.step }), - ...(block.callTree && { callTree: block.callTree.name ? { ...block.callTree, name: shiftRange(block.callTree.name, base) } : block.callTree }), + ...(block.callTree && { callTree: block.callTree }), } if ('properties' in block) return { ...card, properties: block.properties.map(row => shiftRow(row, base)) } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 80847d0..782b990 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -26,7 +26,7 @@ import { commands, EndOfLine, Position, Range, Uri, window, workspace, Workspace import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' import { DocumentCallees } from '../calleeKinds' -import { loadBlockCalls } from '../callTree/query' +import { loadTargetCalls } from '../callTree/query' import { CallTreeSession } from '../callTree/session' import { commentFollowUp, commentJob, commentRewritePrompt, findBlock, writeCommentBody } from '../commentRewrite' import { DefinitionKinds } from '../definitionKinds' @@ -271,7 +271,7 @@ class Preview { private sent: SentDocument | undefined /** 发文档后在后台问语言扩展的请求(中文名、库规则查的定义、调用的种类)排队发:在途的有上限,离视口近的先发,文档改了排着的作废 */ private readonly requests = new LanguageRequests(() => this.document.version, () => this.visibleLine) - /** 能看调用树的块(函数、类、接口卡片)落在几棵调用树里。文档发出去之后再数,不挡住第一次预览,离视口近的先数;同一版文档数过的页面重新加载后接着用 */ + /** 各个调用树的次数(能看调用树的块落在几棵调用树里,卡片上各行的操作见 [CallTreeSession])。文档发出去之后再数,不挡住第一次预览,离视口近的先数;同一版文档数过的页面重新加载后接着用 */ private readonly callTrees = new CallTreeSession( message => this.post(message), () => { @@ -280,7 +280,7 @@ class Preview { return { version: this.sent.version, blocks: this.sent.blocks, - load: (block, cancelled) => loadBlockCalls(this.document, block, this.owner.indexed, cancelled), + load: (target, cancelled) => loadTargetCalls(this.document, target, this.owner.indexed, cancelled), } }, offset => this.requests.distance(this.document.positionAt(offset).line), @@ -820,7 +820,7 @@ class Preview { await this.checkRepoImages(msg.id, msg.paths) return case 'openCallTree': - this.callTrees.open(msg.version, msg.anchor) + this.callTrees.open(msg.version, msg.key) return case 'goToCallNode': await this.goToCallNode(Uri.parse(msg.uri), new Position(msg.line, msg.character)) diff --git a/vscode/src/codeReader/fixtures/loginPage.tsx.txt b/vscode/src/codeReader/fixtures/loginPage.tsx.txt new file mode 100644 index 0000000..557f1ea --- /dev/null +++ b/vscode/src/codeReader/fixtures/loginPage.tsx.txt @@ -0,0 +1,37 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { LoginPanel } from "@/components/auth/login/LoginPanel"; +import type { LoginTab, PasswordView } from "@/components/auth/login/types"; +import { DESKTOP_ENTRY } from "@/lib/desktop-shell"; +import { isSafeReturnTo } from "@/lib/safe-redirect"; + +/** + * 桌面壳的唯一登录面:没有首页、没有弹窗、没有关闭。 + * + * ![桌面登录](docs/prototype/desktop-login/openpencil/exports/desktop-login.png) + */ +const DesktopLoginPage = () => { + const router = useRouter(); + const from = useSearchParams().get("from"); + const [activeTab, setActiveTab] = useState("sms"); + const [passwordView, setPasswordView] = useState("login"); + + return ( +
+
+ router.replace(from && isSafeReturnTo(from) ? from : DESKTOP_ENTRY)} + /> +
+
+ ); +}; + +export default DesktopLoginPage; diff --git a/vscode/src/codeReader/fixtures/searchPanel.tsx.txt b/vscode/src/codeReader/fixtures/searchPanel.tsx.txt new file mode 100644 index 0000000..c805eba --- /dev/null +++ b/vscode/src/codeReader/fixtures/searchPanel.tsx.txt @@ -0,0 +1,46 @@ +"use client"; + +import { useCallback, useContext, useEffect, useMemo, useReducer, useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { AuthContext } from "@/lib/auth"; +import { useSearch } from "@/hooks/use-search"; +import { EMPTY_FILTER, filterReducer } from "./filter"; + +export const SearchPanel = ({ initialQuery }: { initialQuery: string }) => { + const router = useRouter(); + const { user, logout } = useContext(AuthContext); + const [query, setQuery] = useState(initialQuery); + const [filter, dispatch] = useReducer(filterReducer, EMPTY_FILTER); + const { results, loading } = useSearch(query, filter); + const [isPending, startTransition] = useTransition(); + const inputRef = useRef(null); + const visible = useMemo(() => results.filter((r) => !r.hidden), [results]); + + const submit = useCallback((e: FormEvent) => { + e.preventDefault(); + startTransition(() => router.push(`/search?q=${query}`)); + }, [router, query]); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + if (!user) logout(); + }, [user, logout]); + + return ( +
+ setQuery(e.target.value)} /> + + {isPending && 跳转中…} + {loading &&

搜索中…

} +
    + {visible.map((r) =>
  • {r.title}
  • )} +
+
+ ); +}; diff --git a/vscode/src/codeReader/fixtures/stateVariants.tsx.txt b/vscode/src/codeReader/fixtures/stateVariants.tsx.txt new file mode 100644 index 0000000..52d8b49 --- /dev/null +++ b/vscode/src/codeReader/fixtures/stateVariants.tsx.txt @@ -0,0 +1,6 @@ +export function Examples() { + const [open, setOpen] = useState(false) + const [items, setItems] = useState(() => loadItems()) + const [count] = useState(0) + return +} diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index 0831f8d..c0db411 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -307,10 +307,10 @@ function extendsBases(clause: Node): BaseFacts[] { } /** - * 读代码块树的一套函数。typed:TypeScript(typescript、typescriptreact),分得出抽象类、仅类型导入; - * JavaScript 没有这两个概念,facts 里不填(见 classFacts、importFacts)。 + * 读代码块树的一套函数:整份源码(file),一个函数值的主体(body,见 [LanguageAdapter.functionBody])。 + * typed:TypeScript(typescript、typescriptreact),分得出抽象类、仅类型导入;JavaScript 没有这两个概念,facts 里不填(见 classFacts、importFacts)。 */ -function blockReader(typed: boolean): (root: Node) => CodeBlock[] { +function blockReader(typed: boolean): { file: (root: Node) => CodeBlock[], body: (fn: Node) => CodeBlock[] } { function statements(items: Node[], top: boolean, afterRow: number): CodeBlock[] { return listBlocks(items, afterRow, item => statementBlocks(item, top), COMMENTS) } @@ -558,7 +558,15 @@ function blockReader(typed: boolean): (root: Node) => CodeBlock[] { return [leafBlock('field', member)] } - return root => statements(named(root), true, -1) + /** 函数值的主体:语句块是各句,直接返回的表达式是一个返回块(节点是那个表达式,源码里没写 return)。 */ + function body(fn: Node): CodeBlock[] { + const main = fn.childForFieldName('body') + if (!main) + return [] + return main.type === 'statement_block' ? statements(named(main), false, main.startPosition.row) : [leafBlock('return', main)] + } + + return { file: root => statements(named(root), true, -1), body } } // ─── 异常类 ─────────────────────────────────────────────────────────────────── @@ -792,7 +800,8 @@ export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter { throw new Error(`TypeScript 语言插件不支持 ${languageId}`) return path.join(wasmDir, file) }, - toBlocks: (root, _source, languageId) => withExceptions(attachComments(blockReader(TYPED_LANGUAGES.has(languageId))(root), aboutFile), TYPESCRIPT_EXCEPTIONS), + toBlocks: (root, _source, languageId) => withExceptions(attachComments(blockReader(TYPED_LANGUAGES.has(languageId)).file(root), aboutFile), TYPESCRIPT_EXCEPTIONS), + functionBody: (fn, languageId) => withExceptions(attachComments(blockReader(TYPED_LANGUAGES.has(languageId)).body(fn), aboutFile, false), TYPESCRIPT_EXCEPTIONS), tokenRole: typescriptTokenRole, lineComment: '//', freshDoc, diff --git a/vscode/src/codeReader/rules/react.test.ts b/vscode/src/codeReader/rules/react.test.ts index 1261c3d..be512ef 100644 --- a/vscode/src/codeReader/rules/react.test.ts +++ b/vscode/src/codeReader/rules/react.test.ts @@ -2,7 +2,8 @@ import type { Memento } from 'vscode' import type { BlockView } from '../blockView' import type { PropertyItem, PropertyRow, RuleContext } from '../contract' import { describe, expect, it } from 'vitest' -import { toBlockViews } from '../blockView' +import { rowActions, toBlockViews } from '../blockView' +import { callTargets } from '../callTree/session' import { diffBlockTrees } from '../diff/blockDiff' import { createTypeScriptAdapter } from '../languages/typescript' import { CodeReaderRegistry } from '../registry' @@ -21,17 +22,29 @@ const PLAN = readFixture('planChecklist.tsx.txt') const QUEUED = readFixture('queuedBubble.tsx.txt') const HOOKS = readFixture('hooksPanel.tsx.txt') const SHELL_EVENTS = readFixture('useShellEvents.ts.txt') +/** 原型 react-state.js 的三张画板:luffy 的登录页、useState 的其他写法(包进一个组件)、手写的搜索面板 */ +const LOGIN = readFixture('loginPage.tsx.txt') +const VARIANTS = readFixture('stateVariants.tsx.txt') +const SEARCH = readFixture('searchPanel.tsx.txt') -/** 钩子卡片的描边、钩子图标:所有钩子一样的紫 */ +/** 钩子返回值、自定义钩子的描边、钩子图标:所有钩子一样的紫 */ 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 } +/** React 的概念借语法色:状态、引用、记忆值、上下文、过渡、副作用,记忆函数的描边同函数 */ +const STATE_COLOR = { dark: '#9cdcfe', light: '#001080' } +const REF_COLOR = { dark: '#4fc1ff', light: '#0070c1' } +const MEMO_COLOR = { dark: '#b5cea8', light: '#098658' } +const CONTEXT_COLOR = { dark: '#4ec9b0', light: '#267f99' } +const CONCURRENT_COLOR = { dark: '#ce9178', light: '#a31515' } +const EFFECT_COLOR = { dark: '#e5c07b', light: '#9a6a00' } +const FUNCTION_COLOR = { dark: '#dcdcaa', light: '#795e26' } +const STATE_BADGE = { label: '状态', color: STATE_COLOR, icon: 'toggle-right', tooltip: 'React 状态' } +const VALUE_BADGE = { label: '钩子返回值', color: HOOK, icon: 'fishing-hook' } /** 规则查名字的定义时查到的值(名字原文 → IndexEntry.value),别的没查到。 */ function known(values: Record): RuleContext['lookup'] { @@ -96,7 +109,7 @@ describe('react 库规则:认领', () => { expect(reactRule.alwaysIn).toEqual(['typescriptreact', 'javascriptreact']) }) - it('luffy 的 use-shell-events.ts:导入了 react 的 .ts 文件里,自定义钩子出自定义钩子卡片,里面的钩子调用出钩子卡片', async () => { + it('luffy 的 use-shell-events.ts:导入了 react 的 .ts 文件里,自定义钩子出自定义钩子卡片,里面的钩子调用按变量讲', async () => { const blocks = await views(SHELL_EVENTS, undefined, [], 'typescript') expect(blocks.map(view => `${view.kind} ${view.startLine + 1}`)).toEqual(['statement 1', 'import 3', 'import 4', 'import 6', 'import 7', 'import 9', 'import 10', 'react.customHook 12']) const hook = blocks[7] @@ -105,11 +118,12 @@ describe('react 库规则:认领', () => { '说明: 挂载时经 [tauriBridge] 取一次全量状态与壳留存的活动,之后靠事件同步;不在壳里时什么也不取。\n\n另在这里注册窗口内快捷键。', '参数: 无', ]) - expect(kinds(hook)).toEqual(['react.hook 18', 'react.hook 19', 'react.hook 20', 'react.hook 22', 'react.hook 33']) - expect(lines(childAt(hook, 18))).toEqual(['钩子: useRouter', '返回: 1 个', ' router | 路由器,用于页面跳转']) - expect(lines(childAt(hook, 20))).toEqual(['钩子: useSidebarCollapsed', '返回: 1 个', ' toggleSidebar']) - expect(lines(childAt(hook, 22))).toEqual(['钩子: useEffect', '参数: 2 个', ' effect | ⟦函数|· 4 句⟧', ' deps | router 变化时']) - expect(rowLines(childAt(hook, 33), '参数')).toEqual(['参数: 2 个', ' effect | ⟦函数|· 4 句⟧', ' deps | shell、toggleSidebar 变化时']) + expect(kinds(hook)).toEqual(['react.hookValue 18', 'react.hookValue 19', 'react.hookValue 20', 'react.effect 22', 'react.effect 33']) + expect(lines(childAt(hook, 18))).toEqual(['名称: router', '来自: useRouter', '说明: 路由器,用于页面跳转']) + expect(lines(childAt(hook, 20))).toEqual(['名称: { toggle: toggleSidebar } 对象解构', ' toggle | toggleSidebar', '来自: useSidebarCollapsed']) + expect(propertyRow(childAt(hook, 20), '名称').columns).toEqual(['键', '名称']) + expect(lines(childAt(hook, 22))).toEqual(['触发时机: router 变化时', '来自: useEffect', '执行: ⟦函数|· 4 句⟧']) + expect(lines(childAt(hook, 33))).toEqual(['触发时机: shell、toggleSidebar 变化时', '注释: 输入框有焦点时也生效: 这些组合键输入框本来就不吃', '来自: useEffect', '执行: ⟦函数|· 4 句⟧']) }) it('没导入 react 的 .ts 文件不启用:zustand 的 store 叫 useXxx 也照常是常量、函数块;同样的源码写在 .tsx 里照常认', async () => { @@ -139,7 +153,7 @@ describe('react 库规则:认领', () => { '构造参数: 1 个', ' platform | DesktopShell["platform"] | — | 是', ]) - expect(kinds(view)).toEqual(['react.hook 13', 'react.hook 14', 'variable 15', 'react.render 17']) + expect(kinds(view)).toEqual(['react.hookValue 13', 'react.hookValue 14', 'variable 15', 'react.render 17']) expect(view.children.map(child => child.slot)).toEqual([undefined, undefined, undefined, undefined]) }) @@ -306,9 +320,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.hook 35', - 'react.hook 37', - 'react.hook 47', + 'react.state 35', + 'react.effect 37', + 'react.memoFn 47', 'if 57', 'variable 58', 'variable 59', @@ -319,34 +333,32 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis ]) }) - it('useState:钩子 / 参数 / 返回,返回表的名称能跳到定义,改状态的函数用函数色', async () => { + it('useState 是状态卡片:名称写出解构,展开是「第几项 | 名称 | 说明」,名称能跳到定义,修改函数用函数色', async () => { const state = childAt(await card(PLAN, 'PlanChecklist'), 35) - expect(state.badge).toEqual(HOOK_BADGE) + expect(state.badge).toEqual(STATE_BADGE) expect(lines(state)).toEqual([ - '钩子: useState', - '参数: 1 个', - ' initialState | 假', - '返回: 2 个', - ' collapsed | 当前值', - ' setCollapsed | 修改函数', + '名称: [collapsed, setCollapsed] 数组解构', + ' 1 | collapsed | 当前值', + ' 2 | setCollapsed | 修改函数', + '来自: useState', + '状态类型: 未标注', + '默认值: 假', ]) - 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(['参数', '值']) + const names = propertyRow(state, '名称') + expect(names.columns).toEqual(['第几项', '名称', '说明']) + expect(names.items?.map(item => item.defines && PLAN.slice(item.defines.startIndex, item.defines.endIndex))).toEqual(['collapsed', 'setCollapsed']) + expect(names.items?.map(item => inlineOf(item.cells[1])[0])).toMatchObject([{ role: 'variable' }, { role: 'function' }]) expect(state.children).toEqual([]) }) - it('useEffect、useCallback:函数参数收成小框,函数体照常是子卡片', async () => { + it('useEffect 是副作用卡片、useCallback 是记忆函数卡片:函数参数收成小框,函数体照常是子卡片', async () => { const view = await card(PLAN, 'PlanChecklist') const effect = childAt(view, 37) expect(viewLines([effect])).toEqual([ - 'react.hook 37', - ' 钩子: useEffect', - ' 参数: 2 个', - ' effect | ⟦函数|· 1 句⟧', - ' deps | 仅挂载时', + 'react.effect 37', + ' 触发时机: 仅挂载时', + ' 来自: useEffect', + ' 执行: ⟦函数|· 1 句⟧', ' try 38: 尝试', ' if 39: 如果 (localStorage.getItem(PLAN_CHECKLIST_COLLAPSED_KEY) 全等 "1")', ' statement 40: queueMicrotask(() => setCollapsed(真));', @@ -355,13 +367,11 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis ]) const toggle = childAt(view, 47) expect(viewLines([toggle])).toEqual([ - 'react.hook 47', - ' 钩子: useCallback', - ' 参数: 2 个', - ' callback | ⟦函数|· 3 句⟧', - ' deps | collapsed 变化时', - ' 返回: 1 个', - ' toggle | 记住的函数', + 'react.memoFn 47', + ' 名称: toggle', + ' 来自: useCallback', + ' 函数体: ⟦函数|· 3 句⟧', + ' 重建时机: collapsed 变化时', ' variable 48', ' 名称: next', ' 初始值: collapsed 不成立', @@ -394,28 +404,25 @@ describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」 const view = await card(QUEUED, 'QueuedEditor') expect(lines(view).slice(0, 1)).toEqual(['名称: QueuedEditor ⟦客户端组件|⟧']) expect(viewLines(view.children)).toEqual([ - 'react.hook 171', - ' 钩子: useState', - ' 参数: 1 个', - ' initialState | item.text', - ' 返回: 2 个', - ' draft | 当前值', - ' setDraft | 修改函数', - 'react.hook 172', - ' 钩子: useRef', - ' 类型参数: HTMLTextAreaElement', - ' 参数: 1 个', - ' initialValue | 空', - ' 返回: 1 个', - ' textareaRef | 引用对象', + 'react.state 171', + ' 名称: [draft, setDraft] 数组解构', + ' 1 | draft | 当前值', + ' 2 | setDraft | 修改函数', + ' 来自: useState', + ' 状态类型: 未标注', + ' 默认值: item.text', + 'react.ref 172', + ' 名称: textareaRef', + ' 来自: useRef', + ' 指向类型: HTMLTextAreaElement', + ' 初始值: 空', 'variable 173', ' 名称: canSave', ' 初始值: draft.trim().length > 0 或者 item.attachments.length > 0', - 'react.hook 175', - ' 钩子: useEffect', - ' 参数: 2 个', - ' effect | ⟦函数|· 4 句⟧', - ' deps | 仅挂载时', + 'react.effect 175', + ' 触发时机: 仅挂载时', + ' 来自: useEffect', + ' 执行: ⟦函数|· 4 句⟧', ' variable 176', ' 名称: el', ' 初始值: textareaRef.current', @@ -453,9 +460,10 @@ describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」 ]) }) - it('钩子、事件处理函数的样子;名称能跳到定义', async () => { + it('引用、事件处理函数的样子;名称能跳到定义', async () => { const view = await card(QUEUED, 'QueuedEditor') - expect(childAt(view, 172).badge).toEqual(HOOK_BADGE) + expect(childAt(view, 172).badge).toEqual({ label: '引用', color: REF_COLOR, icon: 'anchor' }) + expect(QUEUED.slice(childAt(view, 172).defines?.startIndex, childAt(view, 172).defines?.endIndex)).toBe('textareaRef') const handler = childAt(view, 186) expect(handler.badge).toEqual({ label: '事件处理函数', color: { dark: '#dcdcaa', light: '#795e26' }, icon: 'mouse-pointer-click' }) expect(QUEUED.slice(handler.defines?.startIndex, handler.defines?.endIndex)).toBe('handleKeyDown') @@ -482,191 +490,279 @@ describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」 }) }) -describe('react 库规则:钩子卡片(原型 react-hooks.js 的 Panel.tsx)', () => { - it('组件体里每处钩子调用都是一张「钩子」卡片:钩子 / 类型参数 / 参数 / 返回照签名排,没有的行不显示,函数体照常是子块', async () => { - const view = await card(HOOKS, 'Panel') +/** 卡片属性表里的各个操作写成「名字 悬停字 查法」,名字从源码里取。 */ +function actionLines(view: BlockView, source: string): string[] { + return rowActions(properties(view)).map(action => `${source.slice(action.name.startIndex, action.name.endIndex)} ${action.label} ${action.mode}`) +} + +/** 操作不算的那几段源码。 */ +function exceptTexts(view: BlockView, source: string): string[][] { + return rowActions(properties(view)).map(action => (action.except ?? []).map(range => source.slice(range.startIndex, range.endIndex))) +} + +describe('react 库规则:状态卡片(原型 react-state.js 的登录页、其他写法)', () => { + it('luffy 的登录页:两处 useState 是状态卡片,useRouter 是钩子返回值;useSearchParams().get(…) 接住的不是钩子的返回值,照常是变量', async () => { + const view = await card(LOGIN, 'DesktopLoginPage') + expect(kinds(view)).toEqual(['react.hookValue 17', 'variable 18', 'react.state 19', 'react.state 20', 'react.render 22']) + expect(lines(childAt(view, 19))).toEqual([ + '名称: [activeTab, setActiveTab] 数组解构', + ' 1 | activeTab | 当前值', + ' 2 | setActiveTab | 修改函数', + '来自: useState', + '状态类型: LoginTab', + '默认值: "sms"', + ]) + expect(lines(childAt(view, 18))).toEqual(['名称: from', '初始值: useSearchParams().get("from")']) + }) + + it('状态卡片左边是变量、状态两个图标(状态悬停「React 状态」),描边、淡底都是状态色;来自一格是钩子名,悬停「React 内置钩子」', async () => { + const state = childAt(await card(LOGIN, 'DesktopLoginPage'), 19) + expect(state.badge).toEqual(STATE_BADGE) + expect(state.tint).toBeUndefined() + expect(state.concepts).toEqual([ + { id: 'variable', label: '变量', icon: 'variable' }, + { id: 'react.state', label: 'React 状态', icon: 'toggle-right', color: STATE_COLOR }, + ]) + expect(inlineOf(propertyRow(state, '来自').value)).toEqual([{ text: 'useState', role: 'function', offset: LOGIN.indexOf('useState'), tooltip: 'React 内置钩子' }]) + expect(inlineOf(propertyRow(state, '状态类型').value)).toMatchObject([{ text: 'LoginTab', role: 'type', offset: LOGIN.indexOf('LoginTab>') }]) + expect(state.defines).toBeUndefined() + }) + + it('解构的每一项一个调用树操作:当前值「谁用了这个状态」,修改函数「谁改了这个状态」,都查所有引用;卡片右上角没有调用树按钮', async () => { + const view = await card(LOGIN, 'DesktopLoginPage') + const state = childAt(view, 19) + expect(actionLines(state, LOGIN)).toEqual(['activeTab 谁用了这个状态 references', 'setActiveTab 谁改了这个状态 references']) + expect(rowActions(properties(state)).map(action => action.key)).toEqual([`${state.anchor}|0`, `${state.anchor}|1`]) + expect(propertyRow(state, '名称').actions).toBeUndefined() + expect(state.callTree).toBeUndefined() + const router = childAt(view, 17) + expect(propertyRow(router, '名称').actions?.map(action => action.label)).toEqual(['谁用了']) + expect(router.callTree).toBeUndefined() + }) + + it('其他写法:没标类型写「未标注」,函数形式的默认值写「首次渲染时调用」加函数小框、直接返回的表达式是子块里的返回卡片;只取当前值的解构表只有一行', async () => { + const view = await card(VARIANTS, 'Examples') + expect(viewLines(view.children.slice(0, 3))).toEqual([ + 'react.state 2', + ' 名称: [open, setOpen] 数组解构', + ' 1 | open | 当前值', + ' 2 | setOpen | 修改函数', + ' 来自: useState', + ' 状态类型: 未标注', + ' 默认值: 假', + 'react.state 3', + ' 名称: [items, setItems] 数组解构', + ' 1 | items | 当前值', + ' 2 | setItems | 修改函数', + ' 来自: useState', + ' 状态类型: Item[]', + ' 默认值: 首次渲染时调用 ⟦函数|· 1 句⟧', + ' return 3: 返回 loadItems()', + 'react.state 4', + ' 名称: [count] 数组解构', + ' 1 | count | 当前值', + ' 来自: useState', + ' 状态类型: 未标注', + ' 默认值: 0', + ]) + expect(actionLines(childAt(view, 4), VARIANTS)).toEqual(['count 谁用了这个状态 references']) + }) +}) + +describe('react 库规则:别的内置钩子同样按变量讲(原型 react-state.js 的搜索面板)', () => { + const lookup = known({ useSearch: JSON.stringify(['query', 'filter']) }) + + it('组件体里的钩子各讲成一种卡片:钩子返回值、上下文值、状态、过渡、引用、记忆值、记忆函数、副作用', async () => { + const view = await card(SEARCH, 'SearchPanel', lookup) expect(kinds(view)).toEqual([ - 'react.hook 24', - 'react.hook 25', - 'react.hook 26', - 'react.hook 27', - 'react.hook 28', - 'react.hook 29', - 'react.hook 30', - 'react.hook 31', - 'react.hook 32', - 'react.hook 36', - 'react.hook 40', - 'react.hook 44', - 'react.hook 47', - 'react.handler 50', - 'react.render 53', + 'react.hookValue 11', + 'react.context 12', + 'react.state 13', + 'react.state 14', + 'react.hookValue 15', + 'react.transition 16', + 'react.ref 17', + 'react.memo 18', + 'react.memoFn 20', + 'react.effect 25', + 'react.effect 29', + 'react.render 33', ]) - expect(viewLines(view.children.filter(child => child.kind === 'react.hook'))).toEqual([ - 'react.hook 24', - ' 钩子: useRouter', - ' 返回: 1 个', - ' router | 路由器,用于页面跳转', - 'react.hook 25', - ' 钩子: useContext', + expect(viewLines(view.children.slice(0, 11).map(child => ({ ...child, children: [] })))).toEqual([ + 'react.hookValue 11', + ' 名称: router', + ' 来自: useRouter', + ' 说明: 路由器,用于页面跳转', + 'react.context 12', + ' 名称: { user, logout } 对象解构', + ' user', + ' logout', + ' 来自: useContext', + ' 上下文: AuthContext', + 'react.state 13', + ' 名称: [query, setQuery] 数组解构', + ' 1 | query | 当前值', + ' 2 | setQuery | 修改函数', + ' 来自: useState', + ' 状态类型: 未标注', + ' 默认值: initialQuery', + 'react.state 14', + ' 名称: [filter, dispatch] 数组解构', + ' 1 | filter | 当前值', + ' 2 | dispatch | 派发函数', + ' 来自: useReducer', + ' 处理函数: filterReducer', + ' 状态类型: 未标注', + ' 初始值: EMPTY_FILTER', + 'react.hookValue 15', + ' 名称: { results, loading } 对象解构', + ' results', + ' loading', + ' 来自: useSearch', + ' 参数: 2 个', + ' query | query', + ' filter | filter', + 'react.transition 16', + ' 名称: [isPending, startTransition] 数组解构', + ' 1 | isPending | 是否正在过渡', + ' 2 | startTransition | 启动过渡的函数', + ' 来自: useTransition', + 'react.ref 17', + ' 名称: inputRef', + ' 来自: useRef', + ' 指向类型: HTMLInputElement', + ' 初始值: 空', + 'react.memo 18', + ' 名称: visible', + ' 来自: useMemo', + ' 计算: ⟦函数|· 1 句⟧', + ' 重算时机: results 变化时', + 'react.memoFn 20', + ' 名称: submit', + ' 来自: useCallback', ' 参数: 1 个', - ' context | ThemeContext', - ' 返回: 1 个', - ' theme | 上下文的值', - 'react.hook 26', - ' 钩子: useChatSession', - ' 参数: 1 个', - ' id | id', - ' 返回: 1 个', - ' messages', - 'react.hook 27', - ' 钩子: useState', - ' 参数: 1 个', - ' initialState | 首次渲染时调用 readOpen(id)', - ' 返回: 2 个', - ' open | 当前值', - ' setOpen | 修改函数', - 'react.hook 28', - ' 钩子: useReducer', - ' 参数: 2 个', - ' reducer | reducer', - ' initialState | { count: 0 }', - ' 返回: 2 个', - ' state | 当前值', - ' dispatch | 派发函数', - 'react.hook 29', - ' 钩子: useTransition', - ' 返回: 2 个', - ' isPending | 是否正在过渡', - ' startTransition | 启动过渡的函数', - 'react.hook 30', - ' 钩子: useRef', - ' 类型参数: HTMLDivElement', - ' 参数: 1 个', - ' initialValue | 空', - ' 返回: 1 个', - ' boxRef | 引用对象', - 'react.hook 31', - ' 钩子: useRef', - ' 类型参数: 数值', - ' 参数: 1 个', - ' initialValue | 没写,未定义', - ' 返回: 1 个', - ' timer | 引用对象', - 'react.hook 32', - ' 钩子: useMemo', - ' 参数: 2 个', - ' factory | ⟦函数|· 2 句⟧', - ' deps | messages 变化时', - ' 返回: 1 个', - ' total | 算出的值', - ' variable 33', - ' 名称: sum', - ' 初始值: messages.length', - ' return 34: 返回 sum * 2', - 'react.hook 36', - ' 钩子: useCallback', - ' 参数: 2 个', - ' callback | ⟦函数|(next: 布尔) · 2 句⟧', - ' deps | 仅挂载时', - ' 返回: 1 个', - ' toggle | 记住的函数', - ' statement 37: setOpen(next)', - ' statement 38: dispatch({ type: \'add\' })', - 'react.hook 40', - ' 钩子: useEffect', - ' 参数: 2 个', - ' effect | ⟦函数|· 2 句⟧', - ' deps | open 变化时', - ' statement 41: timer.current = window.setTimeout(() => setOpen(假), 1000)', - ' return 42: 返回 () => clearTimeout(timer.current)', - 'react.hook 44', - ' 钩子: useEffect', - ' 参数: 2 个', - ' effect | ⟦函数|· 1 句⟧', - ' deps | 没写,每次渲染后', - ' statement 45: document.title = String(total)', - 'react.hook 47', - ' 钩子: useLayoutEffect', - ' 参数: 2 个', - ' effect | ⟦函数|· 1 句⟧', - ' deps | 仅挂载时', - ' statement 48: boxRef.current?.scrollTo(0, 0)', + ' e | FormEvent', + ' 函数体: ⟦函数|· 2 句⟧', + ' 重建时机: router、query 变化时', + 'react.effect 25', + ' 触发时机: 仅挂载时', + ' 来自: useEffect', + ' 执行: ⟦函数|· 1 句⟧', + 'react.effect 29', + ' 触发时机: user、logout 变化时', + ' 来自: useEffect', + ' 执行: ⟦函数|· 1 句⟧', ]) }) - it('形参名照 @types/react 写;依赖项、惰性初始值、函数参数各有写法,引用别处定义的名字照抄、带源码下标', async () => { - const view = await card(HOOKS, 'Panel') - const params = (line: number): PropertyItem[] => propertyRow(childAt(view, line), '参数').items ?? [] - const [reducer] = params(28) - expect(inlineOf(reducer.cells[1])).toEqual([{ text: 'reducer', role: 'function', offset: HOOKS.indexOf('reducer, {') }]) - const [context] = params(25) - expect(inlineOf(context.cells[1])).toMatchObject([{ text: 'ThemeContext', offset: HOOKS.indexOf('ThemeContext)') }]) - const [initial] = params(27) - expect(inlineOf(initial.cells[1]).slice(0, 2)).toEqual([{ text: '首次渲染时调用 ', role: 'plain' }, { text: 'readOpen', role: 'function', offset: HOOKS.indexOf('readOpen') }]) - const [callback, deps] = params(36) - expect(inlineOf(callback.cells[1])).toMatchObject([{ style: 'fold', label: '函数' }]) - expect(inlineOf(deps.cells[1])).toEqual([{ text: '仅挂载时', role: 'keyword' }]) - expect(inlineOf(params(40)[1].cells[1])).toEqual([{ text: 'open', role: 'variable', offset: HOOKS.indexOf('open])') }, { text: ' ', role: 'plain' }, { text: '变化时', role: 'keyword' }]) - expect(inlineOf(params(44)[1].cells[1])).toEqual([{ text: '没写,', role: 'hint' }, { text: '每次渲染后', role: 'keyword' }]) - expect(inlineOf(params(31)[0].cells[1])).toEqual([{ text: '没写,', role: 'hint' }, { text: '未定义', role: 'storage' }]) - }) - - it('返回:内置、第三方钩子是「名称 | 是什么」,自己写的只有名称;返回里的函数用函数色、名称能跳到定义', async () => { - const view = await card(HOOKS, 'Panel') - expect(propertyRow(childAt(view, 24), '返回').columns).toEqual(['名称', '是什么']) - expect(propertyRow(childAt(view, 27), '返回').columns).toEqual(['名称', '是什么']) - expect(propertyRow(childAt(view, 26), '返回').columns).toEqual(['名称']) - const transition = propertyRow(childAt(view, 29), '返回').items ?? [] - expect(transition.map(item => inlineOf(item.cells[0]))).toEqual([ - [{ text: 'isPending', role: 'variable', offset: HOOKS.indexOf('isPending') }], - [{ text: 'startTransition', role: 'function', offset: HOOKS.indexOf('startTransition') }], + it('样子:描边、图标按 React 的概念着色,记忆函数描边是函数色、图标是记忆值的色;钩子返回值钩子紫描边、淡底按来源分;来自一格悬停说谁提供的', async () => { + const view = await card(SEARCH, 'SearchPanel', lookup) + const at = (line: number): BlockView => childAt(view, line) + expect([12, 16, 17, 18, 25].map(line => at(line).badge)).toEqual([ + { label: '上下文值', color: CONTEXT_COLOR, icon: 'share-2' }, + { label: '过渡', color: CONCURRENT_COLOR, icon: 'hourglass' }, + { label: '引用', color: REF_COLOR, icon: 'anchor' }, + { label: '记忆值', color: MEMO_COLOR, icon: 'sigma' }, + { label: '副作用', color: EFFECT_COLOR, icon: 'zap' }, ]) - expect(transition.map(item => item.defines && HOOKS.slice(item.defines.startIndex, item.defines.endIndex))).toEqual(['isPending', 'startTransition']) - expect(inlineOf((propertyRow(childAt(view, 36), '返回').items ?? [])[0].cells[0])).toMatchObject([{ text: 'toggle', role: 'function' }]) - }) - - it('钩子卡片:描边、钩子图标都是钩子紫,淡底按来源分;作用图标只有 React 内置钩子有,useLayoutEffect 悬停写浏览器绘制前执行', async () => { - const view = await card(HOOKS, 'Panel') - const router = childAt(view, 24) - const context = childAt(view, 25) - const session = childAt(view, 26) - expect([router, context, session].map(item => item.badge)).toEqual([HOOK_BADGE, HOOK_BADGE, HOOK_BADGE]) - expect([router, context, session].map(item => item.tint)).toEqual([TINT.library, TINT.react, TINT.own]) - expect(router.concepts).toEqual([HOOK_CONCEPT]) - expect(session.concepts).toEqual([HOOK_CONCEPT]) - expect(context.concepts).toEqual([HOOK_CONCEPT, { id: 'react.hook.context', label: '上下文', icon: 'share-2', color: { dark: '#4ec9b0', light: '#267f99' } }]) - expect([27, 30, 32, 29, 40].map(line => childAt(view, line).concepts[1])).toEqual([ - { id: 'react.hook.state', label: '状态', icon: 'toggle-right', color: { dark: '#9cdcfe', light: '#001080' } }, - { id: 'react.hook.ref', label: '引用', icon: 'anchor', color: { dark: '#4fc1ff', light: '#0070c1' } }, - { id: 'react.hook.performance', label: '性能', icon: 'sigma', color: { dark: '#b5cea8', light: '#098658' } }, - { id: 'react.hook.concurrent', label: '并发', icon: 'hourglass', color: { dark: '#ce9178', light: '#a31515' } }, - { id: 'react.hook.effect', label: '副作用', icon: 'zap', color: { dark: '#e5c07b', light: '#9a6a00' } }, + expect(at(20).badge).toEqual({ label: '记忆函数', color: FUNCTION_COLOR, icon: 'sigma', iconColor: MEMO_COLOR }) + expect(at(20).concepts).toEqual([ + { id: 'function', label: '函数', icon: 'square-function' }, + { id: 'react.memoFn', label: '记忆函数', icon: 'sigma', color: MEMO_COLOR }, ]) - expect(childAt(view, 47).concepts[1]).toMatchObject({ id: 'react.hook.effect', label: '副作用 · 浏览器绘制前执行' }) + expect(at(25).concepts[0]).toEqual({ id: 'statement', label: '语句', icon: 'code' }) + expect([11, 15].map(line => at(line).badge)).toEqual([VALUE_BADGE, VALUE_BADGE]) + expect([11, 15].map(line => at(line).tint)).toEqual([TINT.library, TINT.own]) + expect([11, 12, 13, 15, 16, 17, 18, 20, 25].map(line => at(line).tint)).toEqual([TINT.library, undefined, undefined, TINT.own, undefined, undefined, undefined, undefined, undefined]) + expect([11, 13, 15].map(line => inlineOf(propertyRow(at(line), '来自').value)[0])).toMatchObject([ + { text: 'useRouter', tooltip: 'next/navigation 提供的钩子' }, + { text: 'useState', tooltip: 'React 内置钩子' }, + { text: 'useSearch', tooltip: '自定义钩子', offset: SEARCH.indexOf('useSearch(query') }, + ]) + expect(inlineOf(propertyRow(at(12), '上下文').value)).toEqual([{ text: 'AuthContext', role: 'variable', offset: SEARCH.indexOf('AuthContext)'), color: CONTEXT_COLOR }]) + expect(inlineOf(propertyRow(at(14), '处理函数').value)).toEqual([{ text: 'filterReducer', role: 'function', offset: SEARCH.indexOf('filterReducer, EMPTY') }]) }) - it('钩子名:自己写的、内置的带源码下标能跳到定义,第三方钩子悬停看来自哪个库', async () => { - const view = await card(HOOKS, 'Panel') - const hookName = (line: number) => inlineOf(propertyRow(childAt(view, line), '钩子').value) - expect(hookName(24)).toEqual([{ text: 'useRouter', role: 'function', offset: HOOKS.indexOf('useRouter()'), tooltip: '来自 next/navigation' }]) - expect(hookName(26)).toEqual([{ text: 'useChatSession', role: 'function', offset: HOOKS.indexOf('useChatSession(id)') }]) - expect(hookName(27)).toEqual([{ text: 'useState', role: 'function', offset: HOOKS.indexOf('useState(() =>') }]) + it('各项的调用树:值「谁用了」、函数「谁调用了」、派发函数「谁派发了动作」;对象解构的项在组件体里被调用过的算函数;组件体里依赖数组写的那几处不算', async () => { + const view = await card(SEARCH, 'SearchPanel', lookup) + expect(view.children.flatMap(child => 'properties' in child ? actionLines(child, SEARCH) : [])).toEqual([ + 'router 谁用了 references', + 'user 谁用了 references', + 'logout 谁调用了 references', + 'query 谁用了这个状态 references', + 'setQuery 谁改了这个状态 references', + 'filter 谁用了这个状态 references', + 'dispatch 谁派发了动作 references', + 'results 谁用了 references', + 'loading 谁用了 references', + 'isPending 谁用了 references', + 'startTransition 谁调用了 references', + 'inputRef 谁用了 references', + 'visible 谁用了 references', + 'submit 谁调用了 references', + ]) + expect(exceptTexts(childAt(view, 13), SEARCH)).toEqual([ + ['[results]', '[router, query]', '[]', '[user, logout]'], + ['[results]', '[router, query]', '[]', '[user, logout]'], + ]) + const logout = propertyRow(childAt(view, 12), '名称').items?.[1] + expect(logout && inlineOf(logout.cells[0])).toEqual([{ text: 'logout', role: 'function', offset: SEARCH.indexOf('logout }') }]) + expect(childAt(view, 20).callTree).toBeUndefined() }) - it('调用树:返回里有函数的钩子卡片查那个函数,别的钩子卡片没有调用树按钮', async () => { + it('调用树要数的:卡片上的每个操作一个,键是卡片记号加序号,查的是那一项的名字', async () => { + const view = await card(SEARCH, 'SearchPanel', lookup) + const state = childAt(view, 13) + const targets = callTargets([view]).filter(target => target.key.startsWith(state.anchor)) + expect(targets).toEqual([ + { key: `${state.anchor}|0`, offset: SEARCH.indexOf('query, setQuery'), mode: 'references', kind: 'react.state', except: expect.any(Array) }, + { key: `${state.anchor}|1`, offset: SEARCH.indexOf('setQuery]'), mode: 'references', kind: 'react.state', except: expect.any(Array) }, + ]) + }) + + it('钩子收的函数参数:小框是几句,函数体是卡片的子块;直接返回表达式的也有子块(一张返回卡片),语言插件已经排了的不重复排', async () => { + const view = await card(SEARCH, 'SearchPanel', lookup) + expect(viewLines(childAt(view, 18).children)).toEqual(['return 18: 返回 results.filter((r) => r.hidden 不成立)']) + expect(viewLines(childAt(view, 20).children)).toEqual(['statement 21: e.preventDefault();', `statement 22: ${SEARCH.split('\n')[21].trim()}`]) + expect(viewLines(childAt(view, 29).children)).toEqual(['if 30: 如果 (user 不成立)', ' statement 30: logout();']) + expect(inlineOf(propertyRow(childAt(view, 20), '函数体').value)).toEqual([{ style: 'fold', label: '函数', segments: [{ text: '· 2 句', role: 'hint' }] }]) + }) +}) + +describe('react 库规则:钩子卡片的各行(原型 react-hooks.js 的 Panel.tsx)', () => { + it('没写的参数照签名表写「没写,…」:引用的初始值是未定义,副作用每次渲染后', async () => { const view = await card(HOOKS, 'Panel') - const target = (line: number): string | undefined => { - const name = childAt(view, line).callTree?.name - return name && HOOKS.slice(name.startIndex, name.endIndex) - } - expect([27, 28, 29, 36].map(target)).toEqual(['setOpen', 'dispatch', 'startTransition', 'toggle']) - expect([24, 25, 26, 30, 31, 32, 40, 44, 47].map(line => childAt(view, line).callTree)).toEqual(Array.from({ length: 9 }, () => undefined)) + expect(lines(childAt(view, 31))).toEqual(['名称: timer', '来自: useRef', '指向类型: 数值', '初始值: 没写,未定义']) + expect(inlineOf(propertyRow(childAt(view, 31), '初始值').value)).toEqual([{ text: '没写,', role: 'hint' }, { text: '未定义', role: 'storage' }]) + expect(lines(childAt(view, 44))).toEqual(['触发时机: 没写,每次渲染后', '来自: useEffect', '执行: ⟦函数|· 1 句⟧']) + expect(inlineOf(propertyRow(childAt(view, 40), '触发时机').value)).toEqual([{ text: 'open', role: 'variable', offset: HOOKS.indexOf('open])') }, { text: ' ', role: 'plain' }, { text: '变化时', role: 'keyword' }]) + }) + + it('useLayoutEffect 的副作用图标悬停写「副作用 · 浏览器绘制前执行」,筛选栏里还叫副作用', async () => { + const layout = childAt(await card(HOOKS, 'Panel'), 47) + expect(layout.badge).toEqual({ label: '副作用', color: EFFECT_COLOR, icon: 'zap', tooltip: '副作用 · 浏览器绘制前执行' }) + expect(layout.concepts[1]).toEqual({ id: 'react.effect', label: '副作用 · 浏览器绘制前执行', icon: 'zap', color: EFFECT_COLOR }) + expect(layout.attributes.kind).toBe('react.effect') + }) + + it('记忆函数:参数另列「名称 | 类型」,函数小框里不再写参数;记忆值的函数体是语句块时子块就是各句', async () => { + const view = await card(HOOKS, 'Panel') + expect(lines(childAt(view, 36))).toEqual(['名称: toggle', '来自: useCallback', '参数: 1 个', ' next | 布尔', '函数体: ⟦函数|· 2 句⟧', '重建时机: 仅挂载时']) + expect(propertyRow(childAt(view, 36), '参数').columns).toEqual(['名称', '类型']) + expect(kinds(childAt(view, 32))).toEqual(['variable 33', 'return 34']) + }) + + it('惰性初始值传的函数、useReducer 传名字的处理函数:小框带子块,名字照抄成函数', async () => { + const view = await card(HOOKS, 'Panel') + const open = childAt(view, 27) + expect(inlineOf(propertyRow(open, '默认值').value)).toEqual([{ text: '首次渲染时调用 ', role: 'plain' }, { style: 'fold', label: '函数', segments: [{ text: '· 1 句', role: 'hint' }] }]) + expect(viewLines(open.children)).toEqual(['return 27: 返回 readOpen(id)']) + expect(lines(childAt(view, 28)).slice(3)).toEqual(['来自: useReducer', '处理函数: reducer', '状态类型: 未标注', '初始值: { count: 0 }']) }) }) describe('react 库规则:自定义钩子', () => { - it('function useXxx 是「自定义钩子」卡片:名称(带导出标签)/ 参数「名称 | 类型」/ 返回只列名称;钩子体里的钩子也认成钩子卡片', async () => { + it('function useXxx 是「自定义钩子」卡片:名称(带导出标签)/ 参数「名称 | 类型」/ 返回只列名称;钩子体里的钩子同样按变量讲', async () => { const blocks = await views(HOOKS) const hook = blocks.find(view => view.kind === 'react.customHook') expect(hook && viewLines([hook])).toEqual([ @@ -676,19 +772,17 @@ describe('react 库规则:自定义钩子', () => { ' id | 字符串', ' 返回: 1 个', ' messages', - ' react.hook 14', - ' 钩子: useState', - ' 类型参数: Message[]', - ' 参数: 1 个', - ' initialState | 空数组', - ' 返回: 2 个', - ' messages | 当前值', - ' setMessages | 修改函数', - ' react.hook 15', - ' 钩子: useEffect', - ' 参数: 2 个', - ' effect | ⟦函数|· 3 句⟧', - ' deps | id 变化时', + ' react.state 14', + ' 名称: [messages, setMessages] 数组解构', + ' 1 | messages | 当前值', + ' 2 | setMessages | 修改函数', + ' 来自: useState', + ' 状态类型: Message[]', + ' 默认值: 空数组', + ' react.effect 15', + ' 触发时机: id 变化时', + ' 来自: useEffect', + ' 执行: ⟦函数|· 3 句⟧', ' variable 16', ' 名称: ws', ' 初始值: connect(id)', @@ -698,16 +792,16 @@ describe('react 库规则:自定义钩子', () => { ]) expect(hook?.badge).toEqual({ label: '自定义钩子', color: HOOK, icon: 'fishing-hook' }) expect(hook?.tint).toEqual(TINT.own) - expect(hook?.concepts).toEqual([{ id: 'react.customHook', label: '自定义钩子', icon: 'fishing-hook', color: HOOK }]) + expect(hook?.concepts).toEqual([{ id: 'function', label: '函数', icon: 'square-function' }, { id: 'react.customHook', label: '自定义钩子', icon: 'fishing-hook', color: HOOK }]) expect(propertyRow(hook, '参数').columns).toEqual(['名称', '类型']) expect(propertyRow(hook, '返回').columns).toEqual(['名称']) expect(HOOKS.slice(hook?.defines?.startIndex, hook?.defines?.endIndex)).toBe('useChatSession') - const calls = hook?.callTree?.name - expect(calls && HOOKS.slice(calls.startIndex, calls.endIndex)).toBe('useChatSession') - expect(childAt(hook as BlockView, 14).callTree?.name && HOOKS.slice(childAt(hook as BlockView, 14).callTree?.name?.startIndex, childAt(hook as BlockView, 14).callTree?.name?.endIndex)).toBe('setMessages') + expect(hook?.callTree).toEqual({}) + expect(actionLines(childAt(hook as BlockView, 14), HOOKS)).toEqual(['messages 谁用了这个状态 references', 'setMessages 谁改了这个状态 references']) + expect(exceptTexts(childAt(hook as BlockView, 14), HOOKS)).toEqual([['[id]'], ['[id]']]) }) - it('const useXxx = () => … 也是自定义钩子:参数带默认值时多一列,返回读数组、对象简写和改名的键、标识符', async () => { + it('const useXxx = () => … 也是自定义钩子:参数带默认值时多一列,返回读数组、对象简写和改名的键、标识符;没接返回值的钩子调用是钩子调用卡片', async () => { const source = [ 'import { useCallback, useState } from "react";', 'export const useToggle = (initial = false) => {', @@ -727,11 +821,16 @@ describe('react 库规则:自定义钩子', () => { expect(blocks.map(view => `${view.kind} ${view.startLine + 1}`)).toEqual(['import 1', 'react.customHook 2', 'react.customHook 7', 'react.customHook 11']) expect(lines(blocks[1])).toEqual(['名称: useToggle ⟦具名导出|⟧', '参数: 1 个', ' initial | 未标注 | 假', '返回: 2 个', ' on', ' flip']) expect(propertyRow(blocks[1], '参数').columns).toEqual(['名称', '类型', '默认值']) - expect(kinds(blocks[1])).toEqual(['react.hook 3', 'react.hook 4', 'return 5']) + expect(kinds(blocks[1])).toEqual(['react.state 3', 'react.memoFn 4', 'return 5']) + expect(viewLines(childAt(blocks[1], 4).children)).toEqual(['return 4: 返回 setOn(v => v 不成立)']) expect(lines(blocks[2])).toEqual(['名称: useLabel', '参数: 1 个', ' text | 字符串', '返回: 2 个', ' text', ' toggle']) - expect(lines(childAt(blocks[2], 8))).toEqual(['钩子: useToggle', '返回: 1 个', ' value']) + expect(lines(childAt(blocks[2], 8))).toEqual(['名称: value', '来自: useToggle']) expect(lines(blocks[3])).toEqual(['名称: useNothing', '参数: 无']) - expect(lines(childAt(blocks[3], 12))).toEqual(['钩子: useToggle']) + const bare = childAt(blocks[3], 12) + expect(bare.badge).toEqual({ label: '钩子调用', color: HOOK, icon: 'fishing-hook' }) + expect(bare.concepts[0]).toEqual({ id: 'statement', label: '语句', icon: 'code' }) + expect(bare.tint).toEqual(TINT.own) + expect(lines(bare)).toEqual(['来自: useToggle']) }) }) @@ -753,11 +852,21 @@ describe('react 库规则:钩子从哪来', () => { it('从 react 导入、写成 React.useX、没导入的内置钩子名是 React 自带;别的包是第三方;相对路径、路径别名是自己写的', async () => { const view = await card(SOURCE, 'Box') - expect([6, 7, 8, 9, 10].map(line => childAt(view, line).tint)).toEqual([TINT.react, TINT.library, TINT.own, TINT.own, TINT.react]) - expect(childAt(view, 6).concepts[1]).toMatchObject({ id: 'react.hook.state' }) - expect(childAt(view, 6).callTree?.name && SOURCE.slice(childAt(view, 6).callTree?.name?.startIndex, childAt(view, 6).callTree?.name?.endIndex)).toBe('setN') - expect(lines(childAt(view, 7))).toEqual(['钩子: useQuery', '参数: 1 个', ' 第 1 个 | { queryKey: ["x"] }', '返回: 1 个', ' data | —']) - expect(lines(childAt(view, 10))).toEqual(['钩子: useId', '返回: 1 个', ' id | 稳定的唯一标识']) + expect(kinds(view)).toEqual(['react.state 6', 'react.hookValue 7', 'react.hookValue 8', 'react.hookValue 9', 'react.hookValue 10', 'react.render 11']) + expect([7, 8, 9, 10].map(line => childAt(view, line).tint)).toEqual([TINT.library, TINT.own, TINT.own, TINT.react]) + expect(actionLines(childAt(view, 6), SOURCE)).toEqual(['n 谁用了这个状态 references', 'setN 谁改了这个状态 references']) + expect(lines(childAt(view, 7))).toEqual(['名称: { data } 对象解构', ' data', '来自: useQuery', '参数: 1 个', ' 第 1 个 | { queryKey: ["x"] }']) + expect(lines(childAt(view, 10))).toEqual(['名称: id', '来自: useId', '说明: 稳定的唯一标识']) + }) + + it('没有自己那种卡片的内置钩子讲成钩子返回值,React 色的淡底,左边多一个作用图标', async () => { + const id = childAt(await card(SOURCE, 'Box'), 10) + expect(id.badge).toEqual(VALUE_BADGE) + expect(id.concepts).toEqual([ + { id: 'variable', label: '变量', icon: 'variable' }, + { id: 'react.hookValue', label: '钩子返回值', icon: 'fishing-hook', color: HOOK }, + { id: 'react.role.state', label: '状态', icon: 'toggle-right', color: STATE_COLOR }, + ]) }) it('别的文件里自己写的钩子:参数名经工作区索引查定义处的形参,没查到时照位置写', async () => { @@ -766,7 +875,7 @@ describe('react 库规则:钩子从哪来', () => { expect(rowLines(childAt(await card(SOURCE, 'Box', lookup), 8), '参数')).toEqual(['参数: 1 个', ' config | options']) }) - it('签名表:实参多了按重载挑形参名;没传、签名表里也没给说法的参数不出那一行', async () => { + it('签名表:实参多了按重载挑形参(useReducer 的初始化函数);没传的参数照签名表写「没写,…」', async () => { const source = [ 'import { useDeferredValue, useReducer, useState } from "react";', 'export function Box({ q }: { q: string }) {', @@ -777,49 +886,50 @@ describe('react 库规则:钩子从哪来', () => { '}', ].join('\n') const view = await card(source, 'Box') - expect(rowLines(childAt(view, 3), '参数')).toEqual(['参数: 3 个', ' reducer | reducer', ' initialArg | 0', ' init | init']) - expect(lines(childAt(view, 4))).toEqual(['钩子: useState', '返回: 1 个', ' n | 当前值']) - expect(rowLines(childAt(view, 5), '参数')).toEqual(['参数: 1 个', ' value | q']) + expect(lines(childAt(view, 3)).slice(3)).toEqual(['来自: useReducer', '处理函数: reducer', '状态类型: 未标注', '初始值: 0', '初始化函数: init']) + expect(rowLines(childAt(view, 4), '默认值')).toEqual(['默认值: 没写,未定义']) + expect(lines(childAt(view, 5))).toEqual(['名称: deferred', '来自: useDeferredValue', '说明: 延迟更新的值', '参数: 1 个', ' value | q']) + expect(childAt(view, 5).concepts[2]).toMatchObject({ id: 'react.role.concurrent', label: '并发' }) }) }) describe('react 库规则:钩子卡片的几种写法(luffy 的真实组件)', () => { - it('第三方库的钩子照词表写是什么;项目里自己写的只列名称、参数照位置写', async () => { + it('第三方库的钩子照词表写说明;项目里自己写的没有说明、参数照位置写', async () => { const titlebar = await card(TITLEBAR, 'DesktopTitlebar') const pathname = childAt(titlebar, 13) expect(pathname.tint).toEqual(TINT.library) - expect(pathname.concepts).toEqual([HOOK_CONCEPT]) - expect(lines(pathname)).toEqual(['钩子: usePathname', '返回: 1 个', ' pathname | 当前路径']) + expect(lines(pathname)).toEqual(['名称: pathname', '来自: usePathname', '说明: 当前路径']) const title = childAt(titlebar, 14) expect(title.tint).toEqual(TINT.own) - expect(lines(title)).toEqual(['钩子: useDesktopShell', '参数: 1 个', ' 第 1 个 | (s) => s.title', '返回: 1 个', ' title']) + expect(lines(title)).toEqual(['名称: title', '来自: useDesktopShell', '参数: 1 个', ' 第 1 个 | ⟦函数|(s) · 1 句⟧']) + expect(viewLines(title.children)).toEqual(['return 14: 返回 s.title']) const legal = await card(LEGAL, 'LegalDocPage') - expect(lines(childAt(legal, 58))).toEqual(['钩子: useSiteConfig', '返回: 1 个', ' cfg']) - expect(lines(childAt(legal, 59))).toEqual(['钩子: useRouter', '返回: 1 个', ' router | 路由器,用于页面跳转']) + expect(lines(childAt(legal, 58))).toEqual(['名称: cfg', '来自: useSiteConfig']) + expect(lines(childAt(legal, 59))).toEqual(['名称: router', '来自: useRouter', '说明: 路由器,用于页面跳转']) }) - it('luffy 的 LegalDocPage:组件体照源码顺序,useMemo 直接返回表达式的函数照常翻,副作用的清理函数照常是子块,注释是讲的那张卡片的「注释」一行', async () => { + it('luffy 的 LegalDocPage:组件体照源码顺序,useMemo 直接返回表达式的函数也收成小框、子块是返回卡片,副作用的清理函数照常是子块,注释是讲的那张卡片的「注释」一行', async () => { const view = await card(LEGAL, 'LegalDocPage') expect(kinds(view)).toEqual([ - 'react.hook 58', - 'react.hook 59', + 'react.hookValue 58', + 'react.hookValue 59', 'variable 60', - 'react.hook 61', - 'react.hook 63', - 'react.hook 64', - 'react.hook 68', + 'react.memo 61', + 'react.ref 63', + 'react.state 64', + 'react.state 68', 'if 70', - 'react.hook 75', - 'react.hook 80', + 'react.effect 75', + 'react.effect 80', 'react.handler 104', 'react.handler 112', 'react.handler 116', 'react.render 124', ]) - expect(lines(childAt(view, 61))).toEqual(['钩子: useMemo', '参数: 2 个', ' factory | () => extractHeadings(markdown)', ' deps | markdown 变化时', '返回: 1 个', ' headings | 算出的值']) - expect(lines(childAt(view, 64))).toEqual(['钩子: useState', '类型参数: 字符串', '参数: 1 个', ' initialState | headings[0]?.id ?? ""', '返回: 2 个', ' activeSection | 当前值', ' setActiveSection | 修改函数']) + expect(viewLines([childAt(view, 61)])).toEqual(['react.memo 61', ' 名称: headings', ' 来自: useMemo', ' 计算: ⟦函数|· 1 句⟧', ' 重算时机: markdown 变化时', ' return 61: 返回 extractHeadings(markdown)']) + expect(lines(childAt(view, 64))).toEqual(['名称: [activeSection, setActiveSection] 数组解构', ' 1 | activeSection | 当前值', ' 2 | setActiveSection | 修改函数', '来自: useState', '状态类型: 字符串', '默认值: headings[0]?.id ?? ""']) const effect = childAt(view, 80) - expect(lines(effect)).toEqual(['钩子: useEffect', '注释: 滚动时按 heading.top 计算当前高亮章节.', '参数: 2 个', ' effect | ⟦函数|· 5 句⟧', ' deps | doc、headings 变化时']) + expect(lines(effect)).toEqual(['触发时机: doc、headings 变化时', '注释: 滚动时按 heading.top 计算当前高亮章节.', '来自: useEffect', '执行: ⟦函数|· 5 句⟧']) expect(kinds(effect)).toEqual(['variable 82', 'if 83', 'function 84', 'statement 100', 'return 101']) expect(lines(childAt(view, 104))).toEqual(['名称: handleSectionClick', '参数: id: 字符串', '绑定事件: 每个 - {callTree && ( - - )} + {callTree && } ) } + +/** + * 看调用树的按钮:块右上角的「调用树」,属性表里一行的操作(「谁用了这个状态」)都用它。只有图标,名字写在悬停提示和读屏用的 aria-label 里; + * count 是后台数出的次数,大于 0 时按钮上带这个数字,是 0 时只有图标(还没数到、没有调用者,点开时会当场再问);弹框开着时带按下的底色。 + */ +export function CallTreeButton({ callKey, label, count, open, onOpen }: { + /** 这个调用树的键(块的记号,或操作的键),记在 data-call-tree 上 */ + callKey: string + label: string + count: number + open: boolean + onOpen: () => void +}): ReactElement { + return ( + + ) +} diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index fe81678..f653fd6 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -3,6 +3,8 @@ import type { BlockTreeHandle, BlockTreeState, RewriteTree } from './BlockTree' import type { Ask, ChatState } from './qa' import type { BlockView, KindBadge } from './types' +import { act } from 'react' +import { createRoot } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' import { fileAttributes } from '../../../src/codeReader/attributes' @@ -151,13 +153,64 @@ describe('路由筛选后展开函数体', () => { }) describe('库规则卡片的淡底', () => { - it('卡片给了 tint 时淡底用它、描边仍是种类色(React 钩子卡片按来源分底色):两个颜色都写在卡片上', () => { - const tinted: BlockView = { ...view('0', 'react.hook', 'useRouter'), badge: BADGE, tint: { dark: '#e5c07b', light: '#9a6a00' } } - expect(markup([tinted], tree())).toContain('data-kind="react.hook" data-badge="" data-tint="" style="--kind-dark:#61dafb;--kind-light:#0a7ea4;--tint-dark:#e5c07b;--tint-light:#9a6a00"') + it('卡片给了 tint 时淡底用它、描边仍是种类色(React 钩子返回值按来源分底色):两个颜色都写在卡片上', () => { + const tinted: BlockView = { ...view('0', 'react.hookValue', 'useRouter'), badge: BADGE, tint: { dark: '#e5c07b', light: '#9a6a00' } } + expect(markup([tinted], tree())).toContain('data-kind="react.hookValue" data-badge="" data-tint="" style="--kind-dark:#61dafb;--kind-light:#0a7ea4;--tint-dark:#e5c07b;--tint-light:#9a6a00"') expect(markup([{ ...tinted, tint: undefined }], tree())).not.toContain('data-tint') }) }) +describe('卡片里收起来的函数小框', () => { + /** React 记忆值卡片:「计算」一行是收起来的函数,函数体(一张返回卡片)是卡片下面那一层的子块 */ + const memo = (children: BlockView[]): BlockView => ({ + ...view('0', 'react.memo', ''), + badge: BADGE, + properties: [ + { label: '名称', value: [{ text: 'visible', role: 'variable' }] }, + { label: '计算', value: [{ style: 'fold', label: '函数', segments: [{ text: '· 1 句', role: 'hint' }] }] }, + ], + children, + }) + const RETURN = view('0.0', 'return', '返回 results.filter(…)') + const chip = (html: string): string => html.match(/]*data-style="fold"[^>]*>/)?.[0] ?? '' + + it('卡片有下面那一层的子块时,小框能点、和卡片的折叠箭头同开同关', () => { + const closed = chip(markup([memo([RETURN])], tree({ collapsed: new Set(['0']) }))) + expect(closed).toContain('aria-expanded="false"') + expect(closed).toContain('aria-label="展开函数"') + expect(closed).not.toContain('disabled') + const open = chip(markup([memo([RETURN])], tree({ collapsed: new Set() }))) + expect(open).toContain('aria-expanded="true"') + expect(open).toContain('data-open=""') + }) + + it('点小框切换的是卡片自己的折叠', async () => { + const toggled: string[] = [] + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + await act(async () => root.render( toggled.push(id) }} />)) + await act(async () => container.querySelector('[data-style="fold"]')!.click()) + expect(toggled).toEqual(['0']) + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + + it('卡片没有子块、子块都挂在子行上时,小框只是不能点的小框,也不沿用外层卡片的开关', () => { + expect(chip(markup([memo([])], tree()))).toContain('disabled') + const slotted = view('0.0', 'demo.method', '', { slot: { row: 0, item: 0 } }) + expect(chip(markup([memo([slotted])], tree()))).toContain('disabled') + // 挂在外层卡片子行上的子卡片画在外层卡片的属性表里,外层卡片自己有下面那一层的子块 + const inner: BlockView = { ...memo([]), id: '0.0', slot: { row: 1, item: 0 } } + const outer: BlockView = { ...CARD, children: [inner, view('0.2', 'statement', '留下的语句')] } + const html = markup([outer], tree({ collapsed: new Set() })) + expect(html).toContain('计算') + expect(chip(html)).toContain('disabled') + }) +}) + describe('挂在卡片子行上的子块', () => { it('默认不画,子行的名称是展开它的按钮;卡片收着时留下的子块不画', () => { const html = render({}) diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 663c629..9356229 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -6,12 +6,14 @@ import type { Place, Placement } from './placement' import type { ChainSlots, GridSlots } from './PropertyGrid' import type { ChatState, ChatView, QaState } from './qa' import type { PendingImage } from './qaImages' +import type { RowActions } from './rowActions' import type { ReadableStore } from './store' import type { BlockMarks, BlockView, CardView, ChangedBlock } from './types' import { ChevronDown, ChevronRight, ChevronsUpDown, EyeOff } from 'lucide-react' import { createContext, Fragment, memo, useContext, useMemo, useState } from 'react' import { AskThread, REWRITE_THREAD } from './AskThread' import { BlockTools } from './BlockTools' +import { ChainFoldContext } from './chainFold' import { CodeEditor } from './CodeEditor' import { DiffText } from './DiffText' import { removedTitle } from './diffView' @@ -19,6 +21,7 @@ import { estimatedHeight } from './itemHeight' import { KindIcon } from './kindIcons' import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid' import { chatOf, EMPTY_CHAT, sameChat, unfinished } from './qa' +import { cardActions, RowActionsContext } from './rowActions' import { BlockIdContext } from './SegmentText' import { shallowEqual, staticStore, useStore } from './store' import { TextPreview } from './TextPreview' @@ -36,10 +39,13 @@ export interface EditState { focusId: string | null } -/** 函数、类落在几棵调用树里,开着哪一块的弹框。定义浮窗没有,跟问 AI 一样不画这排按钮。 */ +/** + * 各个调用树的次数,开着哪个的弹框,都按调用树的键:块自己的(函数、类)是块的记号,卡片属性表里的操作是操作的键 + * (见 [CallTreeAction.key](vscode/webview-ui/src/codeReader/types.ts#CallTreeAction))。定义浮窗没有,跟问 AI 一样不画这些按钮。 + */ export interface CallTreeState { counts: ReadonlyMap - openAnchor?: string + openKey?: string } /** @@ -80,8 +86,8 @@ export interface BlockTreeHandle { onOpenRun: (id: string) => void /** 编辑:点「编辑 / 预览」、光标放进去之后。和 state.edit 同时有 */ edit?: { onToggleEdit: (id: string) => void, onFocused: () => void } - /** 打开一块的调用树弹框。和 state.callTree 同时有 */ - onOpenCall?: (anchor: string) => void + /** 打开键是 key 的调用树弹框(块的记号,或卡片上一个操作的键)。和 state.callTree 同时有 */ + onOpenCall?: (key: string) => void /** 问 AI;问答没地方存(没打开工作区文件夹)时 `state` 里 [ChatState.available] 是 `false` */ qa?: QaTree /** 按注释规范重写;工作区外的库文件不能重写,`state` 里 [ChatState.available] 是 `false` */ @@ -229,6 +235,10 @@ interface BlockShape { /** 调用树:扩展端标了能看调用树(BlockView.callTree)的块才有棵数 */ calls?: number callOpen: boolean + /** 卡片属性表里各个操作的次数(按 [cardActions] 的顺序,逗号连起来,比得了相等);没有调用树状态、卡片没有操作时没有 */ + actionCounts?: string + /** 开着的调用树是这张卡片上的某个操作时,是它的键 */ + actionOpen?: string } function shapeOf(block: BlockView, state: BlockTreeState): BlockShape { @@ -250,7 +260,32 @@ function shapeOf(block: BlockView, state: BlockTreeState): BlockShape { sourceText: editing ? state.edit?.sourceText : undefined, place: editing ? state.edit?.placement.of(block.id) : undefined, calls: state.callTree && block.callTree ? state.callTree.counts.get(block.anchor) ?? 0 : undefined, - callOpen: state.callTree?.openAnchor === block.anchor, + callOpen: state.callTree?.openKey === block.anchor, + ...actionShape(block, state.callTree), + } +} + +/** 卡片属性表里各个操作的次数、开着的是哪个(见 [BlockShape.actionCounts]);没有调用树状态、卡片没有操作时都没有。 */ +function actionShape(block: BlockView, callTree: CallTreeState | undefined): Pick { + const actions = callTree ? cardActions(block) : [] + if (!callTree || actions.length === 0) + return {} + const open = actions.find(action => action.key === callTree.openKey)?.key + return { + actionCounts: actions.map(action => callTree.counts.get(action.key ?? '') ?? 0).join(','), + ...(open !== undefined && { actionOpen: open }), + } +} + +/** 卡片交给属性表里操作按钮的状态和动作(见 [RowActions]):次数按 [BlockShape.actionCounts] 对回各个键;没有次数(没有调用树状态、卡片没有操作)时没有。 */ +function rowActionsOf(block: BlockView, actionCounts: string | undefined, actionOpen: string | undefined, onOpenCall: ((key: string) => void) | undefined): RowActions | undefined { + if (actionCounts === undefined || !onOpenCall) + return undefined + const counts = actionCounts.split(',').map(Number) + return { + counts: new Map(cardActions(block).map((action, i) => [action.key ?? '', counts[i] ?? 0])), + ...(actionOpen !== undefined && { openKey: actionOpen }), + onOpen: onOpenCall, } } @@ -421,11 +456,27 @@ export function BlockLevel({ blocks, owner, slotted = false, items = false }: { * 译文下面依次接着拆开的各项(跨几行写的字面量,排法同变量初始值下面的)、摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。 * folds 是这张卡片里折叠着的(见 foldsByCard);标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。 * children 是这块的子块,挂在子行上的画在属性表里(删掉的块不画子块,没有)。 + * 卡片有排在它下面那一层的子块时(不挂在子行上、不挂在链的某一步下面),内容里收起来的函数小框(React 钩子收的函数参数)和卡片的折叠箭头是同一个开关 + * (见 [ChainFoldContext]);没有时小框不能点,也不沿用外层卡片的开关。 */ const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { block: CardView, children?: BlockView[], folds: ReadonlySet, marks?: BlockMarks }): ReactElement => { const tree = useBlockTree() const slots = useMemo(() => cardSlots(block, children, folds, tree), [block, children, folds, tree]) const chain = useMemo(() => chainSlots(block, children, folds, tree), [block, children, folds, tree]) + const open = !folds.has(block.id) + const below = children.some(child => !inCard(child)) + const fold = useMemo(() => below ? { open, onToggle: () => tree.onToggleFold(block.id) } : undefined, [below, open, tree, block.id]) + return ( + + + + ) +}) +BlockPreview.displayName = 'BlockPreview' + +/** 预览状态的内容本身(见 [BlockPreview]):属性表,或一行译文连同下面那几样。 */ +function PreviewBody({ block, folds, marks, slots, chain }: { block: CardView, folds: ReadonlySet, marks?: BlockMarks, slots?: GridSlots, chain?: ChainSlots }): ReactElement { + const tree = useBlockTree() if ('properties' in block) return const folding = { collapsed: folds, onToggleFold: tree.onToggleFold } @@ -452,8 +503,7 @@ const BlockPreview = memo(({ block, children = NO_CHILDREN, folds, marks }: { bl )} ) -}) -BlockPreview.displayName = 'BlockPreview' +} /** * 挂在卡片子行上的子块(BlockSlot)怎么画:一行下面展开着的几块画成一层块(同块树);点那个子行的第一格展开它、 @@ -501,6 +551,8 @@ function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: Bl const tree = useBlockTree() const { edit, onOpenCall } = tree const { anchor } = block + const { actionCounts, actionOpen } = shape + const actions = useMemo(() => rowActionsOf(block, actionCounts, actionOpen, onOpenCall), [block, actionCounts, actionOpen, onOpenCall]) const collapsed = shape.folds.has(block.id) const toggled = block.slot !== undefined const Chevron = collapsed && !toggled ? ChevronRight : ChevronDown @@ -544,7 +596,11 @@ function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: Bl onExit={() => edit.onToggleEdit(block.id)} /> ) - : } + : ( + + + + )} {shape.temporary && ( @@ -559,7 +615,7 @@ function BlockCard({ block, shape, ask, rewrite }: { block: BlockView, shape: Bl asking={ask.asking} onToggleAsk={tree.qa && ask.asking !== undefined ? () => tree.qa?.onToggle(anchor) : undefined} rewrite={tree.rewrite && tool && { ...tool, onRewrite: () => tool.reopen ? tree.rewrite?.onOpen(anchor) : tree.rewrite?.onRewrite(anchor) }} - callTree={onOpenCall && shape.calls !== undefined ? { anchor, count: shape.calls, open: shape.callOpen, onOpen: () => onOpenCall(anchor) } : undefined} + callTree={onOpenCall && shape.calls !== undefined ? { key: anchor, count: shape.calls, open: shape.callOpen, onOpen: () => onOpenCall(anchor) } : undefined} /> )} @@ -602,7 +658,10 @@ function RemovedCard({ placed }: { placed: PlacedRemoved }): ReactElement { {open && (
- + {/* 旧内容里的操作只是看看,不画按钮 */} + + +
)} diff --git a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx index c1afea1..48b5704 100644 --- a/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx +++ b/vscode/webview-ui/src/codeReader/CallTreeDialog.test.tsx @@ -49,8 +49,8 @@ function block(kind: string, id = kind, callTree?: BlockView['callTree']): Block }])[0] } -/** 块树:callTree 是各块的棵数、开着哪一块的弹框和点按钮时做什么;edit 是 false 时是只读的树 */ -function tree(callTree?: CallTreeState & { onOpen: (anchor: string) => void }, edit = true): BlockTreeHandle { +/** 块树:callTree 是各个调用树的次数、开着哪个的弹框和点按钮时做什么;edit 是 false 时是只读的树 */ +function tree(callTree?: CallTreeState & { onOpen: (key: string) => void }, edit = true): BlockTreeHandle { return { state: staticStore(treeState({ ...(edit && { edit: { version: 1, sourceText: { source: '', colors: { roles: [], runs: [] } }, placement: NO_PLACEMENT, editing: new Set(), focusId: null } }), @@ -58,7 +58,7 @@ function tree(callTree?: CallTreeState & { onOpen: (anchor: string) => void }, e hidden: new Set(), temporary: new Set(), covered: new Map(), - ...(callTree && { callTree: { counts: callTree.counts, ...(callTree.openAnchor !== undefined && { openAnchor: callTree.openAnchor }) } }), + ...(callTree && { callTree: { counts: callTree.counts, ...(callTree.openKey !== undefined && { openKey: callTree.openKey }) } }), })), onToggleFold: () => {}, onSetFolds: () => {}, @@ -123,7 +123,7 @@ describe('调用树弹框', () => { <> setOpen(true) })} + tree={tree({ counts: new Map([['mask', 3]]), openKey: open ? 'mask' : undefined, onOpen: () => setOpen(true) })} /> {open && ( { expect(container.querySelector(`[data-id="${row}/0/0/t/0"]`)).not.toBeNull() }) }) + +describe('属性表上各行的操作', () => { + const action = (key: string, label: string): CallTreeAction => ({ kind: 'callTree', name: { startIndex: 0, endIndex: 4 }, mode: 'references', label, key }) + /** 数组解构的「名称」一行:两项各带一个操作,第三项(嵌套的解构)没有 */ + const destructured: PropertyRow = { + label: '名称', + value: [{ text: '[open, setOpen]', role: 'plain' }], + columns: ['第几项', '名称', '说明'], + items: [ + { cells: [[{ text: '1', role: 'hint' }], [{ text: 'open', role: 'variable' }], [{ text: '当前值', role: 'plain' }]], actions: [action('s|0', '谁用了这个状态')] }, + { cells: [[{ text: '2', role: 'hint' }], [{ text: 'setOpen', role: 'function' }], [{ text: '修改函数', role: 'plain' }]], actions: [action('s|1', '谁改了这个状态')] }, + { cells: [[{ text: '3', role: 'hint' }], [{ text: '{ a }', role: 'plain' }], []] }, + ], + } + /** 只接一个名字的「名称」一行,操作在行上 */ + const single: PropertyRow = { label: '名称', value: [{ text: 'router', role: 'variable' }], actions: [action('r|0', '谁用了')] } + + async function renderWith(rows: PropertyRow[], actions: RowActions | undefined): Promise { + await act(async () => root.render( + + {}} /> + , + )) + } + + const buttonOf = (key: string): HTMLButtonElement | null => container.querySelector(`[data-call-tree="${key}"]`) + + it('表格多出最后一列「操作」:各行的按钮在这一格里,次数大于 0 带数字、0 只有图标,悬停写操作的说法;没有操作的行空着', async () => { + await renderWith([destructured], { counts: new Map([['s|0', 3]]), onOpen: () => {} }) + const heads = [...container.querySelectorAll('[role="columnheader"]')].map(head => head.textContent) + expect(heads).toEqual(['第几项', '名称', '说明', '操作']) + expect(container.querySelector('.cr-table')!.style.gridTemplateColumns).toMatch(/ max-content$/) + const rows = [...container.querySelectorAll('.cr-table-row')] + expect(rows.map(row => row.querySelectorAll('[role="cell"]').length)).toEqual([4, 4, 4]) + expect(rows[0].querySelector('.cr-table-ops [data-call-tree="s|0"]')?.textContent).toBe('3') + expect(buttonOf('s|1')?.textContent).toBe('') + expect(buttonOf('s|1')?.getAttribute('data-tooltip')).toBe('谁改了这个状态') + expect(buttonOf('s|1')?.getAttribute('aria-label')).toBe('谁改了这个状态') + expect(rows[2].querySelector('.cr-table-ops')?.childElementCount).toBe(0) + }) + + it('点按钮按操作的键打开;开着的那个带按下的样子', async () => { + const opened: string[] = [] + await renderWith([destructured], { counts: new Map(), openKey: 's|1', onOpen: key => opened.push(key) }) + await act(async () => buttonOf('s|0')!.click()) + expect(opened).toEqual(['s|0']) + expect(buttonOf('s|1')?.hasAttribute('data-active')).toBe(true) + expect(buttonOf('s|0')?.hasAttribute('data-active')).toBe(false) + }) + + it('卡片没给操作的状态(定义卡片浮窗、删掉的块的旧内容)时没有「操作」一列,也没有按钮', async () => { + await renderWith([destructured, single], undefined) + expect([...container.querySelectorAll('[role="columnheader"]')].map(head => head.textContent)).toEqual(['第几项', '名称', '说明']) + expect(container.querySelector('[data-call-tree]')).toBeNull() + }) + + it('只接一个名字时操作在「名称」一行的最右边,排在值后面', async () => { + await renderWith([single], { counts: new Map([['r|0', 1]]), onOpen: () => {} }) + const row = byId(propertyRowId('0', 0)) + const button = row.querySelector('[data-call-tree="r|0"]')! + expect(button.textContent).toBe('1') + expect(before(row.querySelector('[data-find$="value"]')!, button)).toBe(true) + }) +}) diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index d407e91..f8263fc 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -1,10 +1,12 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react' -import type { BlockView, ChainSlot, ChainStep, Inline, ItemDiff, ItemsDiff, KindBadge, PreviewText, PropertyItem, PropertyRow, Removed, RowDiff, RowsDiff, TextDiff } from './types' +import type { BlockView, ChainSlot, ChainStep, Inline, ItemDiff, ItemsDiff, KindBadge, PreviewText, PropertyItem, PropertyRow, Removed, RowAction, RowDiff, RowsDiff, TextDiff } from './types' import { ChevronDown, ChevronRight } from 'lucide-react' -import { Fragment } from 'react' +import { Fragment, useContext } from 'react' import { cn } from '@/lib/utils' +import { CallTreeButton } from './BlockTools' import { ChainFoldContext } from './chainFold' import { DiffInline, DiffText } from './DiffText' +import { RowActionsContext } from './rowActions' import { inlineText } from './segments' import { SegmentText } from './SegmentText' import { subRowsLayout, tableCells, tableTemplate } from './subRows' @@ -134,6 +136,24 @@ function ItemHead({ id, cell, foldable, folding, find }: { id: string, cell: Pre ) } +/** + * 一行能做的操作([RowAction]),按钮从左往右排:现在都是看调用树([CallTreeButton]),悬停写操作自己的说法,次数、开着没有照卡片给的(见 [RowActionsContext])。 + * 卡片没给(定义卡片浮窗、删掉的块的旧内容)、操作没有键时不画。 + */ +function RowActionButtons({ actions }: { actions: readonly RowAction[] | undefined }): ReactElement | null { + const state = useContext(RowActionsContext) + const keyed = state ? (actions ?? []).flatMap(action => action.key === undefined ? [] : [{ action, key: action.key }]) : [] + if (!state || keyed.length === 0) + return null + return ( + + {keyed.map(({ action, key }) => ( + state.onOpen(key)} /> + ))} + + ) +} + /** 往右缩进一层、左边一条竖线:一行或一个子行下面的一层子行,列表的一项下面挂的实参表 */ function Nested({ children }: { children: ReactNode }): ReactElement { return ( @@ -211,6 +231,8 @@ export function PropertyItems({ parentId, items, folding, marks }: { parentId: s * 行带 table(实参的值里跨几行写的调用)时那张表排在它们后面,往右缩一点。一层层往下套。 * 标差异时(marks)有加行、删行的表每行第一格前多一个记号位(+ / −,没变的空着),加的行淡绿底,删的行放回原来的位置、淡红底划掉。 * 讲某个子块的行(toggles,Rust 实现块的方法表)第一格是按钮,点它在表下面展开那一块,展开时这一行淡淡垫上那一块的种类色。 + * 有行带操作([PropertyItem.actions])、卡片又给了操作的状态([RowActionsContext])时,最后多一列「操作」,宽度按按钮最多的那一行, + * 各行的操作按钮在这一格里从左往右排,没有操作的行空着。 */ export function PropertyTable({ parentId, columns, items, marks, folding, toggles }: { parentId: string @@ -224,6 +246,7 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle }): ReactElement { // 有加行、删行的表每行第一格前留一个记号位 const signed = marks !== undefined && (marks.removed.length > 0 || Object.values(marks.changed).some(mark => mark.added)) + const operable = useContext(RowActionsContext) !== undefined && items.some(item => (item.actions?.length ?? 0) > 0) /** 一行:删掉的(change 是 del)不带 id,套着的表不再标;toggle 是这一行讲的子块,第一格成了展开它的按钮 */ const tableRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined, toggle?: ItemToggle): ReactElement => { const cells = tableCells(item, columns) @@ -255,6 +278,11 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle ) : {cellBox(cell, i, 'cr-table-cell')})} + {operable && ( +
+ {change !== 'del' && } +
+ )} ) } @@ -262,9 +290,10 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle .map(({ value, index }) => {tableRow(value, `${parentId}/x${index}`, 'del', undefined)}) return (
-
+
{columns.map((name, i) => {name})} + {operable && 操作}
{removed(-1)} {items.map((item, index) => { @@ -283,7 +312,8 @@ export function PropertyTable({ parentId, columns, items, marks, folding, toggle /** * 函数、类、枚举、导入、常量、变量块和库规则的卡片的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行, - * 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。 + * 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。一行带操作([PropertyRow.actions],React 卡片只接一个名字时的「名称」一行)时, + * 按钮排在这一行最右边(见 [RowActionButtons])。 * 标差异时(marks)新加的行淡绿底、左边 +,删掉的行放回原来的位置、淡红底划掉、左边 −,值改了的按旧值 → 新值或逐词标。 */ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, slots, chain }: { @@ -334,14 +364,16 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl ) : {row.label}} - {row.chain - ? ( -
- - -
- ) - : } + + {row.chain + ? ( +
+ + +
+ ) + : } +
{!folded && layout === 'table' && } {!folded && layout === 'list' && } @@ -354,6 +386,19 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl ) } +/** 属性行的值,这一行带操作、卡片又给了操作的状态时,操作按钮排在值的最右边;不然只有值。 */ +function WithActions({ actions, children }: { actions: readonly RowAction[] | undefined, children: ReactElement }): ReactElement { + const state = useContext(RowActionsContext) + if (!state || !actions?.some(action => action.key !== undefined)) + return children + return ( +
+
{children}
+ +
+ ) +} + /** * 一行的值:着色的词或 Markdown(改了的逐截 / 逐词标);跟着说明(PropertyRow.note,输出参数的 `@return`)时说明接在值后面, * 同一行放得下就并排,剩的地方不到 12 个字宽时换到下一行。`id` 是这一行的 id,值和说明按它记上记号(见 [textKey]);删掉的行没有。 diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index 6dfa4db..e583183 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -140,8 +140,9 @@ function GroupBox({ group }: { group: SegmentGroup }): ReactElement { } /** - * 链式调用卡片里收起来的闭包、async 块(「闭包 |app| · 37 句」):前面一个折叠箭头,点它展开、收起这一步下面挂着的子块 - * (开关是所在那一步给的,见 [ChainFoldContext]);不在链的某一步里时只是一个小框。 + * 收起来的一段函数:链式调用卡片里的闭包、async 块(「闭包 |app| · 37 句」),React 钩子卡片里收的函数参数(「函数 · 2 句」)。 + * 前面一个折叠箭头,点它展开、收起讲它的子块:链的一步下面挂着的子块,或卡片下面那一层(开关是所在那一步、那张卡片给的,见 [ChainFoldContext]); + * 没有开关时只是一个小框。 */ function FoldChip({ group }: { group: SegmentGroup }): ReactElement { const fold = useContext(ChainFoldContext) diff --git a/vscode/webview-ui/src/codeReader/chainFold.ts b/vscode/webview-ui/src/codeReader/chainFold.ts index 87a302c..6401442 100644 --- a/vscode/webview-ui/src/codeReader/chainFold.ts +++ b/vscode/webview-ui/src/codeReader/chainFold.ts @@ -1,13 +1,14 @@ import { createContext } from 'react' -/** 链式调用的一步收起来的闭包眼下展开着没有,点它时怎么切换。 */ +/** 收起来的函数小框(fold 框)讲的子块眼下展开着没有,点它时怎么切换。 */ export interface ChainFold { open: boolean onToggle: () => void } /** - * 链式调用卡片的一步交给它值里那几个 fold 框(收起来的闭包、async 块)的开关;不在链的某一步里(删掉的块的旧内容)时没有, - * fold 框只是一个不能点的小框。 + * 交给内容里那几个 fold 框的开关。链式调用卡片的一步给它值里收起来的闭包、async 块,开关的是挂在这一步下面的子块; + * 卡片有排在它下面那一层的子块时,卡片给它内容里收起来的函数(React 钩子收的函数参数),开关同卡片的折叠箭头。 + * 都不在(删掉的块的旧内容、没有子块的卡片)时没有,fold 框只是一个不能点的小框。 */ export const ChainFoldContext = createContext(undefined) diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 49cc706..3c27578 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -717,6 +717,17 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-group[data-colored] { -- .cr-table .cr-table-cell + .cr-table-cell { border-left: 1px solid var(--cr-table-border); } +/* 「操作」一列:这一行能做的操作按钮横排,垂直居中(见 PropertyGrid 的 RowActionButtons) */ +.cr-table .cr-table-ops { + display: flex; + align-items: center; +} +.cr-row-actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 2px; +} .cr-table-head .cr-table-cell { font-size: 12px; line-height: 20px; diff --git a/vscode/webview-ui/src/codeReader/pageState.ts b/vscode/webview-ui/src/codeReader/pageState.ts index 0dcf51f..df7178a 100644 --- a/vscode/webview-ui/src/codeReader/pageState.ts +++ b/vscode/webview-ui/src/codeReader/pageState.ts @@ -12,9 +12,12 @@ import { placementOf } from './placement' import { createStore } from './store' import { collapsedIds, foldsByCard } from './viewState' -/** 开着的调用树弹框:哪一版文档的哪一块、选中第几个入口,扩展端发来的树 */ +/** + * 开着的调用树弹框:哪一版文档的哪个调用树(键:块的记号,或卡片上一个操作的键,见 [CallTreeAction.key](vscode/webview-ui/src/codeReader/types.ts#CallTreeAction))、 + * 选中第几个入口,扩展端发来的树;kind、badge 是那块的(弹框按它上色) + */ export interface OpenCall { - anchor: string + key: string version: number entry: number status: 'loading' | 'ready' @@ -47,7 +50,7 @@ export interface PageInputs { onlyChanges: boolean /** 「只看改动」时点开过的几段没变的块(按第一块的 id),筛选一变就重新折 */ openedRuns: ReadonlySet - /** 后台数出的各块落在几棵调用树里,是哪一版文档的 */ + /** 后台数出的各个调用树的次数(按键,见 [OpenCall.key]),是哪一版文档的 */ callCounts: { version: number, counts: Record } | undefined openCall: OpenCall | null } @@ -122,7 +125,7 @@ export function createPageStore(initial: Pick): editing: ReadonlySet, focusId: string | null, counts: ReadonlyMap, - openAnchor: string | undefined, + openKey: string | undefined, collapsed: ReadonlySet, folds: ReadonlyMap>, filter: FilterView, @@ -131,7 +134,7 @@ export function createPageStore(initial: Pick): folded: FoldedRuns | undefined, ): BlockTreeState => ({ edit: { version, sourceText, placement, editing, focusId }, - callTree: { counts, ...(openAnchor !== undefined && { openAnchor }) }, + callTree: { counts, ...(openKey !== undefined && { openKey }) }, collapsed, folds, hidden: filter.hidden, @@ -152,7 +155,7 @@ export function createPageStore(initial: Pick): const editing = editingOf(inputs.editing) const covered = coveredOf(blocks, editing) const folded = foldedOf(blocks, changes, inputs.onlyChanges, filter, covered, inputs.openedRuns, inputs.keep) - const openAnchor = inputs.openCall?.version === version ? inputs.openCall.anchor : undefined + const openKey = inputs.openCall?.version === version ? inputs.openCall.key : undefined return { ...inputs, changes, @@ -163,7 +166,7 @@ export function createPageStore(initial: Pick): placement, unshown: unshownOf(filter, folded), stops: stopsOf(blocks, placement, changes, filter), - tree: treeOf(version, sourceTextOf(doc?.source ?? '', doc?.colors ?? NO_COLORS), placement, editing, inputs.focusId, countsOf(doc?.version, inputs.callCounts), openAnchor, collapsed, foldsOf(collapsed), filter, covered, changes, folded), + tree: treeOf(version, sourceTextOf(doc?.source ?? '', doc?.colors ?? NO_COLORS), placement, editing, inputs.focusId, countsOf(doc?.version, inputs.callCounts), openKey, collapsed, foldsOf(collapsed), filter, covered, changes, folded), } } diff --git a/vscode/webview-ui/src/codeReader/rowActions.ts b/vscode/webview-ui/src/codeReader/rowActions.ts new file mode 100644 index 0000000..6321197 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/rowActions.ts @@ -0,0 +1,58 @@ +import type { CallTreeAction, CardView, PropertyItem, PropertyRow } from './types' +import { createContext } from 'react' + +/** + * 一张卡片属性表上各个操作([CallTreeAction])眼下的样子和动作:各自的次数(按操作的键),开着的是哪一个,点开一个。 + * 页面上的块树才有;定义卡片浮窗、删掉的块的旧内容没有,不画「操作」一列和行尾的按钮。 + */ +export interface RowActions { + counts: ReadonlyMap + openKey?: string + onOpen: (key: string) => void +} + +/** 卡片交给它属性表里各个操作按钮的 [RowActions];每张卡片都重新给一次,子卡片不沿用父卡片的。 */ +export const RowActionsContext = createContext(undefined) + +const known = new WeakMap() + +/** + * 一张卡片上带键的各个操作,从上往下:一行先是它自己的,再是它的子行(子行先是自己的,再是下一层子行、套着的表),同扩展端排键的顺序。 + * 一张卡片只算一次。 + */ +export function cardActions(card: CardView): CallTreeAction[] { + const cached = known.get(card) + if (cached) + return cached + const found: CallTreeAction[] = [] + const add = (actions: readonly CallTreeAction[] | undefined): void => { + for (const action of actions ?? []) { + if (action.key !== undefined) + found.push(action) + } + } + const item = (entry: PropertyItem): void => { + add(entry.actions) + entry.items?.forEach(item) + entry.table?.items.forEach(item) + } + const row = (entry: PropertyRow): void => { + add(entry.actions) + entry.items?.forEach(item) + } + if ('properties' in card) { + card.properties.forEach(row) + } + else { + card.items?.forEach(item) + card.table?.items.forEach(item) + card.rows?.forEach(row) + } + known.set(card, found) + return found +} + +/** 键是 key 的操作在这张卡片上吗。 */ +export function hasAction(card: CardView, key: string): boolean { + return cardActions(card).some(action => action.key === key) +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index c9fafa7..ea41ea4 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -80,6 +80,26 @@ export interface PropertyItem extends Defines { items?: PropertyItem[] /** 最后一格的值里那次调用的实参表:表格的行排在最后一格里、文字下面,排成列表的子行排在它下面;总是展开,表里的行又可以带 items、table */ table?: PreviewTable + /** 这一行能做的操作(见 [RowAction]):表格的行排在最后多出来的「操作」一列,从左往右;列表的子行没有这一列,不画 */ + actions?: RowAction[] +} + +/** 表格一行、属性一行上能做的一个操作,页面画成一个按钮。现在只有看调用树一种([CallTreeAction]) */ +export type RowAction = CallTreeAction + +/** + * 看调用树(同扩展端 contract 的 CallTreeAction):查源码里一个名字被谁用、被谁调用,按钮上带次数(0 次只画图标),点开是调用树弹框。 + * name、mode、except 是扩展端查的时候用的,这边只看 key(对次数、点开哪个调用树)和 label(悬停的字) + */ +export interface CallTreeAction { + kind: 'callTree' + name: SourceRange + mode: 'calls' | 'references' + /** 按钮悬停的字:「谁用了这个状态」「谁改了这个状态」 */ + label: string + except?: SourceRange[] + /** 扩展端起的键(块的记号加序号):callTreeCounts 按它给次数,openCallTree 按它打开 */ + key?: string } /** @@ -95,6 +115,8 @@ export interface PropertyRow extends Defines { columns?: string[] /** 值是一条链式调用的起点时,链的各步:排成一条竖轨道,接在值下面 */ chain?: ChainStep[] + /** 这一行能做的操作(见 [RowAction]):按钮排在这一行最右边 */ + actions?: RowAction[] } /** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行 */ @@ -137,8 +159,17 @@ export interface BlockConcept { color?: ThemeColor } -/** 库规则的种类:筛选栏里叫 label,卡片边框用 color。图标在 concepts 里,不单画这一个。 */ -export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName } +/** + * 库规则的种类:筛选栏里叫 label,卡片边框用 color。图标在 concepts 里,不单画这一个; + * tooltip、iconColor 是种类图标悬停的字和颜色,扩展端已经放进 concepts 里了,这边不另外用 + */ +export interface KindBadge { + label: string + color: ThemeColor + icon: KindIconName + tooltip?: string + iconColor?: ThemeColor +} /** * 块在源码里占的地方,按相对的量记,前面的块变长变短、挪了位置它都不跟着变: @@ -182,11 +213,10 @@ interface CardViewBase { } /** - * 一块能看调用树:接口卡片带上它讲的接口,查的不是块自己的名字时带上那个名字在块里的范围(React 钩子卡片查返回的 `setOpen`); - * 都是扩展端查调用者用的,这边只看有没有 callTree。 + * 一块能看调用树,查块自己的名字:接口卡片带上它讲的接口;扩展端查调用者用的,这边只看有没有 callTree。 + * 卡片属性表里各行自己查的名字是那一行的操作([CallTreeAction]),不在这里。 */ export interface CallTreeTarget { - name?: SourceRange endpoint?: { method: string, path: string, operationIdPrefix?: string, sameRouter?: { method: string, path: string }[] } } @@ -580,8 +610,9 @@ export interface CodeColors { * repoLinkBuiltin:点的不带路径的引用是语言自带、又问不到定义的名字,没打开,改当普通文字显示;symbol、block、file 同 openRepoLink * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了,block、file 同 openRepoLink * repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在 - * callTreeCounts:version 这份文档里,每个能看调用树的块落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理 - * callTree:anchor 那块的调用树。trees 空着、又没有 contractMiss 是没有调用它的地方;接口卡片在契约文件里没对上时带 contractMiss。 + * callTreeCounts:version 这份文档里各个调用树的次数(只包括大于 0 的),按调用树的键:能看调用树的块(callTree)是块的记号, + * 落在几棵调用树里;卡片属性表里的操作([CallTreeAction])是它的键([CallTreeAction.key])。不是页面上这个版本的不理 + * callTree:键是 key 的那个调用树(同 callTreeCounts)。trees 空着、又没有 contractMiss 是没有调用它的地方;接口卡片在契约文件里没对上时带 contractMiss。 * 不是页面上这个版本的不理 */ /** 点节点跳到哪:文件和位置,行和列从 0 起。 */ @@ -652,7 +683,7 @@ export type ExtensionToWebview | { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] } | { type: 'focusPathAsk' } | { type: 'callTreeCounts', version: number, counts: Record } - | { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss } + | { type: 'callTree', version: number, key: string, name: string, where: string, trees: CallTree[], contractMiss?: ContractMiss } /** * ready:页面准备好了,可以收消息;openedFrom 是页面状态里记着的这个标签页是从哪儿打开的,新开的页面没记过就不带 @@ -684,7 +715,7 @@ export type ExtensionToWebview * block 是它写在哪块的注释里(见 SymbolReference),从那块的声明起一层层往外找 * checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(references)问哪些找不到。id 标出是哪一次问 * checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问 - * openCallTree:打开 anchor 那块的调用树。version 是这块所在的文档版本 + * openCallTree:打开键是 key 的调用树(块的记号,或卡片上一个操作的键,同 callTreeCounts)。version 是它所在的文档版本 * goToCallNode:点了调用树上的一个节点,打开 uri 那个文件,跳到 line 行 character 列(见 CallTreeNode.jump) */ export type WebviewToExtension @@ -712,5 +743,5 @@ export type WebviewToExtension | { type: 'openRepoLink', path?: string, symbol?: string, block?: string, file?: string, lines?: LineRange } | { type: 'checkRepoLinks', id: number, paths: string[], references: SymbolReference[] } | { type: 'checkRepoImages', id: number, paths: string[] } - | { type: 'openCallTree', version: number, anchor: string } + | { type: 'openCallTree', version: number, key: string } | { type: 'goToCallNode', uri: string, line: number, character: number } diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index 4d8e492..1b4c669 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -73,11 +73,11 @@ describe('webview 状态', () => { it('开着的调用树弹框(哪一块、选中第几个入口)页面重新加载后取回,关掉后就没了;形状不对的当没记过', () => { const storage = memoryStorage() const store = createViewStateStore(storage) - store.update({ callTree: { anchor: 'mask_phone', entry: 1 } }) - expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {}, callTree: { anchor: 'mask_phone', entry: 1 } }) + store.update({ callTree: { key: 'mask_phone', entry: 1 } }) + expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {}, callTree: { key: 'mask_phone', entry: 1 } }) store.update({ callTree: undefined }) expect(createViewStateStore(storage).get()).toEqual({ editing: [], folds: {} }) - for (const callTree of ['mask_phone', { anchor: 1, entry: 0 }, { anchor: 'mask_phone' }, { anchor: 'mask_phone', entry: -1 }, { anchor: 'mask_phone', entry: 1.5 }]) + for (const callTree of ['mask_phone', { key: 1, entry: 0 }, { key: 'mask_phone' }, { key: 'mask_phone', entry: -1 }, { key: 'mask_phone', entry: 1.5 }, { anchor: 'mask_phone', entry: 0 }]) expect(createViewStateStore(memoryStorage({ callTree })).get()).toEqual({ editing: [], folds: {} }) }) diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index 93d630b..1aa9b50 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -4,7 +4,7 @@ import type { BlockView, ChainSlot, OpenedFrom } from './types' /** * 页面重新加载(webview 进程崩了重建、开发者重载 webview)时靠它恢复(标签页切走时页面一直留着,用不上它):哪些块停在「编辑」、 * 用户把哪些块、属性行和子行折叠 / 展开了(块 id 是它在树里的位置,文档刷新后照旧对得上;属性行、子行 id 见 [propertyRowId]、[propertyItemId]),滚到了哪, - * 以及开着哪一块的调用树弹框、选中第几个入口。 + * 以及开着哪个调用树弹框、选中第几个入口。 * VS Code 重启后恢复标签页时也带着它,这个标签页是从哪儿打开的靠它报回扩展端。 */ export interface ViewState { @@ -18,9 +18,9 @@ export interface ViewState { callTree?: CallTreeView } -/** 开着的调用树弹框:哪一块(anchor),选中第几个入口(从 0 起)。 */ +/** 开着的调用树弹框:哪个调用树(键,同 [OpenCall.key](vscode/webview-ui/src/codeReader/pageState.ts#OpenCall)),选中第几个入口(从 0 起)。 */ export interface CallTreeView { - anchor: string + key: string entry: number } @@ -73,7 +73,7 @@ function readViewState(saved: unknown): ViewState { } function isCallTreeView(value: unknown): value is CallTreeView { - return isRecord(value) && typeof value.anchor === 'string' && Number.isInteger(value.entry) && (value.entry as number) >= 0 + return isRecord(value) && typeof value.key === 'string' && Number.isInteger(value.entry) && (value.entry as number) >= 0 } /**