From 6723c1dcc3da338a111693c87413f56978b82e5a Mon Sep 17 00:00:00 2001 From: cruldra Date: Thu, 8 Oct 2026 17:35:07 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E6=8C=89?= =?UTF-8?q?=E7=A7=8D=E7=B1=BB=E7=AD=9B=E9=80=89=E5=90=8E=E4=BB=8D=E8=83=BD?= =?UTF-8?q?=E5=B1=95=E5=BC=80=E5=8C=B9=E9=85=8D=E5=9D=97=E7=9A=84=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E4=B8=BB=E4=BD=93=EF=BC=8C=E6=8E=A5=E5=8F=A3=E6=94=B9?= =?UTF-8?q?=E5=8F=AB=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/codeReader/rules/fastapi.test.ts | 2 +- vscode/src/codeReader/rules/fastapi.ts | 2 +- .../src/codeReader/BlockTree.test.tsx | 64 ++++++++++++++++ .../webview-ui/src/codeReader/filter.test.ts | 75 ++++++++++++++++--- vscode/webview-ui/src/codeReader/filter.ts | 25 ++++++- 5 files changed, 152 insertions(+), 16 deletions(-) diff --git a/vscode/src/codeReader/rules/fastapi.test.ts b/vscode/src/codeReader/rules/fastapi.test.ts index f187862..64c33a0 100644 --- a/vscode/src/codeReader/rules/fastapi.test.ts +++ b/vscode/src/codeReader/rules/fastapi.test.ts @@ -65,7 +65,7 @@ const HEADER = [ describe('fastapi 库规则:接口卡片', () => { it('luffy 的 update_user:名称 / 说明 / 标签 / 异步 / 依赖,请求表、响应表;Forbidden 的状态码查到是 403,四条消息写在一起', async () => { const card = await route(ROUTES, 'update_user', known({ Forbidden: '403' })) - expect(card.badge).toEqual({ label: '接口', color: { dark: '#61afef', light: '#1a6fb5' }, icon: 'globe' }) + expect(card.badge).toEqual({ label: '路由', color: { dark: '#61afef', light: '#1a6fb5' }, icon: 'globe' }) expect(propertyLines(properties(card))).toEqual([ '名称: update_user', '说明: 更新用户信息。\n\n管理员可更新任意用户,普通用户只能更新自己的基础资料。', diff --git a/vscode/src/codeReader/rules/fastapi.ts b/vscode/src/codeReader/rules/fastapi.ts index 9679d51..ef0c95d 100644 --- a/vscode/src/codeReader/rules/fastapi.ts +++ b/vscode/src/codeReader/rules/fastapi.ts @@ -97,7 +97,7 @@ interface Words { } const ZH: Words = { - kind: '接口', + kind: '路由', routerKind: '路由器', labels: { name: '名称', diff --git a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx index b7c1d61..a4a28cc 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.test.tsx @@ -2,7 +2,15 @@ import type { BlockTreeState } from './BlockTree' import type { BlockView, KindBadge } from './types' import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it, vi } from 'vitest' +import { fileAttributes } from '../../../src/codeReader/attributes' +import { toBlockViews } from '../../../src/codeReader/blockView' +import { createPythonAdapter } from '../../../src/codeReader/languages/python' +import { fastapiRule } from '../../../src/codeReader/rules/fastapi' +import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' +import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' import { BlockLevel } from './BlockTree' +import { applyFilter } from './filter' +import { FilterBar } from './FilterBar' import { collapsedIds } from './viewState' // 高亮的上下文所在的模块、编辑框都连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着 @@ -77,6 +85,62 @@ function render(folds: Record): string { return renderToStaticMarkup() } +describe('路由筛选后展开函数体', () => { + it('真实 FastAPI 路由保留 if 和 return 子块,收起时有箭头,展开后显示完整主体及请求响应,菜单和标签统一叫路由', async () => { + const source = [ + 'from fastapi import APIRouter', + 'router = APIRouter()', + '@router.put("/account")', + 'async def put_miniprogram_account(enabled: bool):', + ' if enabled:', + ' return "enabled-body"', + ' return "disabled-body"', + 'def unrelated():', + ' return "unrelated-body"', + ].join('\n') + const language = createPythonAdapter(WASM_DIR) + const blocks = await withBlocks(language, 'python', source, parsed => toBlockViews( + parsed, + { source, language, markers: [] }, + pythonZhTranslator, + { rules: [fastapiRule], report: (message) => { throw new Error(message) } }, + )) + const route = blocks.find(block => block.kind === 'fastapi.route')! + expect(route.children.map(block => block.kind)).toEqual(['if', 'return']) + expect(route.children[0].children.map(block => block.kind)).toEqual(['return']) + const attributes = fileAttributes(blocks, pythonZhTranslator.attributeNames) + const excluded = { kind: attributes.find(attribute => attribute.id === 'kind')!.values.map(value => value.id).filter(kind => kind !== 'fastapi.route') } + const filtered = applyFilter(blocks, excluded, new Set()) + const tree = { ...state(collapsedIds(blocks, {})), ...filtered } + const closed = renderToStaticMarkup() + expect(closed).toContain('aria-label="展开子块"') + expect(closed).not.toContain('enabled-body') + const opened = renderToStaticMarkup() + expect(opened).toContain('enabled-body') + expect(opened).toContain('disabled-body') + expect(opened).not.toContain('unrelated-body') + expect(opened).toContain('请求') + expect(opened).toContain('响应') + expect(route.badge?.label).toBe('路由') + expect(route.concepts.find(concept => concept.id === 'fastapi.route')?.label).toBe('路由') + const bar = renderToStaticMarkup( + {}} + onMoveOccurrence={() => {}} + translators={[]} + translatorId="zh" + onSelectTranslator={() => {}} + />, + ) + expect(bar).toContain('种类:路由') + expect(bar).not.toContain('接口') + }) +}) + describe('挂在卡片子行上的子块', () => { it('默认不画,子行的名称是展开它的按钮;卡片收着时留下的子块不画', () => { const html = render({}) diff --git a/vscode/webview-ui/src/codeReader/filter.test.ts b/vscode/webview-ui/src/codeReader/filter.test.ts index 7df3a28..38cbde8 100644 --- a/vscode/webview-ui/src/codeReader/filter.test.ts +++ b/vscode/webview-ui/src/codeReader/filter.test.ts @@ -1,8 +1,8 @@ -import type { BlockKind, BlockView, FileAttribute } from './types' +import type { BlockView, FileAttribute } from './types' import { describe, expect, it } from 'vitest' import { applyFilter, blockPath, excludedBy, facetLabel, facetsOf, keptWhileEditing, setChecked, toggleValue } from './filter' -function block(id: string, kind: BlockKind, extra: Record = {}, children: BlockView[] = []): BlockView { +function block(id: string, kind: BlockView['kind'], extra: Record = {}, children: BlockView[] = []): BlockView { return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] } } @@ -41,15 +41,69 @@ describe('按筛选藏块', () => { it('同一属性里勾着的值满足一个就显示,不同属性要同时满足', () => { const kinds = { kind: ['comment', 'if', 'return'] } - expect([...applyFilter(BLOCKS, kinds, NONE).hidden].sort()).toEqual(['1.0', '1.0.0', '2.0', '3']) + expect([...applyFilter(BLOCKS, kinds, NONE).hidden].sort()).toEqual(['3']) const both = applyFilter(BLOCKS, { access: ['private'], async: ['false'] }, NONE) expect([...both.hidden].sort()).toEqual(['0', '1.1', '2', '2.0']) expect(excludedBy(BLOCKS[1], { access: ['private'], async: ['false'] })).toBe(false) expect(excludedBy(BLOCKS[1], { async: ['true'] })).toBe(true) }) + it('选中的函数保留完整主体,种类筛选不再截断其子孙;其他属性仍然逐块生效', () => { + const kinds = { kind: ['const', 'if', 'return', 'comment'] } + const view = applyFilter(BLOCKS, kinds, NONE) + expect([...view.hidden].sort()).toEqual(['0', '3']) + expect(view.hiddenCount).toBe(2) + expect(view.temporary).toEqual(NONE) + const both = applyFilter(BLOCKS, { ...kinds, access: ['private'] }, NONE) + expect([...both.hidden].sort()).toEqual(['0', '1.1', '2', '2.0', '3']) + expect(both.hiddenCount).toBe(4) + }) + + it('嵌套路由保留通向它的祖先与完整主体,不带出祖先的其他子块或兄弟路由', () => { + const blocks = [ + block('0', 'function', {}, [ + block('0.0', 'const'), + block('0.1', 'function', {}, [ + block('0.1.0', 'fastapi.route', { httpMethod: 'PUT' }, [ + block('0.1.0.0', 'if', {}, [block('0.1.0.0.0', 'return')]), + block('0.1.0.1', 'return'), + ]), + block('0.1.1', 'return'), + ]), + block('0.2', 'fastapi.route', { httpMethod: 'GET' }, [block('0.2.0', 'return')]), + ]), + block('1', 'function', {}, [block('1.0', 'return')]), + ] + const excluded = { kind: ['function', 'const', 'if', 'return'], httpMethod: ['GET'] } + const view = applyFilter(blocks, excluded, NONE) + expect([...view.hidden].sort()).toEqual(['0.0', '0.1.1', '0.2', '0.2.0', '1', '1.0']) + expect(view.hiddenCount).toBe(4) + expect(view.total).toBe(12) + expect(view.temporary).toEqual(NONE) + }) + + it('只看 return 时保留祖先路径,不显示路径旁的 const 和没有 return 的函数', () => { + const view = applyFilter(BLOCKS, { kind: ['const', 'function', 'if', 'comment'] }, NONE) + expect([...view.hidden].sort()).toEqual(['0', '1.1', '2', '2.0', '3']) + expect(view.hiddenCount).toBe(4) + }) + + it('种类全部排除后仍可临时显示目标路径,不带出被筛掉的兄弟', () => { + const view = applyFilter(BLOCKS, { kind: ['const', 'function', 'if', 'return', 'comment'] }, new Set(blockPath('1.0.0'))) + expect([...view.hidden].sort()).toEqual(['0', '1.1', '2', '2.0', '3']) + expect([...view.temporary]).toEqual(['1', '1.0', '1.0.0']) + expect(view.hiddenCount).toBe(4) + }) + + it('没有选中种类时全部隐藏,全选后全部恢复', () => { + const all = ['const', 'function', 'if', 'return', 'comment'] + const excluded = setChecked({}, 'kind', all, false) + expect(applyFilter(BLOCKS, excluded, NONE).hidden.size).toBe(8) + expect(applyFilter(BLOCKS, setChecked(excluded, 'kind', all, true), NONE).hidden).toEqual(NONE) + }) + it('藏着的块里又被筛掉的子块不重复计数', () => { - expect(applyFilter(BLOCKS, { kind: ['function', 'if'] }, NONE).hiddenCount).toBe(2) + expect(applyFilter(BLOCKS, { kind: ['const', 'function', 'if', 'return'] }, NONE).hiddenCount).toBe(3) }) it('跳到定义临时露出来的:藏着的祖先下面只露出通向目标的路径,本来藏着的记进 temporary;已隐藏的块数照旧按筛选算', () => { @@ -57,10 +111,10 @@ describe('按筛选藏块', () => { expect([...view.hidden].sort()).toEqual(['0', '2', '2.0']) expect([...view.temporary]).toEqual(['1.1']) expect(view.hiddenCount).toBe(3) - const path = applyFilter(BLOCKS, { kind: ['function'] }, new Set(blockPath('1.0'))) - expect([...path.hidden].sort()).toEqual(['1.1', '2', '2.0']) + const path = applyFilter(BLOCKS, { access: ['public', 'private'] }, new Set(blockPath('1.0'))) + expect([...path.hidden].sort()).toEqual(['0', '1.1', '2', '2.0']) expect([...path.temporary].sort()).toEqual(['1', '1.0']) - expect(path.hiddenCount).toBe(2) + expect(path.hiddenCount).toBe(3) const visible = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('1.0'))) expect(visible.temporary).toEqual(new Set()) }) @@ -69,9 +123,10 @@ describe('按筛选藏块', () => { const view = applyFilter(BLOCKS, { access: ['private'] }, new Set(blockPath('2'))) expect([...view.temporary]).toEqual(['2']) expect([...view.hidden].sort()).toEqual(['0', '1.1']) - const inner = applyFilter(BLOCKS, { kind: ['function', 'return'] }, new Set(blockPath('1'))) - expect([...inner.hidden].sort()).toEqual(['1.0.0', '2', '2.0']) - expect(inner.hiddenCount).toBe(2) + const inner = applyFilter(BLOCKS, { access: ['public', 'private'] }, new Set(blockPath('1'))) + expect([...inner.hidden].sort()).toEqual(['0', '1.1', '2', '2.0']) + expect([...inner.temporary]).toEqual(['1']) + expect(inner.hiddenCount).toBe(3) }) }) diff --git a/vscode/webview-ui/src/codeReader/filter.ts b/vscode/webview-ui/src/codeReader/filter.ts index e4e0d4b..dce7b29 100644 --- a/vscode/webview-ui/src/codeReader/filter.ts +++ b/vscode/webview-ui/src/codeReader/filter.ts @@ -28,11 +28,28 @@ export function excludedBy(block: BlockView, excluded: Excluded): boolean { } /** - * 按筛选算出哪些块不画:块被筛掉,连同它的子孙一起藏。keep 是跳到定义时要临时露出来的块(目标和它的各层祖先), - * 其中本来藏着的照画、记进 temporary:藏着的祖先下面只露出通向目标的那条路径,目标自己和没临时显示的块一样, - * 它的子块照常按筛选显示(函数照样能展开看主体)。 + * 种类筛选保留匹配块的完整主体及通向它的祖先,其余属性仍能隐藏整棵子树,让选中的卡片可以展开阅读。 + * + * @param blocks 文档的顶层代码块。 + * @param excluded 每个筛选属性不显示的值。 + * @param keep 跳到定义时临时显示的目标及祖先,原本隐藏的块会记入 `temporary`,目标的子块仍按筛选显示。 */ export function applyFilter(blocks: readonly BlockView[], excluded: Excluded, keep: ReadonlySet): FilterView { + const { kind: excludedKinds = [], ...otherAttributes } = excluded + const kindVisible = new Set() + const selectKinds = (list: readonly BlockView[], matchedAbove: boolean): boolean => { + let anyVisible = false + for (const block of list) { + const matched = matchedAbove || !excludedKinds.includes(block.attributes.kind) + const childVisible = selectKinds(block.children, matched) + if (matched || childVisible) { + kindVisible.add(block.id) + anyVisible = true + } + } + return anyVisible + } + selectKinds(blocks, false) const hidden = new Set() const temporary = new Set() let hiddenCount = 0 @@ -41,7 +58,7 @@ export function applyFilter(blocks: readonly BlockView[], excluded: Excluded, ke const visit = (list: readonly BlockView[], filteredAbove: boolean, hiddenAbove: boolean): void => { for (const block of list) { total += 1 - const out = excludedBy(block, excluded) + const out = !kindVisible.has(block.id) || excludedBy(block, otherAttributes) if (out && !filteredAbove) hiddenCount += 1 const hiddenHere = hiddenAbove || out