✨ feat(vscode): 代码阅读支持库规则,内置 SQLAlchemy 模型类显示成数据表卡片
This commit is contained in:
1 parent
32be7d54b6
commit
5ece24cef9
26 files changed
+1768
-81
No files matched your search
@@ -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] ?? [])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user