✨ feat(vscode): 代码阅读卡片横排多个概念图标

This commit is contained in:
cruldra committed 2026-10-03 03:11:47 +08:00
1 parent 11fcbef225
commit 394cd2fc1c
19 files changed
+328 -99

No files matched your search

@@ -15,7 +15,21 @@ vi.mock('./vscode', () => ({ postMessage: () => {}, onMessage: () => () => {}, v
const BADGE: KindBadge = { label: 'React 组件', color: { dark: '#61dafb', light: '#0a7ea4' }, icon: 'atom' }
function view(id: string, kind: string, text: string, extra: Partial<BlockView> = {}): BlockView {
return { id, anchor: id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, children: [], translation: [{ text, role: 'plain' }], ...extra } as BlockView
const badge = extra.badge
return {
id,
anchor: id,
kind,
startLine: 0,
endLine: 0,
code: '',
codeTokens: [],
attributes: { kind },
concepts: [{ id: kind, label: badge?.label ?? kind, icon: badge?.icon ?? 'box', ...(badge && { color: badge.color }) }],
children: [],
translation: [{ text, role: 'plain' }],
...extra,
} as BlockView
}
/** 一张组件卡片:第 1 行下面放着一个副作用,第 2 行的两个子行各讲一个处理函数,后面还有一个留下的注释块。 */
@@ -251,7 +251,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
</button>
)
: <span className="w-3.5 shrink-0" />}
<KindIcon block={block} name={tree.kindName(block.kind)} />
<KindIcon concepts={block.concepts} />
<div className="min-w-0 flex-1">
{editing
? (
@@ -303,7 +303,7 @@ function RemovedCard({ placed, tree }: { placed: PlacedRemoved, tree: BlockTreeS
>
<Chevron size={14} strokeWidth={1.8} />
</button>
<KindIcon block={block} name={kindName} />
<KindIcon concepts={block.concepts} />
<div className="min-w-0 flex-1">
<div className="cr-removed-head cursor-pointer truncate text-[12.5px] leading-5" onClick={toggle}>
<span className="cr-removed-label">已删除 · </span>
@@ -3,7 +3,7 @@ import type { CSSProperties, ReactElement } from 'react'
import type { GroupStyle, Inline, SegmentGroup } from './types'
import { useContext, useState } from 'react'
import { DisplayNameContext, shownName } from './displayNames'
import { KIND_ICON_COMPONENTS, KIND_ICONS } from './kindIcons'
import { KIND_ICON_COMPONENTS } from './kindIcons'
import { isCurrent, segmentOccurrence, sourceOf } from './occurrences'
import { HighlightContext } from './useOccurrences'
@@ -47,10 +47,10 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement
* 颜色在 codeReader.css 的 .cr-group[data-style] 里,框自己给了颜色(color)时用它。
*/
const GROUP_ICONS: Record<GroupStyle, LucideIcon | undefined> = {
iteration: KIND_ICON_COMPONENTS[KIND_ICONS.loop],
condition: KIND_ICON_COMPONENTS[KIND_ICONS.if],
iteration: KIND_ICON_COMPONENTS.repeat,
condition: KIND_ICON_COMPONENTS.split,
branch: undefined,
skip: KIND_ICON_COMPONENTS[KIND_ICONS.return],
skip: KIND_ICON_COMPONENTS['corner-down-left'],
tag: undefined,
classes: undefined,
}
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { changeStops, diffTree, foldUnchanged, nextStop, openedByDiff, removedTitle, statsParts } from './diffView'
function block(id: string, anchor: string, children: BlockView[] = [], extra: Partial<BlockView> = {}): BlockView {
return { id, anchor, kind: 'function', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'function' }, children, translation: [], ...extra } as BlockView
return { id, anchor, kind: 'function', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'function' }, concepts: [{ id: 'function', label: '函数', icon: 'square-function' }], children, translation: [], ...extra } as BlockView
}
function removed(parent: string, after: string | undefined, kind = 'function'): RemovedView {
@@ -4,7 +4,7 @@ import { coveredByEditing, startEditing } from './editing'
import { revealPlan, uncoveredPlan } from './reveal'
function block(id: string, kind: string, [startLine, endLine]: [number, number], children: BlockView[] = []): BlockView {
return { id, kind, startLine, endLine, code: '', codeTokens: [], attributes: { kind }, children, properties: [] }
return { id, kind, startLine, endLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties: [] }
}
/**
@@ -3,7 +3,7 @@ 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<string, string> = {}, children: BlockView[] = []): BlockView {
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, children, translation: [] }
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind, ...extra }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] }
}
const NONE = new Set<string>()
@@ -1,10 +1,15 @@
import type { BlockView, KindIconName } from './types'
import type { BlockConcept, KindIconName } from './types'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { KIND_ICON_COMPONENTS, KIND_ICONS, KindIcon } from './kindIcons'
import { KIND_ICONS } from '../../../src/codeReader/contract'
import { KIND_ICON_COMPONENTS, KindIcon } from './kindIcons'
function render(block: Pick<BlockView, 'kind' | 'badge'>, name: string): string {
return renderToStaticMarkup(<KindIcon block={block} name={name} />)
function render(concepts: readonly BlockConcept[]): string {
return renderToStaticMarkup(<KindIcon concepts={concepts} />)
}
function concept(id: string, label: string, icon: KindIconName, color?: BlockConcept['color']): BlockConcept {
return { id, label, icon, ...(color && { color }) }
}
/** 标签之间的字:图标(svg)没有字 */
@@ -49,23 +54,44 @@ describe('块的种类图标', () => {
it('这组图标里每个名字画的就是 lucide 里同名的图标', () => {
for (const name of Object.keys(KIND_ICON_COMPONENTS) as KindIconName[]) {
const html = render({ kind: 'sqlalchemy.table', badge: { label: name, color: { dark: '#fff', light: '#000' }, icon: name } }, name)
const html = render([concept(name, name, name)])
expect(html, name).toContain(`class="lucide lucide-${name}"`)
}
})
it('只画图标不带字,名字在悬停提示(data-tooltip,不用原生 title)和 aria-label 里;20px 的框、16px 的图形', () => {
const html = render({ kind: 'function' }, '函数')
it('只画图标不带字,名字在悬停提示(data-tooltip,不用原生 title)里;读屏念这个名字;20px 的框、16px 的图形', () => {
const html = render([concept('function', '函数', 'square-function')])
expect(visibleText(html)).toBe('')
expect(html).toMatch(/^<span class="cr-kind [^"]*size-5[^"]*" role="img" data-tooltip="函数" aria-label="函数"><svg/)
expect(html).toContain('role="img" aria-label="函数"')
expect(html).toContain('data-tooltip="函数"')
expect(html).toContain('data-kind="function"')
expect(html).not.toContain(' title=')
expect(html).toContain('lucide-square-function')
expect(html).toContain('width="16" height="16"')
expect(html).toContain('size-5')
})
it('库规则的卡片用规则给的图标,名字是规则给的', () => {
const html = render({ kind: 'sqlalchemy.table', badge: { label: '数据表', color: { dark: '#e8a860', light: '#a3590d' }, icon: 'database' } }, '数据表')
expect(html).toContain('lucide-database')
expect(html).toContain('data-tooltip="数据表" aria-label="数据表"')
it('认到几个就横排几个:悬停各自的名字,读屏把名字连起来念;规则的图标用规则给的颜色', () => {
const html = render([
concept('class', '类', 'box'),
concept('dataclasses.dataclass', '数据类', 'package', { dark: '#ef7fb0', light: '#b3306c' }),
concept('dataclasses.frozen', '不可修改', 'lock', { dark: '#9cdcfe', light: '#001080' }),
])
expect(visibleText(html)).toBe('')
expect(html).toContain('aria-label="类,数据类,不可修改"')
expect(html).toContain('data-tooltip="类"')
expect(html).toContain('data-tooltip="数据类"')
expect(html).toContain('data-tooltip="不可修改"')
expect(html).toContain('data-kind="class"')
expect(html).toContain('lucide-box')
expect(html).toContain('lucide-package')
expect(html).toContain('lucide-lock')
expect(html).toContain('--kind-dark:#ef7fb0')
expect(html).toContain('--kind-light:#b3306c')
expect(html).toContain('--kind-dark:#9cdcfe')
expect(html).toContain('--kind-light:#001080')
expect(html).not.toContain(' title=')
expect(html.match(/size-5/g)).toHaveLength(3)
expect(html).toContain('gap-0.5')
})
})
+18 -47
View File
@@ -1,6 +1,6 @@
import type { LucideIcon } from 'lucide-react'
import type { ReactElement } from 'react'
import type { BlockKind, BlockView, KindIconName } from './types'
import type { CSSProperties, ReactElement } from 'react'
import type { BlockConcept, KindIconName } from './types'
import {
Activity,
Atom,
@@ -79,53 +79,24 @@ export const KIND_ICON_COMPONENTS: Record<KindIconName, LucideIcon> = {
}
/**
* 内置种类的图标:块左边只画图标,名字是悬停提示。
* 同一类语义共用一个颜色(codeReader.css 的 --cr-kind-*:常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),图标区分具体是哪种。
* 卡片左边的概念图标,横排,各 20px,间距紧。图标名由宿主放进概念列表,这里只按名字画。
* 没有颜色的是语言的种类,按 id 用 CSS 的种类色;有颜色的只给这个图标上色。
* 每个图标悬停自己的名字(data-tooltip,见 components/ui/tooltip.tsx);读屏把名字连起来念。
*/
export const KIND_ICONS: Record<BlockKind, KindIconName> = {
import: 'import',
function: 'square-function',
class: 'box',
type: 'braces',
enum: 'list-ordered',
exception: 'triangle-alert',
field: 'tag',
const: 'lock',
variable: 'variable',
if: 'split',
else: 'corner-down-right',
loop: 'repeat',
try: 'shield',
catch: 'shield-alert',
finally: 'shield-check',
return: 'corner-down-left',
statement: 'code',
comment: 'message-square-text',
interpreter: 'terminal',
options: 'sliders-horizontal',
case: 'signpost',
command: 'square-terminal',
pipeline: 'square-terminal',
directive: 'terminal',
stage: 'layers',
run: 'square-terminal',
copy: 'copy',
setting: 'sliders-horizontal',
label: 'tag',
health: 'activity',
start: 'play',
}
/**
* 块左边的种类:只画图标,和一行字等高(20px 的框、16px 的图形),颜色是种类色(--kind)。图标是库规则给的(badge.icon),
* 没有就是内置种类的(KIND_ICONS,没带 badge 的块 kind 是 BlockKind);名字(翻译插件给种类起的,库规则的种类是规则给的)
* 写在悬停提示(data-tooltip,见 components/ui/tooltip.tsx)和读屏念的 aria-label 里。
*/
export function KindIcon({ block, name }: { block: Pick<BlockView, 'kind' | 'badge'>, name: string }): ReactElement {
const Icon = KIND_ICON_COMPONENTS[block.badge?.icon ?? KIND_ICONS[block.kind as BlockKind]]
export function KindIcon({ concepts }: { concepts: readonly BlockConcept[] }): ReactElement {
return (
<span className="cr-kind flex size-5 shrink-0 items-center justify-center" role="img" data-tooltip={name} aria-label={name}>
<Icon size={16} strokeWidth={2.2} aria-hidden />
<span className="flex shrink-0 items-center gap-0.5" role="img" aria-label={concepts.map(concept => concept.label).join(',')}>
{concepts.map((concept) => {
const Icon = KIND_ICON_COMPONENTS[concept.icon]
const tint = concept.color
? { 'data-badge': '' as const, 'style': { '--kind-dark': concept.color.dark, '--kind-light': concept.color.light } as CSSProperties }
: { 'data-kind': concept.id }
return (
<span key={concept.id} className="cr-kind flex size-5 shrink-0 items-center justify-center" data-tooltip={concept.label} aria-hidden {...tint}>
<Icon size={16} strokeWidth={2.2} aria-hidden />
</span>
)
})}
</span>
)
}
@@ -21,11 +21,11 @@ function word(text: string): Segment {
}
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Inline[], children: BlockView[] = []): BlockView {
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, translation }
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation }
}
function factBlock(id: string, kind: BlockKind, startLine: number, properties: PropertyRow[], children: BlockView[] = []): BlockView {
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, properties }
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, properties }
}
/**
@@ -127,7 +127,7 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => {
const blocks: BlockView[] = [
{ id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, children: [], translation: { markdown: '见 `get_db`' } },
{ id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, concepts: [{ id: 'comment', label: '注释', icon: 'message-square-text' }], children: [], translation: { markdown: '见 `get_db`' } },
factBlock('1', 'function', 1, [
{ label: '说明', value: { markdown: '同 `get_db`' } },
{ label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] },
+16 -3
View File
@@ -101,7 +101,18 @@ export type KindIconName
| 'message-square-text' | 'terminal' | 'sliders-horizontal' | 'signpost' | 'square-terminal' | 'layers' | 'copy' | 'activity' | 'play'
| 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' | 'atom' | 'zap' | 'mouse-pointer-click'
/** 库规则的种类:名字(悬停提示、读屏念的文字)、颜色和图标(从 KindIconName 里挑);块左边只画图标 */
/**
* 一块上认出的一个概念。宿主放进预览数据:稳定 id、悬停名、图标名。
* 颜色不填的是语言的种类,按 id 用 CSS 的种类色;填了的只给这个图标上色。
*/
export interface BlockConcept {
id: string
label: string
icon: KindIconName
color?: ThemeColor
}
/** 库规则的种类:筛选栏里叫 label,卡片边框用 color。图标在 concepts 里,不单画这一个。 */
export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName }
interface BlockViewBase {
@@ -109,7 +120,7 @@ interface BlockViewBase {
id: string
/** 块的位置记号:问答挂在它上面(QaThreadView.anchor),文件别处改了照样对得上;webview 只拿它比相等,不拆开看 */
anchor: string
/** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table);webview 不认识后者,只按 badge 画 */
/** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table)。筛选用它;图标不看它,看 concepts */
kind: string
/** 编辑范围的起止行,0 起:块的完整源码,头部连同各子孙块、结尾(块编辑时子孙都不画,见 coveredByEditing) */
startLine: number
@@ -122,7 +133,9 @@ interface BlockViewBase {
defines?: SourceRange
/** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */
attributes: Record<string, string>
/** 库规则认领的块才有:种类的名字、颜色和图标,由规则给;和类块一样默认收起子块 */
/** 这块认出来的概念,从左到右。webview 按图标名画,不自己用种类查 */
concepts: BlockConcept[]
/** 库规则的卡片才有:卡片边框的颜色,筛选栏里这个种类的名字。和类块一样默认收起子块 */
badge?: KindBadge
/** 库规则放进父卡片里的子块才有:画在父卡片属性表第 row 行下面,不排在父卡片的子块里;item 见 BlockSlot */
slot?: BlockSlot
@@ -15,7 +15,7 @@ function memoryStorage(initial?: unknown): ViewStateStorage {
}
function block(id: string, kind: BlockKind, children: BlockView[] = []): BlockView {
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, children, translation: [] }
return { id, kind, startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind }, concepts: [{ id: kind, label: kind, icon: 'box' }], children, translation: [] }
}
/**