✨ feat(vscode): 代码阅读支持库规则,内置 SQLAlchemy 模型类显示成数据表卡片

This commit is contained in:
cruldra committed 2026-09-26 18:29:40 +08:00
1 parent 32be7d54b6
commit 5ece24cef9
26 files changed
+1768 -81

No files matched your search

+25 -12
View File
@@ -1,5 +1,5 @@
import type { ReactElement } from 'react'
import type { BlockKind, BlockView } from './types'
import type { CSSProperties, ReactElement } from 'react'
import type { BlockView } from './types'
import { ChevronDown, ChevronRight, Eye, EyeOff, Pencil } from 'lucide-react'
import { Fragment } from 'react'
import { cn } from '@/lib/utils'
@@ -16,8 +16,8 @@ export interface BlockTreeState {
hidden: ReadonlySet<string>
/** 本来被筛掉、跳到定义时临时显示的块 */
temporary: ReadonlySet<string>
/** 种类标签上写什么:翻译插件给种类起的名字,没给就是 id */
kindName: (kind: BlockKind) => string
/** 种类标签上写什么:翻译插件给种类起的名字(库规则的种类是规则给的标签),没给就是 id */
kindName: (kind: string) => string
/** 刚点了「编辑」、要把光标放进去的块 */
focusId: string | null
onFocused: () => void
@@ -31,12 +31,25 @@ function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean {
}
/**
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉的块不画,不留空位,竖线跟着变短。
* kind 是这一层所属块的种类(顶层没有),悬停那个块时竖线换成它的颜色。
* 块的种类色写在哪:内置种类按 data-kind 取 CSS 里的配色;库规则认领的块(带 badge)按规则给的颜色,
* 暗色、亮色两个都写在 style 上,CSS 按主题挑一个(见 [data-badge])。
*/
export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?: BlockKind, tree: BlockTreeState }): ReactElement {
function kindProps(block: BlockView | undefined): { 'data-kind'?: string, 'data-badge'?: '', 'style'?: CSSProperties } {
if (!block)
return {}
if (!block.badge)
return { 'data-kind': block.kind }
const { dark, light } = block.badge.color
return { 'data-kind': block.kind, 'data-badge': '', 'style': { '--kind-dark': dark, '--kind-light': light } as CSSProperties }
}
/**
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉的块不画,不留空位,竖线跟着变短。
* owner 是这一层所属的块(顶层没有),悬停它时竖线换成它的种类色。
*/
export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState }): ReactElement {
return (
<div className="cr-level flex gap-2" data-kind={kind}>
<div className="cr-level flex gap-2" {...kindProps(owner)}>
<div className="flex w-3 shrink-0 justify-center">
<div className="cr-guide w-px" />
</div>
@@ -45,7 +58,7 @@ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?:
<Fragment key={block.id}>
<BlockCard block={block} tree={tree} />
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
<BlockLevel blocks={block.children} kind={block.kind} tree={tree} />
<BlockLevel blocks={block.children} owner={block} tree={tree} />
)}
</Fragment>
))}
@@ -54,7 +67,7 @@ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?:
)
}
/** 预览状态的内容:函数、类、导入、常量、变量块是属性表,其余是一行译文。 */
/** 预览状态的内容:函数、类、导入、常量、变量块和库规则认领的块是属性表,其余是一行译文。 */
function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
if ('properties' in block)
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} />
@@ -70,7 +83,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
return (
<div
className="cr-block flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2"
data-kind={block.kind}
{...kindProps(block)}
data-id={block.id}
data-line={block.startLine}
data-editing={editing ? '' : undefined}
@@ -88,7 +101,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
</button>
)
: <span className="w-3.5 shrink-0" />}
<span className="cr-badge flex h-5 w-11 shrink-0 items-center justify-center rounded text-[11px] leading-[14px] font-medium">
<span className="cr-badge flex h-5 min-w-11 shrink-0 items-center justify-center rounded px-1 text-[11px] leading-[14px] font-medium whitespace-nowrap">
{tree.kindName(block.kind)}
</span>
<div className="min-w-0 flex-1">
@@ -4,11 +4,11 @@ import { ChevronDown, ChevronRight } from 'lucide-react'
import { Fragment } from 'react'
import { inlineText } from './segments'
import { SegmentText } from './SegmentText'
import { subRowsLayout, tableCells } from './subRows'
import { subRowsLayout, tableCells, tableTemplate } from './subRows'
import { TextPreview } from './TextPreview'
import { propertyItemId, propertyRowId } from './viewState'
/** 子行里除最后一列以外各列的宽度(参数表格的名称、类型列,字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
/** 列表子行里除最后一列以外各列的宽度(字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)']
function gridTemplate(count: number): string {
@@ -95,19 +95,18 @@ function PropertyItems({ parentId, items, folding }: { parentId: string, items:
}
/**
* 给了列名的子行(输入参数)排成表格:第一行是表头(列名),下面每个子行一行;外框圆角,格子之间、行之间一条分隔线。
* 列宽同列表的子行,最后一列占满剩下的,长了在格子里换行。
* 给了列名的子行(输入参数、数据表的字段)排成表格:第一行是表头(列名),下面每个子行一行;外框圆角,格子之间、行之间一条分隔线。
* 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。
*/
function PropertyTable({ parentId, columns, items }: { parentId: string, columns: string[], items: PropertyItem[] }): ReactElement {
const template = { gridTemplateColumns: gridTemplate(columns.length) }
return (
<div className="pl-[5px]">
<div className="cr-table" role="table">
<div className="cr-table-head grid" role="row" style={template}>
<div className="cr-table" role="table" style={{ gridTemplateColumns: tableTemplate(columns.length) }}>
<div className="cr-table-head" role="row">
{columns.map((name, i) => <span key={i} className="cr-table-cell" role="columnheader">{name}</span>)}
</div>
{items.map((item, index) => (
<div key={index} className="cr-table-row grid" role="row" data-id={propertyItemId(parentId, index)} style={template}>
<div key={index} className="cr-table-row" role="row" data-id={propertyItemId(parentId, index)}>
{tableCells(item, columns).map((cell, i) => (
<p key={i} className="cr-table-cell cr-code min-w-0" role="cell">
<SegmentText segments={cell} />
@@ -121,8 +120,8 @@ function PropertyTable({ parentId, columns, items }: { parentId: string, columns
}
/**
* 函数、类、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行,
* 翻译插件给了列名的(输入参数)排成表格,没给的排成列表。
* 函数、类、导入、常量、变量块和库规则认领的块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行,
* 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。
*/
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: {
blockId: string
@@ -63,6 +63,9 @@ body.vscode-high-contrast-light {
[data-kind="return"] { --kind: var(--cr-kind-return); }
[data-kind="statement"] { --kind: var(--cr-kind-statement); }
[data-kind="comment"] { --kind: var(--cr-kind-comment); }
/* 库规则认领的块:颜色由规则给,暗色、亮色两个写在元素的 style 上(--kind-dark / --kind-light),这里按主题挑 */
[data-badge] { --kind: var(--kind-dark); }
body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(--kind-light); }
/* 卡片和它的子层是相邻兄弟,:hover 只落在鼠标下最里面那张卡片上,父卡片不跟着亮 */
.cr-block {
@@ -330,15 +333,23 @@ body.vscode-high-contrast-light {
}
/*
* 属性表里带列名的子行(输入参数)排成的表格:外框圆角,格子之间、行之间一条主题的表格分隔线;
* 表头垫主题的表头底色(比编辑区深一档或浅一档)、列名用说明色
* 属性表里带列名的子行(输入参数、数据表的字段)排成的表格:外框圆角,格子之间、行之间一条主题的表格分隔线;
* 表头垫主题的表头底色(比编辑区深一档或浅一档)、列名用说明色。
* 整张表格一个网格、每行借用它的列(subgrid),列宽按整列的内容定,见 tableTemplate
*/
.cr-table {
--cr-table-border: var(--vscode-tree-tableColumnsBorder, var(--vscode-editorWidget-border, var(--vscode-contrastBorder)));
display: grid;
border: 1px solid var(--cr-table-border);
border-radius: 4px;
overflow: hidden;
}
.cr-table-head,
.cr-table-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.cr-table-head {
background: var(--vscode-keybindingTable-headerBackground, color-mix(in srgb, var(--vscode-foreground) 5%, transparent));
}
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../../../src/codeReader/blockView'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { createTypeScriptAdapter } from '../../../src/codeReader/languages/typescript'
import { sqlalchemyRule } from '../../../src/codeReader/rules/sqlalchemy'
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh'
@@ -293,6 +294,30 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => {
expect(await atPy('run')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'def run(self):' })
})
it('库规则认领的块:类名闪整块,字段名只闪字段表里它那一行(先展开「字段」那一行);留下的方法照常,卡片默认折叠', async () => {
const source = [
'from sqlalchemy.orm import Mapped, mapped_column',
'',
'class User(Base):',
' __tablename__ = "users"',
' name: Mapped[str] = mapped_column(comment="名字")',
'',
' def greet(self):',
' return self.name',
].join('\n')
const views = await withBlocks(py, 'python', source, blocks =>
toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [sqlalchemyRule], report: () => {} }))
const at = (needle: string): { plan: RevealPlan | undefined, flashed: string } => {
const offset = source.indexOf(needle)
const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset)
return { plan, flashed: flashed(views, plan) }
}
expect(at('User')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'class User(Base):' })
expect(at('name:')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'name | 字符串 | | 名字' })
expect(at('greet')).toEqual({ plan: { id: '1.0', expand: ['1'] }, flashed: 'def greet(self):' })
expect([...collapsedIds(views, {})].sort()).toEqual(['1', '1.0'])
})
it('字典 / 对象:定义落在对象的属性名上只闪那一项,嵌套的连外层各层一起展开;值里只是显示着的名字闪整块,展开到它那一层', async () => {
const source = [
'export const routes = {',
@@ -4,7 +4,7 @@ import { toBlockViews } from '../../../src/codeReader/blockView'
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
import { subRowsLayout, tableCells } from './subRows'
import { subRowsLayout, tableCells, tableTemplate } from './subRows'
const x = { text: 'x', role: 'variable' as const, offset: 4 }
@@ -22,6 +22,11 @@ describe('属性行下面的子行怎么排', () => {
expect(tableCells({ cells: [[x], [x], [x], [x]] }, ['名称', '类型'])).toEqual([[x], [x]])
})
it('表格的列宽按整列内容定:前几列最宽到不换行,最后一列分剩下的、至少留一段', () => {
expect(tableTemplate(2)).toBe('minmax(0, max-content) minmax(min(20%, 8em), 1fr)')
expect(tableTemplate(4)).toBe('minmax(0, max-content) minmax(0, max-content) minmax(0, max-content) minmax(min(20%, 8em), 1fr)')
})
it('真实的 Python 函数:输入参数排成表格,导入项、字典的各项排成列表', async () => {
const source = [
'from m import a',
@@ -10,6 +10,16 @@ export function subRowsLayout(row: PropertyRow): 'table' | 'list' | undefined {
return row.columns ? 'table' : 'list'
}
/**
* 表格的列宽(整张表格一个网格,各行借用它的列):按整列的内容定,不认是哪种表格。
* 前几列(参数名、类型,字段、约束)宽裕时放到整列不换行;最后一列(默认值、说明)分剩下的宽度,至少留 min(20%, 8em):
* 放不下时先让最后一列换行,前几列(约束这种结构化的词)尽量一行写完。编辑区再窄,前几列平分剩下的宽度、在格子里换行(长名字从中间断开),
* 不把表格撑出卡片、不藏掉哪一列。
*/
export function tableTemplate(count: number): string {
return [...Array.from({ length: count - 1 }, () => 'minmax(0, max-content)'), 'minmax(min(20%, 8em), 1fr)'].join(' ')
}
/** 表格里一个子行的格子:按列名的列数取,子行少给的格子留空、多给的不显示。 */
export function tableCells(item: PropertyItem, columns: string[]): Inline[][] {
return columns.map((_, i) => item.cells[i] ?? [])
+13 -4
View File
@@ -63,14 +63,21 @@ export interface PropertyRow extends Defines {
columns?: string[]
}
/** 一种颜色在暗色、亮色主题下各用哪个(CSS 颜色值);高对比主题跟着暗 / 亮走 */
export interface ThemeColor { dark: string, light: string }
/** 种类标签:文字和颜色 */
export interface KindBadge { label: string, color: ThemeColor }
interface BlockViewBase {
/** 在树里的位置,例如 '6.1.0' */
id: string
kind: BlockKind
/** header 的起止行,0 起 */
/** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table);webview 不认识后者,只按 badge 画 */
kind: string
/** 编辑范围的起止行,0 起:一般是 header,库规则认领的块到最后一个并进来的子块为止 */
startLine: number
endLine: number
/** header 源码,去掉共同缩进 */
/** 编辑范围的源码,去掉共同缩进 */
code: string
/** 源码着色(拼起来等于 code) */
codeTokens: Segment[]
@@ -78,10 +85,12 @@ interface BlockViewBase {
defines?: SourceRange
/** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */
attributes: Record<string, string>
/** 库规则认领的块才有:种类标签的文字和颜色,由规则给;和类块一样默认收起子块 */
badge?: KindBadge
children: BlockView[]
}
/** 函数、类、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文 */
/** 函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文 */
export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] })
export interface TranslatorOption { id: string, label: string }
@@ -106,6 +106,11 @@ describe('眼下折叠着哪些', () => {
expect([...collapsedIds(BLOCKS, {})].sort()).toEqual(['1', '1.0.0', '2', '2.0'])
})
it('没点过:库规则认领的块(带种类标签)和类块一样折叠,不看种类 id', () => {
const table: BlockView = { ...block('0', 'if', [block('0.0', 'return')]), kind: 'sqlalchemy.table', badge: { label: '数据表', color: { dark: '#e8a860', light: '#a3590d' } } }
expect([...collapsedIds([table, block('1', 'if')], {})]).toEqual(['0'])
})
it('点过的按用户的:展开的函数、类保持展开,折叠的其他块、属性行保持折叠', () => {
const folds = { '1': false, '2': false, '1.0': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false }
expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '1.0', '2.0'])
@@ -1,5 +1,5 @@
import type { ScrollAnchor } from './scrollPosition'
import type { BlockKind, BlockView } from './types'
import type { BlockView } from './types'
/**
* 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、
@@ -54,9 +54,9 @@ function readViewState(saved: unknown): ViewState {
return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds }
}
/** 块默认折叠吗:函数、类块收起主体(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */
export function foldedByDefault(kind: BlockKind): boolean {
return kind === 'function' || kind === 'class'
/** 块默认折叠吗:函数、类块和库规则认领的块收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */
export function foldedByDefault(block: Pick<BlockView, 'kind' | 'badge'>): boolean {
return block.badge !== undefined || block.kind === 'function' || block.kind === 'class'
}
/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */
@@ -64,7 +64,7 @@ export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Recor
const collapsed = new Set(Object.keys(folds).filter(id => folds[id]))
const visit = (list: readonly BlockView[]): void => {
for (const block of list) {
if (folds[block.id] === undefined && foldedByDefault(block.kind))
if (folds[block.id] === undefined && foldedByDefault(block))
collapsed.add(block.id)
visit(block.children)
}