✨ feat(vscode): 代码阅读标出项目写明的入口文件和入口函数

This commit is contained in:
cruldra committed 2026-10-08 07:17:25 +08:00
1 parent 78d5748b81
commit 718bbb7f6c
19 files changed
+761 -83

No files matched your search

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { escapeMarkdown, rowDecoration } from './rowDecoration'
describe('树上的一行', () => {
it('没有注释、不是入口的行保持原样', () => {
expect(rowDecoration('file', 'src/a.ts', undefined, undefined)).toBeUndefined()
})
it('有注释:名字后面灰字是注释,contextValue 加 noted,提示里粗体路径下一行注释全文', () => {
expect(rowDecoration('file', 'campaign/order_bonus.py', '下单返利:订单完成后按活动规则发积分', undefined)).toEqual({
description: '下单返利:订单完成后按活动规则发积分',
tooltip: '**campaign\\/order\\_bonus\\.py** \n下单返利:订单完成后按活动规则发积分',
contextValue: 'file noted',
})
expect(rowDecoration('folder', 'campaign', '营销活动', undefined)?.contextValue).toBe('folder noted')
})
it('入口、没注释:灰字是「入口」,提示里写从哪个名字开始读,contextValue 不变', () => {
expect(rowDecoration('file', 'src/agent_cli/main.py', undefined, ['app', 'dev'])).toEqual({
description: '入口',
tooltip: '**src\\/agent\\_cli\\/main\\.py** \n入口,从 `app`、`dev` 开始读',
contextValue: 'file',
})
expect(rowDecoration('file', 'bin/cli.js', undefined, [])?.tooltip).toBe('**bin\\/cli\\.js** \n入口')
})
it('入口、有注释:灰字是「注释 · 入口」,注释不被盖掉,提示里注释下面再写入口', () => {
expect(rowDecoration('file', 'src/main.rs', '服务启动', ['main'])).toEqual({
description: '服务启动 · 入口',
tooltip: '**src\\/main\\.rs** \n服务启动 \n入口,从 `main` 开始读',
contextValue: 'file noted',
})
})
it('注释里的 Markdown 符号原样显示', () => {
expect(escapeMarkdown('**别加粗** <b>x</b> [链接](command:x) `代码` # 标题 $(icon)'))
.toBe('\\*\\*别加粗\\*\\* \\<b\\>x\\<\\/b\\> \\[链接\\]\\(command\\:x\\) \\`代码\\` \\# 标题 \\$\\(icon\\)')
})
})
@@ -0,0 +1,36 @@
/** 侧栏一行名字后面的灰字和悬停提示:文件注释、项目入口。 */
export interface RowDecoration {
/** 名字后面的灰字:注释,是入口时后面接「 · 入口」,没注释时就是「入口」;VS Code 放不下时自己截断 */
description: string
/** 悬停提示(Markdown):第一行粗体的相对路径,下面是注释全文,是入口时再一行写明 */
tooltip: string
/** 有注释时末尾加上 ` noted`:菜单的 when 子句靠它在「添加注释」和「编辑注释」之间换;是不是入口不影响 */
contextValue: string
}
/** 每个 ASCII 标点前加反斜杠(CommonMark 允许任何 ASCII 标点这样转义),注释里的 `*`、`_`、`<`、`[` 等原样显示。 */
export function escapeMarkdown(text: string): string {
return text.replace(/[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]/g, '\\$&')
}
/**
* @param contextValue 这一行原本的 contextValue(`file` / `folder`)
* @param key 相对工作区文件夹的路径
* @param note 文件注释
* @param entry 是项目写明的入口时,预览里要标的名字(package.json 的 `bin` 没有名字,是空的);不是入口时 undefined
* @returns 没有注释、也不是入口时 undefined,这一行保持原样
*/
export function rowDecoration(contextValue: string, key: string, note: string | undefined, entry: readonly string[] | undefined): RowDecoration | undefined {
if (note === undefined && entry === undefined)
return undefined
const lines = [`**${escapeMarkdown(key)}**`]
if (note !== undefined)
lines.push(escapeMarkdown(note))
if (entry !== undefined)
lines.push(entry.length > 0 ? `入口,从 ${entry.map(name => `\`${name}\``).join('、')} 开始读` : '入口')
return {
description: note === undefined ? '入口' : entry === undefined ? note : `${note} · 入口`,
tooltip: lines.join(' \n'),
contextValue: note === undefined ? contextValue : `${contextValue} noted`,
}
}
@@ -0,0 +1,40 @@
import type { Event, TreeItem, Uri } from 'vscode'
import type { FileNode, FileTreeDecorator } from './fileTree'
import { Disposable, MarkdownString } from 'vscode'
import { locate } from '../folderStore'
import { rowDecoration } from './rowDecoration'
/** 各行的文件注释(见 notes/)。 */
export interface RowNotes {
readonly onDidChange: Event<readonly Uri[]>
noteOf: (node: FileNode) => string | undefined
}
/** 哪些文件是项目写明的入口(见 entries/)。 */
export interface RowEntries {
readonly onDidChange: Event<readonly Uri[]>
namesOf: (uri: Uri) => readonly string[] | undefined
}
/** 侧栏一行名字后面的灰字和悬停提示:文件注释,后面接项目入口(见 rowDecoration);工作区外面的行(审查的工作副本)不加。 */
export class RowDecorator implements FileTreeDecorator {
constructor(private readonly notes: RowNotes, private readonly entries: RowEntries) {}
/** 注释变了、入口变了的那些行 */
readonly onDidChange: Event<readonly Uri[]> = (listener, thisArgs, disposables) => {
const both = Disposable.from(this.notes.onDidChange(listener, thisArgs), this.entries.onDidChange(listener, thisArgs))
disposables?.push(both)
return both
}
decorate(item: TreeItem, node: FileNode): void {
const at = locate(node.uri)
const entry = node.isDirectory ? undefined : this.entries.namesOf(node.uri)
const decoration = at && rowDecoration(item.contextValue ?? '', at.key, this.notes.noteOf(node), entry)
if (!decoration)
return
item.description = decoration.description
item.tooltip = new MarkdownString(decoration.tooltip)
item.contextValue = decoration.contextValue
}
}
@@ -1,10 +1,10 @@
import { readFileSync } from 'node:fs'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { noteDecoration } from '../notes/noteItem'
import { UNSTAGED_MARKER } from '../uncommitted/uncommittedFiles'
import { COPY_COMMANDS } from './copyText'
import { entryContext, GROUP_CONTEXT } from './fileEntries'
import { rowDecoration } from './rowDecoration'
interface MenuItem { command?: string, submenu?: string, when: string, group?: string }
interface Manifest {
@@ -28,7 +28,7 @@ const root = entryContext({ isDirectory: true, isRoot: true })
const unstaged = entryContext({ isDirectory: false, isRoot: false }, UNSTAGED_MARKER)
const STAGE = 'superpowers.codeReader.stage'
const ASK = 'superpowers.codeReader.askPath'
const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue
const noted = (context: string): string => rowDecoration(context, 'x', '注释', undefined)!.contextValue
const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0)