diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 972563f..7485362 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -21,6 +21,7 @@ | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | | 折叠 | 有子块的块、属性表里带子行的行能折叠;函数块(含方法、嵌套函数)默认收起主体、属性表照常显示,其余块和属性行默认展开;用户点过的按他点的记住 | | 函数的说明 | TypeScript 紧贴在函数前面的 JSDoc、Python 函数主体开头的 docstring 是函数自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数块的头部里,「编辑」时和签名一起改 | +| 注释按 Markdown 显示 | 注释块和函数的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | | 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | | 侧栏文件树 | 只看和打开:展开目录、打开文件、刷新、全部折叠;不做新建 / 重命名 / 删除 | | 译文用词 | 结构照抄原语言,只翻关键字、内置类型、运算符;用专业术语(`export` → 公共,`catch` → 捕获,`===` → 全等) | @@ -49,7 +50,7 @@ - 块的组成(从左到右):折叠箭头(只有有子块的块才显示,其余占位对齐)、种类标签、内容、右侧切换按钮。 - 种类标签和底色按种类区分(颜色见下表);注释块虚线框。 - 内容两种状态,每块单独切: - - 预览:函数、导入、常量、变量块是属性表(见下节),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。 + - 预览:函数、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。 - 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数的说明(JSDoc / docstring)也算在函数块的头部里。 - 折叠:函数块打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 @@ -62,7 +63,7 @@ | 种类 | 行 | | --- | --- | -| 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,保留换行和 `@param` 这类标签行;按 comment 着色,多行照原样换行、长了折行;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(`默认 ` + 译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | +| 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,`@param` 这类标签行照抄;得到的原文按 Markdown 渲染,见「注释的 Markdown 排版」;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(`默认 ` + 译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) | | 导入 | 来源(TypeScript 是字符串字面量原样,如 `'node:fs'`,字符串色;Python 是点分模块名,如 `fastapi`、`.models`,类型色)/ 仅类型(是 / 否,只有 TypeScript 有,看 `import type`)/ 导入项(`N 个` 或「无」,每项一行:`name`、`name 作为 alias`;默认导入写「默认导出 作为 x」,`* as x` 写「全部 作为 x」,Python `from m import *` 写「全部」,Python `import a.b as c` 写「整个模块 作为 c」,一条 `import a, b` 导入多个模块时写模块名) | | 常量 / 变量 | 名称(解构声明写整个模式,如 `{ p, q }`)/ 访问级别 / 类型(写了标注才有)/ 值(常量)或初始值(变量):初始化表达式的译文,没有写「无」。一条语句声明几个变量(`let a = 1, b = 2`)就重复几组 | @@ -74,6 +75,15 @@ 值里的表达式只翻落在块头部里的那一段:`const v = useMemo(() => { … })` 这种值带语句块主体回调的块,回调主体是子块,值只翻到 `useMemo(() =>`,和块头部一致。 +### 注释的 Markdown 排版 + +- 用在:注释块(`#`、`//`、`/* */`、不挂在函数上的 JSDoc、Python 模块 / 类主体开头的 docstring)的预览,函数属性表的「说明」。视图数据里它们是 `{ markdown }`(见「通用契约」的 `Markdown`),webview 按数据决定怎么渲染,不猜。 +- 原文:翻译插件去掉注释符号(`#`、`//`、`/*` `*/` 和每行开头的 `*`)和引号,再去掉行尾空白、首尾空行和共同缩进,保留换行,不做别的转换。一个注释节点(块注释、docstring)的第一行紧跟在注释符号 / 引号后面,不参与算共同缩进(同 PEP 257);几条行注释连成的一块每行地位相同,第一行也算,缩进四格的代码块才留得住。 +- 渲染:webview 用 markdown-it(`html: false`、`linkify: true`),CommonMark 加 GFM 的表格、删除线:段落、强调、行内代码(含双反引号)、代码块(围栏、缩进四格)、列表、链接、标题、引用、表格。段内单个换行是软换行,空行分段。同一段文字只渲染一次(缓存最近 500 段)。 +- 安全:源码里的 HTML 当文字显示;链接只认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、相对路径)照原文显示。点链接由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。图片不渲染(webview 的 CSP 不放行外部图片),`![alt](url)` 显示成 `!` 加链接。 +- 样子:正文用界面字体(`--vscode-font-family`、`--vscode-font-size`)、comment 色、行高 20px,一行注释和一行译文一样高;行内代码用编辑器字体、`--vscode-textCodeBlock-background` 底;围栏代码块用编辑器字体和字号、同样的底,长行折行;段落、列表、代码块、表格之间 6px;标题只比正文稍大(h1 1.15em、h2 1.08em,其余同正文,加粗);链接 `--vscode-textLink-foreground`;引用左边 3px `--vscode-textBlockQuote-border` 竖线、`--vscode-textBlockQuote-background` 底;表格 1px 细线。 +- 「编辑」状态照旧是源码,编辑写回不变;Markdown 里的名字不能点,不参与跳到定义。 + ### 跳到定义 - 能点的:预览里(属性表的名称、类型、来源、导入项、默认值、值,以及一行译文)照抄自源码的名字——标识符、属性名、类型名、点分模块名的每一节(`luffy_agent.core.db` 三节各自能点,和 VS Code 一样)、字符串(TypeScript 的模块路径 `'./x'` 就是字符串)。翻出来的关键字、运算符、说明词不能点。 @@ -163,7 +173,7 @@ export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', export type FactBlock = FunctionBlock | ImportBlock | VariableBlock export type TextKind = Exclude -/** 预览成一行译文的块。 */ +/** 预览成一行译文的块(注释块是 Markdown 说明)。 */ export interface TextBlock extends BlockShape { kind: TextKind } export type CodeBlock = TextBlock | FactBlock @@ -183,6 +193,15 @@ export type TokenRole = */ export interface Segment { text: string, role: TokenRole, offset?: number } +/** + * 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,不做别的转换。 + * 预览按 Markdown 渲染成注释色的段落、列表、代码块(见「注释的 Markdown 排版」);第三方翻译插件给注释也填它。 + */ +export interface Markdown { markdown: string } + +/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数的说明)。 */ +export type PreviewText = Segment[] | Markdown + /** * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts)在源码里的范围—— * 参数名(连 `*` 前缀、整个解构模式)、导入项(名称到别名)、声明的变量名。跳到的定义落在里面就只闪这一行; @@ -193,8 +212,8 @@ export interface Defines { defines?: SourceRange } /** 属性表里展开的一个子行:几列对齐的格子。 */ export interface PropertyItem extends Defines { cells: Segment[][] } -/** 属性表的一行:属性名、着色的值、展开在下面的子行(输入参数、导入项)。 */ -export interface PropertyRow extends Defines { label: string, value: Segment[], items?: PropertyItem[] } +/** 属性表的一行:属性名、着色的值或 Markdown 说明(函数的「说明」)、展开在下面的子行(输入参数、导入项)。 */ +export interface PropertyRow extends Defines { label: string, value: PreviewText, items?: PropertyItem[] } /** 语言插件:语法树 → 代码块树。 */ export interface LanguageAdapter { @@ -213,7 +232,7 @@ export interface Translator { id: string // 'typescript.zh-template' label: string // '中文直译' languageIds: string[] - translate: (block: TextBlock, source: string) => Segment[] // 只翻 header 那一段 + translate: (block: TextBlock, source: string) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文 describe: (block: FactBlock, source: string) => PropertyRow[] // 函数、导入、常量、变量块 → 属性表 } @@ -288,7 +307,7 @@ export interface CodeReaderApi { 同一套表和节点模板也用来翻属性表里的值:引擎能翻任意一个节点(只取它落在块头部里的那一段),参数类型、默认值、返回类型、变量类型和初始值都走这条路。 -其他规则:空数组字面量 → 空数组(Python 空列表 → 空列表、空字典 → 空字典);复合语句头部去掉结尾的 `{` 或 `:`;多行合成一行(连续空白压成一个空格,右括号前的尾逗号去掉);注释去掉注释符号,原文照搬。关键字与前后内容之间保留源码里的空格(`!` 翻成「非」后补一个空格)。 +其他规则:空数组字面量 → 空数组(Python 空列表 → 空列表、空字典 → 空字典);复合语句头部去掉结尾的 `{` 或 `:`;多行合成一行(连续空白压成一个空格,右括号前的尾逗号去掉);注释块给去掉注释符号的 Markdown 原文(见「注释的 Markdown 排版」)。关键字与前后内容之间保留源码里的空格(`!` 翻成「非」后补一个空格)。 关键字按所在语法节点选择译法:同一个关键字在不同语法结构里意思不同时,表里写一个默认译法,再加若干「语境 → 译法」。语境是从关键字节点往上的节点类型链,外层在前:`raise_statement` 指父节点是 raise_statement,`for_statement > else_clause` 指父节点是 else_clause、祖父节点是 for_statement(关键字包在一层子句节点里时要看到祖父节点)。几条语境同时匹配时取最长的,都不匹配用默认译法。节点类型以 tree-sitter 语法为准。下面两张表里写成「(语境)译法 / (语境)译法」的关键字就是按这条规则区分的,排第一个的是默认译法。 @@ -326,7 +345,7 @@ Python 内置类型(仅注解里):str 字符串、int 整数、float 浮 | 源码 | 属性表 | | --- | --- | -| `export async function findEnvFiles(workspaceRoot: string): Promise {`(envLock.ts 18–23 行,连同前面的 JSDoc) | 名称 findEnvFiles / 说明 `Scan the workspace folder rooted at …`(三行,照原样换行)/ 访问级别 公共 / 异步 是 / 输入参数 1 个:`workspaceRoot \| 字符串 \|` / 输出参数 `Promise<字符串[]>` | +| `export async function findEnvFiles(workspaceRoot: string): Promise {`(envLock.ts 18–23 行,连同前面的 JSDoc) | 名称 findEnvFiles / 说明 `Scan the workspace folder rooted at …`(三行原文照留,按 Markdown 排成一段)/ 访问级别 公共 / 异步 是 / 输入参数 1 个:`workspaceRoot \| 字符串 \|` / 输出参数 `Promise<字符串[]>` | | `async function chmodAll(workspaceRoot: string, mode: number): Promise {` | 名称 chmodAll / 访问级别 私有 / 异步 是 / 输入参数 2 个:`workspaceRoot \| 字符串`、`mode \| 数值` / 输出参数 `Promise` | | `import { promises as fsp } from 'node:fs'` | 来源 `'node:fs'` / 仅类型 否 / 导入项 1 个:`promises 作为 fsp` | | `const ENV_PATTERN = '**/.env*'` | 名称 ENV_PATTERN / 访问级别 私有 / 值 `'**/.env*'` | @@ -352,8 +371,8 @@ interface BlockViewBase { children: BlockView[] } -/** 属性表块(有 facts)只带 properties,其余块只带 translation;没有翻译插件时是空数组。 */ -type BlockView = BlockViewBase & ({ translation: Segment[] } | { properties: PropertyRow[] }) +/** 属性表块(有 facts)只带 properties,其余块只带 translation(注释块是 `{ markdown }`);没有翻译插件时是空数组。 */ +type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] }) ``` - 扩展 → webview: @@ -408,7 +427,7 @@ vscode/src/codeReader/ editor/definitions.ts 查定义(vscode.executeDefinitionProvider)、几处定义的挑选框 editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数) index.ts registerCodeReader(context): CodeReaderApi -vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;属性表在 PropertyGrid.tsx) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;属性表在 PropertyGrid.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx) ``` ## 测试 @@ -419,6 +438,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、导入、常量、变量块只带属性表,其余块只带译文。 +- 注释的 Markdown:注释块、函数「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。 - 行替换:多行块替换后缩进正确。 - 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,函数名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量标的范围指回源码原文;块自己的名字只有函数和单变量声明有。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 diff --git a/vscode/pnpm-lock.yaml b/vscode/pnpm-lock.yaml index cab7074..dd595bc 100644 --- a/vscode/pnpm-lock.yaml +++ b/vscode/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: lucide-react: specifier: ^1.16.0 version: 1.16.0(react@19.2.6) + markdown-it: + specifier: ^15.0.2 + version: 15.0.2 react: specifier: ^19.0.0 version: 19.2.6 @@ -1515,6 +1518,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.2: + resolution: {integrity: sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ==} + args-tokenizer@0.3.0: resolution: {integrity: sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q==} @@ -1871,6 +1877,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -2576,6 +2586,9 @@ packages: linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkify-it@6.1.0: + resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==} + local-pkg@1.1.2: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} @@ -2646,6 +2659,10 @@ packages: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true + markdown-it@15.0.2: + resolution: {integrity: sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -2688,6 +2705,9 @@ packages: mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3416,6 +3436,9 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + uc.micro@3.0.0: + resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==} + ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -5012,6 +5035,8 @@ snapshots: argparse@2.0.1: {} + argparse@3.0.2: {} + args-tokenizer@0.3.0: {} assertion-error@2.0.1: {} @@ -5362,6 +5387,8 @@ snapshots: entities@4.5.0: {} + entities@8.1.0: {} + environment@1.1.0: {} es-module-lexer@1.7.0: {} @@ -6149,6 +6176,10 @@ snapshots: dependencies: uc.micro: 2.1.0 + linkify-it@6.1.0: + dependencies: + uc.micro: 3.0.0 + local-pkg@1.1.2: dependencies: mlly: 1.7.4 @@ -6216,6 +6247,15 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-it@15.0.2: + dependencies: + argparse: 3.0.2 + entities: 8.1.0 + linkify-it: 6.1.0 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 3.0.0 + markdown-table@3.0.4: {} mdast-util-find-and-replace@3.0.2: @@ -6333,6 +6373,8 @@ snapshots: mdurl@2.0.0: {} + mdurl@2.1.0: {} + merge2@1.4.1: {} micromark-core-commonmark@2.0.2: @@ -7202,6 +7244,8 @@ snapshots: uc.micro@2.1.0: {} + uc.micro@3.0.0: {} + ufo@1.6.1: {} uglify-js@3.19.3: diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index 5758350..60dd31c 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest' import { readBlockViews, toBlockViews } from './blockView' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' -import { propertyLines, readFixture, runtime, segmentText, WASM_DIR, withBlocks } from './testSupport' +import { previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport' import { pythonZhTranslator } from './translators/pythonZh' import { typescriptZhTranslator } from './translators/typescriptZh' @@ -38,7 +38,7 @@ const PROPERTY_KINDS = new Set(['function', 'import', 'const', 'variable']) function translationText(view: BlockView): string { if (!('translation' in view)) throw new Error(`块 ${view.id} 是属性表`) - return segmentText(view.translation) + return previewText(view.translation) } describe('blockView', () => { @@ -53,7 +53,7 @@ describe('blockView', () => { if ('properties' in view) expect(view.properties.length, view.id).toBeGreaterThan(0) else - expect(view.translation.length, view.id).toBeGreaterThan(0) + expect(previewText(view.translation), view.id).not.toBe('') } }) @@ -100,6 +100,13 @@ describe('blockView', () => { expect(getUser?.children[0]).toMatchObject({ kind: 'comment', startLine: 38, endLine: 38 }) }) + it('注释块的译文、函数的「说明」是 Markdown 原文,webview 按它渲染', async () => { + const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) + expect(byId(env, '0')).toMatchObject({ kind: 'comment', translation: { markdown: expect.stringMatching(/^\.env file lock \/ unlock helpers\.\n\nRecursively/) } }) + const findEnvFiles = byId(env, '5') + expect('properties' in findEnvFiles && findEnvFiles.properties[1]).toEqual({ label: '说明', value: { markdown: expect.stringMatching(/^Scan the workspace folder/) } }) + }) + it('id 是块在树里的位置,行号 0 起,多行块去掉共同缩进', async () => { const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) expect(byId(env, '5.1.0')).toMatchObject({ kind: 'return', startLine: 25, endLine: 25, code: 'return []' }) diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 502cb26..e72107c 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,4 +1,4 @@ -import type { BlockKind, CodeBlock, LanguageAdapter, Node, PropertyRow, Segment, SourceRange, TokenRole, Translator } from './contract' +import type { BlockKind, CodeBlock, LanguageAdapter, Node, PreviewText, PropertyRow, Segment, SourceRange, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import { commonIndent, splitLines } from './editText' @@ -18,8 +18,8 @@ interface BlockViewBase { children: BlockView[] } -/** 发给 webview 的一块(可序列化):函数、导入、常量、变量块预览时是属性表,其余是一行译文。 */ -export type BlockView = BlockViewBase & ({ translation: Segment[] } | { properties: PropertyRow[] }) +/** 发给 webview 的一块(可序列化):函数、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 */ +export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] }) interface Line { start: number, end: number } diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 3652ea3..8b1c412 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -110,6 +110,15 @@ export type TokenRole */ export interface Segment { text: string, role: TokenRole, offset?: number } +/** + * 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,不做别的转换 + * (reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄)。预览按 Markdown 渲染成注释色的段落、列表、代码块,源码里的 HTML 当文字显示。 + */ +export interface Markdown { markdown: string } + +/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数的说明)。 */ +export type PreviewText = Segment[] | Markdown + /** * 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts,例如参数名、导入项的名称和别名、 * 声明的变量名)在源码里的范围。跳到定义时,定义处落在这个范围里就只闪这一行;不填(第三方翻译插件可以不填)就闪整块。 @@ -119,10 +128,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[] } @@ -143,8 +152,8 @@ export interface Translator { id: string // 'typescript.zh-template' label: string // '中文直译' languageIds: string[] - /** 只翻 header 那一段。 */ - translate: (block: TextBlock, source: string) => Segment[] + /** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */ + translate: (block: TextBlock, source: string) => PreviewText /** 函数、导入、常量、变量块 → 属性表。 */ describe: (block: FactBlock, source: string) => PropertyRow[] } diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index 746e672..1fa315a 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -1,4 +1,4 @@ -import type { BlockKind, CodeBlock, LanguageAdapter, PropertyRow, Segment } from './contract' +import type { BlockKind, CodeBlock, LanguageAdapter, PreviewText, PropertyRow, Segment } from './contract' import { readFileSync } from 'node:fs' import * as path from 'node:path' import { Node } from '@vscode/tree-sitter-wasm' @@ -49,10 +49,15 @@ export function segmentText(segments: Segment[]): string { return segments.map(segment => segment.text).join('') } +/** 预览文字写成文字:着色的词拼起来,Markdown 取原文。 */ +export function previewText(text: PreviewText): string { + return 'markdown' in text ? text.markdown : segmentText(text) +} + /** 属性表写成文字:每行 `名称: 值`,展开的子行缩进两格、各列用 ` | ` 隔开。 */ export function propertyLines(rows: PropertyRow[]): string[] { return rows.flatMap(row => [ - `${row.label}: ${segmentText(row.value)}`, + `${row.label}: ${previewText(row.value)}`, ...(row.items ?? []).map(item => ` ${item.cells.map(segmentText).join(' | ')}`), ]) } diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index cf8a5c7..16e6f13 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -11,16 +11,21 @@ import { createZhTemplateTranslator } from './zhTemplate' const ts = createTypeScriptAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR) -interface Translated { kind: string, segments: Segment[] } +/** 一块的译文:一行译文在 segments 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */ +interface Translated { kind: string, segments: Segment[], markdown?: string } -/** 一行译文块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 */ +/** 一行译文块、注释块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 */ function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise> { return withBlocks(adapter, languageId, source, (blocks) => { const byLine = new Map() walk(blocks, (block) => { const line = block.header.startRow + 1 - if (!byLine.has(line) && !('facts' in block)) - byLine.set(line, { kind: block.kind, segments: translator.translate(block, source) }) + if (byLine.has(line) || 'facts' in block) + return + const translation = translator.translate(block, source) + byLine.set(line, 'markdown' in translation + ? { kind: block.kind, segments: [], markdown: translation.markdown } + : { kind: block.kind, segments: translation }) }) return byLine }) @@ -44,7 +49,10 @@ function rowTexts(map: Map): Record { } function valueOf(rows: PropertyRow[] | undefined, label: string): Segment[] { - return rows?.find(row => row.label === label)?.value ?? [] + const value = rows?.find(row => row.label === label)?.value ?? [] + if ('markdown' in value) + throw new Error(`「${label}」是 Markdown`) + return value } function text(segments: Segment[] | undefined): string { @@ -52,7 +60,7 @@ function text(segments: Segment[] | undefined): string { } function texts(map: Map): Record { - return Object.fromEntries([...map].map(([line, { segments }]) => [line, text(segments)])) + return Object.fromEntries([...map].map(([line, { segments, markdown }]) => [line, markdown ?? text(segments)])) } /** 只看文字和着色:去掉各段的源码下标、属性行讲的定义(这两样另有用例)。 */ @@ -93,14 +101,23 @@ describe('typeScript 中文直译', () => { expect(text(issue.get(20)?.segments)).toBe('返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }') }) - it('其余块:结构照抄,复合语句去掉结尾的 {,注释去掉注释符号', async () => { + it('其余块:结构照抄,复合语句去掉结尾的 {,注释去掉注释符号、保留换行', async () => { const env = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))) const issue = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))) expect(env[32]).toBe('公共 接口 EnvLockResult') expect(env[42]).toBe('尝试') expect(env[46]).toBe('捕获 (err)') expect(env[47]).toBe('result.failed.push({ path: f, reason: 如果 err 属于 Error 则 err.message 否则 String(err) })') - expect(env[1]).toBe('.env file lock / unlock helpers. Recursively scans the main workspace folder for `.env*` files (skipping `node_modules`, `.git`, `.claude`) and chmods them to 0o444 (locked) or 0o644 (unlocked) in bulk. Backs the toolbar Lock toggle in `PanelHeader`. No persistent watcher — re-scan happens on every toggle, so newly created `.env` files are only picked up the next time the user clicks the button.') + expect(env[1]).toBe([ + '.env file lock / unlock helpers.', + '', + 'Recursively scans the main workspace folder for `.env*` files (skipping', + '`node_modules`, `.git`, `.claude`) and chmods them to 0o444 (locked) or', + '0o644 (unlocked) in bulk. Backs the toolbar Lock toggle in `PanelHeader`.', + '', + 'No persistent watcher — re-scan happens on every toggle, so newly created', + '`.env` files are only picked up the next time the user clicks the button.', + ].join('\n')) expect(issue[5]).toBe('prerequisiteNumber?: 数值') expect(issue[15]).toBe('如果 (非 issue 或者 issue.prerequisite 全等 未定义)') }) @@ -382,7 +399,7 @@ describe('属性表', () => { const rows = colors(env.get(18) ?? []) expect(rows.map(row => row.label)).toEqual(['名称', '说明', '访问级别', '异步', '输入参数', '输出参数']) expect(rows[0].value).toEqual([{ text: 'findEnvFiles', role: 'function' }]) - expect(rows[1].value).toEqual([{ text: valueOf(env.get(18), '说明')[0].text, role: 'comment' }]) + expect(rows[1].value).toEqual({ markdown: expect.stringMatching(/^Scan the workspace folder/) }) expect(rows[2].value).toEqual([{ text: '公共', role: 'keyword' }]) expect(rows[3].value).toEqual([{ text: '是', role: 'storage' }]) expect(rows[4].value).toEqual([{ text: '1 个', role: 'hint' }]) @@ -447,6 +464,57 @@ describe('属性表', () => { expect(pyRows[8].slice(0, 2)).toEqual(['名称: g', '访问级别: 公共']) }) + it('注释块和函数的说明是 Markdown 原文:只去注释符号、引号和共同缩进,reST 角色、双反引号、JSDoc 标签、HTML 照抄', async () => { + const pySource = [ + '"""手机号规范化 / 脱敏工具.', + '', + '凡是入库、比对的手机号都先过 :func:`normalize_phone`,日志里打印 ``user`` 时用 mask。', + '"""', + '# 用法:', + '#', + '# mask_phone("13800138000")', + 'def mask_phone(phone: str) -> str:', + ' """把中间四位换成 ``*``.', + '', + ' - 不足 7 位原样返回', + ' - 见 https://example.com/phone', + ' """', + ].join('\n') + const pyText = await translateAll(py, 'python', pythonZhTranslator, pySource) + expect(pyText.get(1)).toEqual({ + kind: 'comment', + segments: [], + markdown: '手机号规范化 / 脱敏工具.\n\n凡是入库、比对的手机号都先过 :func:`normalize_phone`,日志里打印 ``user`` 时用 mask。', + }) + expect(pyText.get(5)?.markdown).toBe('用法:\n\n mask_phone("13800138000")') + const pyRows = await describeAll(py, 'python', pythonZhTranslator, pySource) + expect(pyRows.get(8)?.find(row => row.label === '说明')?.value).toEqual({ + markdown: '把中间四位换成 ``*``.\n\n- 不足 7 位原样返回\n- 见 https://example.com/phone', + }) + + const tsSource = [ + '// 第一行', + '// - 缩进的列表', + '/* 单行块注释 */', + '/**', + ' * 读配置,见 {@link load}。', + ' *', + ' * ```ts', + ' * load(\'a\')', + ' * ```', + ' * @param path 文件路径', + ' */', + 'function load(path: string) {}', + ].join('\n') + const tsText = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, tsSource)) + expect(tsText[1]).toBe('第一行\n - 缩进的列表') + expect(tsText[3]).toBe('单行块注释') + const tsRows = await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource) + expect(tsRows.get(4)?.find(row => row.label === '说明')?.value).toEqual({ + markdown: '读配置,见 {@link load}。\n\n```ts\nload(\'a\')\n```\n@param path 文件路径', + }) + }) + it('导入:默认导出、全部、整个模块、副作用导入;Python 没有「仅类型」', async () => { const tsSource = [ 'import d, { b as c } from \'y\'', @@ -517,7 +585,7 @@ describe('源码下标(跳到定义用)', () => { } function propertySegments(rows: PropertyRow[]): Segment[] { - return rows.flatMap(row => [...row.value, ...(row.items ?? []).flatMap(item => item.cells.flat())]) + return rows.flatMap(row => [...('markdown' in row.value ? [] : row.value), ...(row.items ?? []).flatMap(item => item.cells.flat())]) } it('python:点分模块名每一节、导入项、别名各自带下标,点号不带;前面有中文注释也对得上', async () => { diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index a5d0d43..07272e1 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, Defines, FactBlock, FunctionFacts, ImportFacts, ImportItem, Node, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, Defines, FactBlock, FunctionFacts, ImportFacts, ImportItem, Markdown, Node, PreviewText, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -102,13 +102,15 @@ function oneLine(text: string): string { } /** - * 说明文字排版:去掉行尾空白、首尾空行和共同缩进,保留换行(段落之间的空行也留着)。 - * 第一行紧跟在引号 / 注释符号后面,本来就没有缩进,不参与算共同缩进(同 PEP 257 的做法)。 + * 注释的 Markdown 原文:各个注释节点去掉注释符号后(texts)拼起来,去掉行尾空白、首尾空行和共同缩进,保留换行(段落之间的空行也留着)。 + * 只有一个节点(块注释、docstring)时第一行紧跟在引号 / 注释符号后面,本来就没有缩进,不参与算共同缩进(同 PEP 257 的做法); + * 几条行注释连成的一块每行地位相同,第一行也算,缩进四格的代码块才留得住。 */ -function docText(raw: string): string { - const [first = '', ...rest] = splitLines(raw).map(line => line.trimEnd()) - const indent = commonIndent(rest).length - return [first.trim(), ...rest.map(line => line.slice(indent))].join('\n').replace(/^\n+|\n+$/g, '') +function docText(texts: string[]): string { + const lines = splitLines(texts.join('\n')).map(line => line.trimEnd()) + const quoted = texts.length === 1 + const indent = commonIndent(quoted ? lines.slice(1) : lines).length + return lines.map((line, i) => quoted && i === 0 ? line.trim() : line.slice(indent)).join('\n').replace(/^\n+|\n+$/g, '') } /** 模板槽位引用的子节点;注释是语法之外的节点,不占具名子节点序号。 */ @@ -261,15 +263,14 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return segments } - function translateComment(block: TextBlock): Segment[] { + function translateComment(block: TextBlock): Markdown { const { startIndex, endIndex } = block.header const root = block.node.tree.rootNode const nodes = block.node.type === 'comment' ? commentsWithin(root.descendantForIndex(startIndex, endIndex) ?? root, block.header) : [block.node] - const text = nodes.map(table.commentText).join('\n').split('\n').map(line => line.trim()).filter(Boolean).join(' ') - return text ? [{ text, role: 'comment' }] : [] + return { markdown: docText(nodes.map(table.commentText)) } } - function translate(block: TextBlock, source: string): Segment[] { + function translate(block: TextBlock, source: string): PreviewText { if (block.kind === 'comment') return translateComment(block) const tokens = tokensIn(block.node, block.header, source) @@ -299,8 +300,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node))] : verbatim(node, source, 'type'), name: (node, role) => [segment(node.text, role, offsetOf(node))], doc: (node) => { - const text = docText(table.commentText(node)) - return text ? [{ text, role: 'comment' }] : [] + const markdown = docText([table.commentText(node)]) + return markdown ? { markdown } : undefined }, } switch (block.kind) { @@ -362,8 +363,8 @@ interface Phrases { module: (node: Node) => Segment[] /** 源码里的一个名字原样照抄、按 role 着色(函数名称)。 */ name: (node: Node, role: TokenRole) => Segment[] - /** 函数的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进,保留换行,按注释着色;没有文字时是空的。 */ - doc: (node: Node) => Segment[] + /** 函数的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进的 Markdown 原文;没有文字时没有。 */ + doc: (node: Node) => Markdown | undefined } const ACCESS_ZH: Record = { public: '公共', private: '私有', protected: '受保护' } @@ -416,10 +417,10 @@ function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segmen /** 函数:名称 / 访问级别 / 异步 / 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] { const decorators = facts.decorators.map(say.verbatim) - const doc = facts.doc ? say.doc(facts.doc) : [] + const doc = facts.doc && say.doc(facts.doc) return [ { label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] }, - ...(doc.length > 0 ? [{ label: '说明', value: doc }] : []), + ...(doc ? [{ label: '说明', value: doc }] : []), ...accessRows(facts.access), { label: '异步', value: yesNo(facts.async) }, ...(decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators, '\n') }] : []), diff --git a/vscode/webview-ui/package.json b/vscode/webview-ui/package.json index 92db1db..922ab64 100644 --- a/vscode/webview-ui/package.json +++ b/vscode/webview-ui/package.json @@ -18,6 +18,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.16.0", + "markdown-it": "^15.0.2", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-merge": "^3.6.0" diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index b800655..e7d80ad 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -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 = { 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 - return ( -

- -

- ) + return } /** 一块:折叠箭头、种类标签、预览内容或源码、右边的「编辑 / 预览」切换。 */ diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index 1b39e3e..1a9cfe6 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -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 }: { ) : {row.label}} -

- -

+ {items.length > 0 && !folded && } diff --git a/vscode/webview-ui/src/codeReader/TextPreview.tsx b/vscode/webview-ui/src/codeReader/TextPreview.tsx new file mode 100644 index 0000000..7f7557a --- /dev/null +++ b/vscode/webview-ui/src/codeReader/TextPreview.tsx @@ -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
+ return ( +

+ +

+ ) +} diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 97a0153..ecbd897 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -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); diff --git a/vscode/webview-ui/src/codeReader/markdown.test.ts b/vscode/webview-ui/src/codeReader/markdown.test.ts new file mode 100644 index 0000000..1788192 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/markdown.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { renderMarkdown } from './markdown' + +describe('renderMarkdown', () => { + it('一行文字是一个段落;段内换行是软换行,空行分段', () => { + expect(renderMarkdown('读配置。')).toBe('

读配置。

\n') + expect(renderMarkdown('第一行\n接着写\n\n第二段')).toBe('

第一行\n接着写

\n

第二段

\n') + }) + + it('行内代码(含双反引号)、强调、列表、代码块、标题、引用、表格', () => { + expect(renderMarkdown('先过 :func:`normalize_phone`,打印 ``user`` 时 **脱敏**')).toBe( + '

先过 :func:normalize_phone,打印 user 时 脱敏

\n', + ) + expect(renderMarkdown('- 甲\n- 乙')).toBe('
    \n
  • 甲
  • \n
  • 乙
  • \n
\n') + expect(renderMarkdown('```ts\nload(\'a\')\n```')).toBe('
load(\'a\')\n
\n') + expect(renderMarkdown('例子:\n\n f(1)')).toBe('

例子:

\n
f(1)\n
\n') + expect(renderMarkdown('# 标题')).toBe('

标题

\n') + expect(renderMarkdown('> 引用')).toBe('
\n

引用

\n
\n') + expect(renderMarkdown('| a | b |\n| - | - |\n| 1 | 2 |')).toContain('') + }) + + it('源码里的 HTML 当文字显示', () => { + expect(renderMarkdown('用 mask')).toBe( + '

用 <b>mask</b> <script>alert(1)</script>

\n', + ) + expect(renderMarkdown('
块
')).toBe('

<div onclick="x()">块</div>

\n') + }) + + it('带协议的网址、邮箱自动成链接;不带协议的文件名不算', () => { + expect(renderMarkdown('见 https://example.com/phone')).toBe('

见 https://example.com/phone

\n') + expect(renderMarkdown('[文档](http://example.com)')).toBe('

文档

\n') + expect(renderMarkdown('找 dev@example.com')).toContain('') + expect(renderMarkdown('改 sample.py 和 README.md')).toBe('

改 sample.py 和 README.md

\n') + }) + + it('链接只认 http / https / mailto,别的照原文显示;图片不渲染', () => { + expect(renderMarkdown('[点](javascript:alert(1))')).not.toContain(' 的点击交给 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() + +/** 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 +} diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index a20f38b..d15e2e0 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -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'] }) diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index 300a74a..b36a43e 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -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) } /** diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 4dd8524..7363c19 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -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 }