✨ feat(vscode): 代码阅读的注释和函数说明按 Markdown 渲染

This commit is contained in:
cruldra committed 2026-09-25 14:28:23 +08:00
1 parent aae9012f14
commit a53c2a6aac
18 files changed
+394 -66

No files matched your search

@@ -5,7 +5,7 @@ import { Fragment } from 'react'
import { cn } from '@/lib/utils'
import { CodeEditor } from './CodeEditor'
import { PropertyGrid } from './PropertyGrid'
import { SegmentText } from './SegmentText'
import { TextPreview } from './TextPreview'
const KIND_LABEL: Record<BlockKind, string> = {
import: '导入',
@@ -66,11 +66,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} />
return (
<p className="cr-code">
<SegmentText segments={block.translation} />
</p>
)
return <TextPreview text={block.translation} />
}
/** 一块:折叠箭头、种类标签、预览内容或源码、右边的「编辑 / 预览」切换。 */
@@ -3,6 +3,7 @@ import type { PropertyItem, PropertyRow } from './types'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Fragment } from 'react'
import { SegmentText } from './SegmentText'
import { TextPreview } from './TextPreview'
import { propertyItemId, propertyRowId } from './viewState'
/** 子行里除最后一列以外各列的宽度(参数的名称、类型),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
@@ -69,9 +70,7 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: {
</button>
)
: <span className="cr-prop-label flex h-5 w-[76px] shrink-0 items-center">{row.label}</span>}
<p className="cr-code min-w-0 flex-1">
<SegmentText segments={row.value} />
</p>
<TextPreview text={row.value} className="min-w-0 flex-1" />
</div>
{items.length > 0 && !folded && <PropertyItems rowId={id} items={items} />}
</Fragment>
@@ -0,0 +1,16 @@
import type { ReactElement } from 'react'
import type { PreviewText } from './types'
import { cn } from '@/lib/utils'
import { renderMarkdown } from './markdown'
import { SegmentText } from './SegmentText'
/** 预览里的一段文字:着色的词按源码排版;Markdown 说明渲染成段落、列表、代码块。 */
export function TextPreview({ text, className }: { text: PreviewText, className?: string }): ReactElement {
if ('markdown' in text)
return <div className={cn('cr-markdown', className)} dangerouslySetInnerHTML={{ __html: renderMarkdown(text.markdown) }} />
return (
<p className={cn('cr-code', className)}>
<SegmentText segments={text} />
</p>
)
}
@@ -163,6 +163,85 @@ body.vscode-high-contrast-light {
.cr-role-comment { color: var(--cr-comment); }
.cr-role-hint { color: var(--vscode-descriptionForeground); }
/* 注释块、函数说明的 Markdown:正文界面字体、注释色,和属性名一样的行高;代码用编辑器字体、垫代码块底色。
间距压紧:一行注释和一行译文一样高,标题只比正文稍大 */
.cr-markdown {
font-family: var(--vscode-font-family, system-ui, sans-serif), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
font-size: var(--vscode-font-size, 13px);
line-height: 20px;
color: var(--cr-comment);
overflow-wrap: break-word;
}
.cr-markdown :is(p, ul, ol, pre, blockquote, table, h1, h2, h3, h4, h5, h6, hr) {
margin: 0 0 6px;
}
.cr-markdown :is(li, blockquote) > :last-child,
.cr-markdown > :last-child {
margin-bottom: 0;
}
.cr-markdown :is(ul, ol) {
padding-left: 20px;
}
.cr-markdown ul { list-style: disc; }
.cr-markdown ol { list-style: decimal; }
.cr-markdown :is(ul, ol) ul { list-style: circle; }
.cr-markdown :is(h1, h2, h3, h4, h5, h6) {
font-size: 1em;
font-weight: 600;
}
.cr-markdown h1 { font-size: 1.15em; }
.cr-markdown h2 { font-size: 1.08em; }
/* 行内代码换了字体,行高压成 1 才不把这一行撑高 */
.cr-markdown code {
padding: 1px 4px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family, monospace), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", monospace;
font-size: 1em;
line-height: 1;
background: var(--vscode-textCodeBlock-background);
}
.cr-markdown pre {
padding: 4px 8px;
border-radius: 4px;
background: var(--vscode-textCodeBlock-background);
white-space: pre-wrap;
tab-size: 4;
}
.cr-markdown pre code {
padding: 0;
font-size: var(--vscode-editor-font-size, 12.5px);
line-height: inherit;
background: none;
}
.cr-markdown a {
color: var(--vscode-textLink-foreground);
text-decoration: none;
}
.cr-markdown a:hover {
color: var(--vscode-textLink-activeForeground);
text-decoration: underline;
}
.cr-markdown blockquote {
padding: 0 8px;
border-left: 3px solid var(--vscode-textBlockQuote-border);
background: var(--vscode-textBlockQuote-background);
}
.cr-markdown table {
border-collapse: collapse;
}
.cr-markdown :is(th, td) {
padding: 0 8px;
border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
}
.cr-markdown th {
font-weight: 600;
text-align: left;
}
.cr-markdown hr {
border: 0;
border-top: 1px solid color-mix(in srgb, currentColor 30%, transparent);
}
/* 按住 ⌘ / Ctrl 停在查得到定义的名字上:和编辑器里的链接一样 */
.cr-code [data-link] {
color: var(--vscode-editorLink-activeForeground);
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { renderMarkdown } from './markdown'
describe('renderMarkdown', () => {
it('一行文字是一个段落;段内换行是软换行,空行分段', () => {
expect(renderMarkdown('读配置。')).toBe('<p>读配置。</p>\n')
expect(renderMarkdown('第一行\n接着写\n\n第二段')).toBe('<p>第一行\n接着写</p>\n<p>第二段</p>\n')
})
it('行内代码(含双反引号)、强调、列表、代码块、标题、引用、表格', () => {
expect(renderMarkdown('先过 :func:`normalize_phone`,打印 ``user`` 时 **脱敏**')).toBe(
'<p>先过 :func:<code>normalize_phone</code>,打印 <code>user</code> 时 <strong>脱敏</strong></p>\n',
)
expect(renderMarkdown('- 甲\n- 乙')).toBe('<ul>\n<li>甲</li>\n<li>乙</li>\n</ul>\n')
expect(renderMarkdown('```ts\nload(\'a\')\n```')).toBe('<pre><code class="language-ts">load(\'a\')\n</code></pre>\n')
expect(renderMarkdown('例子:\n\n f(1)')).toBe('<p>例子:</p>\n<pre><code>f(1)\n</code></pre>\n')
expect(renderMarkdown('# 标题')).toBe('<h1>标题</h1>\n')
expect(renderMarkdown('> 引用')).toBe('<blockquote>\n<p>引用</p>\n</blockquote>\n')
expect(renderMarkdown('| a | b |\n| - | - |\n| 1 | 2 |')).toContain('<table>')
})
it('源码里的 HTML 当文字显示', () => {
expect(renderMarkdown('用 <b>mask</b> <script>alert(1)</script>')).toBe(
'<p>用 &lt;b&gt;mask&lt;/b&gt; &lt;script&gt;alert(1)&lt;/script&gt;</p>\n',
)
expect(renderMarkdown('<div onclick="x()">块</div>')).toBe('<p>&lt;div onclick=&quot;x()&quot;&gt;块&lt;/div&gt;</p>\n')
})
it('带协议的网址、邮箱自动成链接;不带协议的文件名不算', () => {
expect(renderMarkdown('见 https://example.com/phone')).toBe('<p>见 <a href="https://example.com/phone">https://example.com/phone</a></p>\n')
expect(renderMarkdown('[文档](http://example.com)')).toBe('<p><a href="http://example.com">文档</a></p>\n')
expect(renderMarkdown('找 dev@example.com')).toContain('<a href="mailto:dev@example.com">')
expect(renderMarkdown('改 sample.py 和 README.md')).toBe('<p>改 sample.py 和 README.md</p>\n')
})
it('链接只认 http / https / mailto,别的照原文显示;图片不渲染', () => {
expect(renderMarkdown('[点](javascript:alert(1))')).not.toContain('<a')
expect(renderMarkdown('[命令](command:workbench.action.reloadWindow)')).not.toContain('<a')
expect(renderMarkdown('[相对](./x.md)')).not.toContain('<a')
expect(renderMarkdown('![图](https://example.com/a.png)')).not.toContain('<img')
})
})
@@ -0,0 +1,23 @@
import MarkdownIt from 'markdown-it'
/**
* 注释、函数说明的 Markdown 排版:CommonMark 加 GFM 的表格、删除线,带 http(s):// 的网址和邮箱自动成链接。
* 源码里的 HTML 当文字显示;链接只认 http / https / mailto,别的写法照原文显示——
* webview 会拦下 <a> 的点击交给 VS Code 在外部打开。图片不渲染:webview 的 CSP 不放行外部图片。
*/
const md = new MarkdownIt({ html: false, linkify: true }).disable('image')
md.validateLink = url => /^(?:https?|mailto):/i.test(url)
/** 最近渲染过的文字 → HTML,最多留 CACHE_SIZE 段,最久没用的先丢。 */
const CACHE_SIZE = 500
const cache = new Map<string, string>()
/** Markdown 原文 → HTML;同一段文字只渲染一次。 */
export function renderMarkdown(text: string): string {
const html = cache.get(text) ?? md.render(text)
cache.delete(text)
cache.set(text, html)
if (cache.size > CACHE_SIZE)
cache.delete(cache.keys().next().value!)
return html
}
@@ -56,6 +56,17 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
})
it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => {
const blocks: BlockView[] = [
{ id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], 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)]] }] },
]),
]
expect(revealPlan(blocks, 0, 30)).toEqual({ id: '1', expand: ['1/p1'] })
})
it('翻译插件没标 defines、名字在属性行展开的子行里(参数):闪整块,连那一行一起展开', () => {
expect(revealPlan(BLOCKS, 2, 57)).toEqual({ id: '1', expand: ['1/p2'] })
expect(revealPlan(BLOCKS, 0, 34)).toEqual({ id: '0', expand: ['0/p1'] })
+4 -3
View File
@@ -1,4 +1,4 @@
import type { BlockView, Segment, SourceRange } from './types'
import type { BlockView, PreviewText, SourceRange } from './types'
import { propertyItemId, propertyRowId } from './viewState'
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块和属性行:它们折叠着,目标就不在页面上。 */
@@ -30,8 +30,9 @@ function covers(range: SourceRange | undefined, offset: number): boolean {
return range !== undefined && range.startIndex <= offset && offset < range.endIndex
}
function shows(segments: Segment[], offset: number): boolean {
return segments.some(segment => segment.offset === offset)
/** 文字里显示着 offset 处的名字;Markdown 说明里的名字不带下标。 */
function shows(text: PreviewText, offset: number): boolean {
return !('markdown' in text) && text.some(segment => segment.offset === offset)
}
/**
+10 -4
View File
@@ -17,6 +17,12 @@ export type TokenRole
/** offset:照抄自源码里的名字、字符串时它在源码里的起始下标,⌘ / Ctrl 悬停、点击时拿它查定义;译词、提示词没有 */
export interface Segment { text: string, role: TokenRole, offset?: number }
/** 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,按 Markdown 渲染成注释色的段落、列表、代码块 */
export interface Markdown { markdown: string }
/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数的说明) */
export type PreviewText = Segment[] | Markdown
/** 源码里一段的下标,end 为开区间 */
export interface SourceRange { startIndex: number, endIndex: number }
@@ -29,10 +35,10 @@ export interface Defines { defines?: SourceRange }
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值 */
export interface PropertyItem extends Defines { cells: Segment[][] }
/** 属性表的一行:左列属性名,右列着色的值;items 是展开在这一行下面的子行(输入参数、导入项) */
/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项) */
export interface PropertyRow extends Defines {
label: string
value: Segment[]
value: PreviewText
items?: PropertyItem[]
}
@@ -52,8 +58,8 @@ interface BlockViewBase {
children: BlockView[]
}
/** 函数、导入、常量、变量块预览时是属性表,其余是一行译文 */
export type BlockView = BlockViewBase & ({ translation: Segment[] } | { properties: PropertyRow[] })
/** 函数、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文 */
export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] })
export interface TranslatorOption { id: string, label: string }