✨ feat(vscode): 注释卡片可按规范重写注释
This commit is contained in:
1 parent
724c81188c
commit
299acb979f
11 files changed
+717
-5
No files matched your search
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { commentProse, commentRewritePrompt, commentSource, isModuleComment, loadCommentSkill, modelCommentBody, refillComment, skillBody } from './commentRewrite'
|
||||
import { commonIndent, replaceLines } from './editText'
|
||||
|
||||
/** 和预览写回同一条路:正文套回壳,再按原来的缩进替换这几行。 */
|
||||
function rewrite(file: string, start: number, end: number, body: string): string {
|
||||
const lines = file.split('\n').slice(start, end + 1)
|
||||
const filled = refillComment(commentSource(lines), body)
|
||||
expect(filled, '认得出定界符').toBeTruthy()
|
||||
return replaceLines(file, start, end, filled ?? '')
|
||||
}
|
||||
|
||||
describe('重写注释后套回原来的壳', () => {
|
||||
it('三引号模块说明:定界符还在,正文换了,文件别的部分不动', () => {
|
||||
const file = [
|
||||
'"""旧的模块说明。',
|
||||
'',
|
||||
'只做一件事。',
|
||||
'"""',
|
||||
'',
|
||||
'def keep():',
|
||||
' return 1',
|
||||
'',
|
||||
].join('\n')
|
||||
expect(rewrite(file, 0, 3, '新的模块说明。\n\n换成了。')).toBe([
|
||||
'"""新的模块说明。',
|
||||
'',
|
||||
'换成了。',
|
||||
'"""',
|
||||
'',
|
||||
'def keep():',
|
||||
' return 1',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('行注释 #:缩进和井号留着,只换这几行', () => {
|
||||
const file = [
|
||||
'def keep():',
|
||||
' return 0',
|
||||
'',
|
||||
' # 旧说明',
|
||||
' # 第二行',
|
||||
' return 1',
|
||||
'',
|
||||
].join('\n')
|
||||
const next = rewrite(file, 3, 4, '新说明')
|
||||
expect(next).toBe([
|
||||
'def keep():',
|
||||
' return 0',
|
||||
'',
|
||||
' # 新说明',
|
||||
' return 1',
|
||||
'',
|
||||
].join('\n'))
|
||||
expect(commonIndent([' # 新说明'])).toBe(' ')
|
||||
})
|
||||
|
||||
it('块注释 /** */:星号和收尾留着,前后的代码不动', () => {
|
||||
const file = [
|
||||
'export const KEEP = 1',
|
||||
'',
|
||||
'/**',
|
||||
' * 旧说明',
|
||||
' * 还有一行',
|
||||
' */',
|
||||
'export function stay(): number {',
|
||||
' return KEEP',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
expect(rewrite(file, 2, 5, '新说明')).toBe([
|
||||
'export const KEEP = 1',
|
||||
'',
|
||||
'/**',
|
||||
' * 新说明',
|
||||
' */',
|
||||
'export function stay(): number {',
|
||||
' return KEEP',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('双斜杠行注释同样只换正文', () => {
|
||||
const file = [
|
||||
'function keep() {',
|
||||
' // 旧说明',
|
||||
' return 1',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
expect(rewrite(file, 1, 1, '新说明')).toBe([
|
||||
'function keep() {',
|
||||
' // 新说明',
|
||||
' return 1',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('认不出定界符、正文是空的,都不给出替换', () => {
|
||||
expect(refillComment('const x = 1', '新')).toBeUndefined()
|
||||
expect(refillComment('# 旧', ' ')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('给模型的正文不带定界符', () => {
|
||||
expect(commentProse('"""旧说明"""')).toBe('旧说明')
|
||||
expect(commentProse('# 旧说明\n# 第二行')).toBe('旧说明\n第二行')
|
||||
expect(commentProse('/**\n * 旧说明\n */')).toBe('旧说明')
|
||||
expect(commentProse('// 旧说明')).toBe('旧说明')
|
||||
})
|
||||
})
|
||||
|
||||
describe('重写注释的提示', () => {
|
||||
it('文件顶上的三引号:上下文是去掉这段之后的文件,太长会截掉并说明', () => {
|
||||
const rest = Array.from({ length: 130 }, (_, index) => `line ${index}`).join('\n')
|
||||
const prompt = commentRewritePrompt({
|
||||
skill: '技能正文',
|
||||
comment: '"""旧"""',
|
||||
source: `"""旧"""\n${rest}`,
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
earlierKinds: [],
|
||||
})
|
||||
expect(prompt).toContain('技能正文')
|
||||
expect(prompt).toContain('只按上面的技能重写这一段注释')
|
||||
expect(prompt).toContain('这段注释现在的正文:\n旧')
|
||||
expect(prompt).not.toContain('"""')
|
||||
expect(prompt).toContain('截断了')
|
||||
expect(prompt).toContain('line 0')
|
||||
expect(prompt).not.toContain('line 129')
|
||||
})
|
||||
|
||||
it('普通注释只带后面紧挨着的那一块,不带整份文件', () => {
|
||||
const prompt = commentRewritePrompt({
|
||||
skill: '技能正文',
|
||||
comment: '# 旧',
|
||||
source: '整份文件不该出现',
|
||||
startLine: 4,
|
||||
endLine: 4,
|
||||
earlierKinds: ['function'],
|
||||
following: 'def stay():\n return 1',
|
||||
})
|
||||
expect(prompt).toContain('def stay():')
|
||||
expect(prompt).not.toContain('整份文件不该出现')
|
||||
expect(isModuleComment('# 旧', [])).toBe(false)
|
||||
expect(isModuleComment('"""旧"""', [])).toBe(true)
|
||||
expect(isModuleComment('"""旧"""', ['function'])).toBe(false)
|
||||
})
|
||||
|
||||
it('技能按给出的顺序找,用到第一份;都没有就是没有', async () => {
|
||||
const read = async (path: string): Promise<string> => {
|
||||
if (path.includes('/marketplaces/ecc/'))
|
||||
throw Object.assign(new Error('no'), { code: 'ENOENT' })
|
||||
return '---\nname: writing-code-comments\n---\n规范正文\n'
|
||||
}
|
||||
expect(await loadCommentSkill(read)).toBe('规范正文')
|
||||
expect(await loadCommentSkill(async () => {
|
||||
throw Object.assign(new Error('no'), { code: 'ENOENT' })
|
||||
})).toBeUndefined()
|
||||
expect(skillBody('没有头的正文')).toBe('没有头的正文')
|
||||
})
|
||||
|
||||
it('模型套了代码围栏时只留里面的正文', () => {
|
||||
expect(modelCommentBody('```\n新说明\n```')).toBe('新说明')
|
||||
expect(modelCommentBody(' ')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 按注释规范重写一段注释:模型只给新正文,这里套回原来的定界符和缩进。
|
||||
* 认 Python 的三引号、井号行注释,以及块注释和双斜杠行注释。认不出就不改。
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { commonIndent, splitLines } from './editText'
|
||||
|
||||
/** 贴给模型的代码最多这么多行,再长就截断并在提示里说明。 */
|
||||
export const COMMENT_CONTEXT_LINES = 120
|
||||
|
||||
/** 运行时按这个顺序找技能,用到的第一份。不把技能正文放进仓库。 */
|
||||
export const COMMENT_SKILL_PATHS = [
|
||||
join(homedir(), '.claude/plugins/marketplaces/ecc/skills/writing-code-comments/SKILL.md'),
|
||||
join(homedir(), '.claude/writing-code-comments/SKILL.md'),
|
||||
] as const
|
||||
|
||||
const DOUBLE_DOC = '"""'
|
||||
const SINGLE_DOC = '\'' + '\'' + '\''
|
||||
/** 写在三引号前面的字符串前缀,长的排前面,免得 `r` 吃掉 `rf`。 */
|
||||
const DOC_PREFIXES = ['rf', 'fr', 'rb', 'br', 'r', 'b', 'u', 'f']
|
||||
|
||||
/** 一块注释占的几行,去掉共同缩进。和块编辑框里的字一样。 */
|
||||
export function commentSource(lines: readonly string[]): string {
|
||||
const width = commonIndent([...lines]).length
|
||||
return lines.map(line => line.slice(Math.min(width, line.length))).join('\n')
|
||||
}
|
||||
|
||||
/** 去掉技能文件开头的 frontmatter,剩下的才是给模型的规范。 */
|
||||
export function skillBody(markdown: string): string {
|
||||
const text = markdown.charCodeAt(0) === 0xFEFF ? markdown.slice(1) : markdown
|
||||
const fence = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/)
|
||||
return (fence ? text.slice(fence[0].length) : text).trim()
|
||||
}
|
||||
|
||||
function missingFile(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/** 读到的第一份技能正文;两处都没有就是 undefined。读失败(不是没有文件)照样抛。 */
|
||||
export async function loadCommentSkill(read: (path: string) => Promise<string> = path => readFile(path, 'utf8')): Promise<string | undefined> {
|
||||
for (const path of COMMENT_SKILL_PATHS) {
|
||||
try {
|
||||
return skillBody(await read(path))
|
||||
}
|
||||
catch (err) {
|
||||
if (!missingFile(err))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface DocShell {
|
||||
prefix: string
|
||||
quote: string
|
||||
/** 引号里面的原文,含换行 */
|
||||
inner: string
|
||||
}
|
||||
|
||||
function docShell(comment: string): DocShell | undefined {
|
||||
const trimmed = comment.trimEnd()
|
||||
const lower = trimmed.toLowerCase()
|
||||
let prefix = ''
|
||||
for (const candidate of DOC_PREFIXES) {
|
||||
if (!lower.startsWith(candidate))
|
||||
continue
|
||||
const after = trimmed.slice(candidate.length)
|
||||
if (after.startsWith(DOUBLE_DOC) || after.startsWith(SINGLE_DOC)) {
|
||||
prefix = trimmed.slice(0, candidate.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
const rest = trimmed.slice(prefix.length)
|
||||
const quote = rest.startsWith(DOUBLE_DOC) ? DOUBLE_DOC : rest.startsWith(SINGLE_DOC) ? SINGLE_DOC : undefined
|
||||
if (!quote || rest.length < quote.length * 2 || !rest.endsWith(quote))
|
||||
return undefined
|
||||
return { prefix, quote, inner: rest.slice(quote.length, -quote.length) }
|
||||
}
|
||||
|
||||
/** 三引号里的正文:去掉包着正文的那一层换行,再去掉各行共同多出来的缩进。 */
|
||||
function docProse(shell: DocShell): string {
|
||||
let inner = shell.inner
|
||||
if (inner.startsWith('\n'))
|
||||
inner = inner.slice(1)
|
||||
if (inner.endsWith('\n'))
|
||||
inner = inner.slice(0, -1)
|
||||
const lines = inner.split('\n')
|
||||
const extra = commonIndent(lines)
|
||||
return lines.map(line => line.startsWith(extra) ? line.slice(extra.length) : line).join('\n').trim()
|
||||
}
|
||||
|
||||
/** 收尾那一行、引号前面的空白。正文和引号写在同一行时没有。 */
|
||||
function closerIndent(comment: string, quote: string): string {
|
||||
const nl = comment.lastIndexOf('\n')
|
||||
if (nl < 0)
|
||||
return ''
|
||||
const last = comment.slice(nl + 1)
|
||||
const at = last.lastIndexOf(quote)
|
||||
if (at < 0 || last.slice(at + quote.length).trim() !== '')
|
||||
return ''
|
||||
return last.slice(0, at)
|
||||
}
|
||||
|
||||
function refillDoc(comment: string, body: string): string | undefined {
|
||||
const shell = docShell(comment)
|
||||
if (!shell)
|
||||
return undefined
|
||||
const single = !shell.inner.includes('\n')
|
||||
if (single && !body.includes('\n'))
|
||||
return `${shell.prefix}${shell.quote}${body}${shell.quote}`
|
||||
let inner = shell.inner
|
||||
if (inner.startsWith('\n'))
|
||||
inner = inner.slice(1)
|
||||
if (inner.endsWith('\n'))
|
||||
inner = inner.slice(0, -1)
|
||||
const extra = commonIndent(inner.split('\n'))
|
||||
const closeIndent = closerIndent(comment, shell.quote)
|
||||
const rendered = body.split('\n').map(line => line ? extra + line : '')
|
||||
// 原来正文就写在开头引号那一行时,第一行仍写在那里
|
||||
if (shell.inner.length > 0 && !shell.inner.startsWith('\n')) {
|
||||
const [first, ...rest] = rendered
|
||||
if (rest.length === 0)
|
||||
return `${shell.prefix}${shell.quote}${first ?? ''}${shell.quote}`
|
||||
return `${shell.prefix}${shell.quote}${first ?? ''}\n${rest.join('\n')}\n${closeIndent}${shell.quote}`
|
||||
}
|
||||
return `${shell.prefix}${shell.quote}\n${rendered.join('\n')}\n${closeIndent}${shell.quote}`
|
||||
}
|
||||
|
||||
interface LineShell {
|
||||
token: string
|
||||
spaced: boolean
|
||||
prose: string
|
||||
}
|
||||
|
||||
/** 一行 `#` 或 `//` 注释:记号、记号后面有没有空格、空格后面的字。 */
|
||||
function lineParts(line: string): { token: string, spaced: boolean, text: string } | undefined {
|
||||
let end = 0
|
||||
if (line.startsWith('//')) {
|
||||
end = 2
|
||||
}
|
||||
else if (line.startsWith('#')) {
|
||||
while (line[end] === '#')
|
||||
end++
|
||||
}
|
||||
else {
|
||||
return undefined
|
||||
}
|
||||
const gap = line[end]
|
||||
const spaced = gap === ' ' || gap === '\t'
|
||||
return { token: line.slice(0, end), spaced, text: line.slice(spaced ? end + 1 : end) }
|
||||
}
|
||||
|
||||
function lineShell(comment: string): LineShell | undefined {
|
||||
const lines = comment.split('\n')
|
||||
if (lines.length === 0 || lines.some(line => line.length === 0))
|
||||
return undefined
|
||||
const parsed = lines.map(lineParts)
|
||||
if (parsed.some(item => !item))
|
||||
return undefined
|
||||
const rows = parsed.flatMap(item => item ? [item] : [])
|
||||
const token = rows[0]?.token
|
||||
if (!token || rows.some(item => item.token !== token))
|
||||
return undefined
|
||||
return { token, spaced: rows.some(item => item.spaced), prose: rows.map(item => item.text).join('\n') }
|
||||
}
|
||||
|
||||
function refillLine(comment: string, body: string): string | undefined {
|
||||
const shell = lineShell(comment)
|
||||
if (!shell)
|
||||
return undefined
|
||||
return body.split('\n').map(line => line ? shell.token + (shell.spaced ? ' ' : '') + line : shell.token).join('\n')
|
||||
}
|
||||
|
||||
function blockOpener(comment: string): '/**' | '/*' | undefined {
|
||||
if (comment.startsWith('/**'))
|
||||
return '/**'
|
||||
if (comment.startsWith('/*'))
|
||||
return '/*'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 块注释中间一行 ` * 正文` 里的正文。不是星号行就 undefined。 */
|
||||
function starText(line: string): string | undefined {
|
||||
const trimmed = line.trimStart()
|
||||
if (!trimmed.startsWith('*') || trimmed.startsWith('*/'))
|
||||
return undefined
|
||||
const rest = trimmed.slice(1)
|
||||
if (rest.startsWith(' ') || rest.startsWith('\t'))
|
||||
return rest.slice(1)
|
||||
return rest
|
||||
}
|
||||
|
||||
/** 写回时套在每行正文前面的 ` * `,沿用原来那一行的缩进和空格。 */
|
||||
function starPrefix(line: string): string | undefined {
|
||||
const trimmed = line.trimStart()
|
||||
if (!trimmed.startsWith('*') || trimmed.startsWith('*/'))
|
||||
return undefined
|
||||
const indent = line.slice(0, line.length - trimmed.length)
|
||||
const rest = trimmed.slice(1)
|
||||
const gap = rest.startsWith(' ') || rest.startsWith('\t') ? rest[0] : ''
|
||||
return `${indent}*${gap}`
|
||||
}
|
||||
|
||||
function blockProse(comment: string): string | undefined {
|
||||
const open = blockOpener(comment)
|
||||
if (!open || !comment.trimEnd().endsWith('*/'))
|
||||
return undefined
|
||||
if (!comment.includes('\n'))
|
||||
return comment.trimEnd().slice(open.length, -2).trim()
|
||||
const lines = comment.split('\n')
|
||||
const first = lines[0] ?? ''
|
||||
const last = lines[lines.length - 1] ?? ''
|
||||
if (!first.startsWith(open) || last.trim() !== '*/')
|
||||
return undefined
|
||||
const body: string[] = []
|
||||
const head = first.slice(open.length).trim()
|
||||
if (head)
|
||||
body.push(head)
|
||||
for (const line of lines.slice(1, -1)) {
|
||||
const text = starText(line)
|
||||
body.push(text ?? line.trim())
|
||||
}
|
||||
while (body[0] === '')
|
||||
body.shift()
|
||||
while (body.length > 0 && body[body.length - 1] === '')
|
||||
body.pop()
|
||||
return body.join('\n')
|
||||
}
|
||||
|
||||
function refillBlock(comment: string, body: string): string | undefined {
|
||||
const open = blockOpener(comment)
|
||||
if (!open || blockProse(comment) === undefined)
|
||||
return undefined
|
||||
if (!comment.includes('\n') && !body.includes('\n'))
|
||||
return `${open} ${body} */`
|
||||
const lines = comment.split('\n')
|
||||
const prefix = lines.slice(1, -1).map(starPrefix).find(item => item !== undefined) ?? ' * '
|
||||
const rendered = body.split('\n').map(line => line ? `${prefix}${line}` : prefix.trimEnd())
|
||||
const closeIndent = (lines[lines.length - 1] ?? '').trim() === '*/'
|
||||
? (lines[lines.length - 1] ?? '').slice(0, (lines[lines.length - 1] ?? '').indexOf('*/'))
|
||||
: ' '
|
||||
return `${open}\n${rendered.join('\n')}\n${closeIndent}*/`
|
||||
}
|
||||
|
||||
/** 去掉定界符之后的正文。认不出这种注释就是 undefined。 */
|
||||
export function commentProse(comment: string): string | undefined {
|
||||
const doc = docShell(comment)
|
||||
if (doc)
|
||||
return docProse(doc)
|
||||
return lineShell(comment)?.prose ?? blockProse(comment)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把新正文套回这段注释原来的壳。comment 是去掉共同缩进的原文。
|
||||
* 认不出定界符,或新正文是空的,就返回 undefined,调用方不写文件。
|
||||
*/
|
||||
export function refillComment(comment: string, body: string): string | undefined {
|
||||
const text = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
|
||||
if (!text)
|
||||
return undefined
|
||||
return refillDoc(comment, text) ?? refillLine(comment, text) ?? refillBlock(comment, text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件顶上的模块说明:前面没有别的代码(可以有别的注释),而且是三引号,或没挂到声明上的 `/**`。
|
||||
* `#`、`//`、普通块注释不算,它们讲的是后面那一块。
|
||||
*/
|
||||
export function isModuleComment(comment: string, earlierKinds: readonly string[]): boolean {
|
||||
if (earlierKinds.some(kind => kind !== 'comment'))
|
||||
return false
|
||||
if (comment.trimStart().startsWith('/**'))
|
||||
return true
|
||||
return docShell(comment) !== undefined
|
||||
}
|
||||
|
||||
export interface CommentPlace<T extends { anchor: string, children: readonly T[] }> {
|
||||
block: T
|
||||
siblings: readonly T[]
|
||||
index: number
|
||||
}
|
||||
|
||||
/** 按记号在块树里找到这块,以及它和哪些块排在同一层。 */
|
||||
export function findPlaced<T extends { anchor: string, children: readonly T[] }>(blocks: readonly T[], anchor: string): CommentPlace<T> | undefined {
|
||||
const index = blocks.findIndex(block => block.anchor === anchor)
|
||||
const found = index >= 0 ? blocks[index] : undefined
|
||||
if (found)
|
||||
return { block: found, siblings: blocks, index }
|
||||
for (const block of blocks) {
|
||||
const inner = findPlaced(block.children, anchor)
|
||||
if (inner)
|
||||
return inner
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function clip(text: string, what: string): { text: string, note: string } {
|
||||
const lines = splitLines(text)
|
||||
if (lines.length <= COMMENT_CONTEXT_LINES)
|
||||
return { text, note: '' }
|
||||
return {
|
||||
text: lines.slice(0, COMMENT_CONTEXT_LINES).join('\n'),
|
||||
note: `\n(${what}一共 ${lines.length} 行,这里只贴了前 ${COMMENT_CONTEXT_LINES} 行,后面截断了)`,
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommentRewriteInput {
|
||||
/** 去掉 frontmatter 的技能正文;没有技能就不组提示 */
|
||||
skill: string | undefined
|
||||
/** 去掉共同缩进的注释原文 */
|
||||
comment: string
|
||||
/** 整份文件,用来在是模块说明时拿掉这段注释 */
|
||||
source: string
|
||||
startLine: number
|
||||
endLine: number
|
||||
earlierKinds: readonly string[]
|
||||
/** 普通注释后面紧挨着的那一块的原文;没有就不填 */
|
||||
following?: string
|
||||
}
|
||||
|
||||
/** 发给模型的整段话。注释认不出,或没有技能,就不组。 */
|
||||
export function commentRewritePrompt(input: CommentRewriteInput): string | undefined {
|
||||
if (input.skill === undefined)
|
||||
return undefined
|
||||
const body = commentProse(input.comment)
|
||||
if (body === undefined)
|
||||
return undefined
|
||||
const fileComment = isModuleComment(input.comment, input.earlierKinds)
|
||||
const context = fileComment
|
||||
? clip(splitLines(input.source).filter((_, index) => index < input.startLine || index > input.endLine).join('\n').trim(), '文件其余部分')
|
||||
: input.following
|
||||
? clip(input.following, '这块代码')
|
||||
: { text: '(后面没有紧挨着的代码)', note: '' }
|
||||
return [
|
||||
input.skill,
|
||||
'只按上面的技能重写这一段注释。用中文。不要改代码,不要加技能没要求的中文名记号。只输出新注释的正文,不要定界符,不要解释。',
|
||||
`这段注释现在的正文:\n${body}`,
|
||||
`它说明的代码:\n${context.text}${context.note}`,
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
/** 模型偶尔会套一层代码围栏。剥掉;剥完是空的就当没给。 */
|
||||
export function modelCommentBody(text: string): string | undefined {
|
||||
let body = text.trim()
|
||||
if (body.startsWith('```') && body.endsWith('```')) {
|
||||
const nl = body.indexOf('\n')
|
||||
if (nl >= 0)
|
||||
body = body.slice(nl + 1, -3).trim()
|
||||
}
|
||||
if (!body)
|
||||
return undefined
|
||||
return body
|
||||
}
|
||||
@@ -106,6 +106,7 @@ export type ExtensionToWebview
|
||||
| ({ type: 'askProfiles' } & ProfileChoice)
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
|
||||
| { type: 'commentRewritten', anchor: string }
|
||||
| { type: 'diff', diff: DocumentDiff | null }
|
||||
|
||||
/**
|
||||
@@ -130,6 +131,7 @@ export type WebviewToExtension
|
||||
| { type: 'setFilter', excluded: Excluded }
|
||||
| { type: 'setOnlyChanges', on: boolean }
|
||||
| { type: 'edit', startLine: number, endLine: number, text: string }
|
||||
| { type: 'rewriteComment', anchor: string }
|
||||
| { type: 'selectTranslator', id: string }
|
||||
| { type: 'visibleLine', line: number }
|
||||
| { type: 'probeDefinition', id: number, version: number, offset: number }
|
||||
|
||||
@@ -12,15 +12,18 @@ import type { IndexedEntry } from '../workspaceIndex'
|
||||
import type { BlockFilter } from './blockFilter'
|
||||
import type { Definition, FoundLocation } from './definitions'
|
||||
import type { ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
|
||||
import { dirname } from 'node:path'
|
||||
import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { webviewHtml, webviewUiRoot } from '../../webview/html'
|
||||
import { DocumentCallees } from '../calleeKinds'
|
||||
import { commentRewritePrompt, commentSource, findPlaced, loadCommentSkill, modelCommentBody, refillComment } from '../commentRewrite'
|
||||
import { PreviewDiff } from '../diff/previewDiff'
|
||||
import { blockReplacement } from '../editText'
|
||||
import { locate } from '../folderStore'
|
||||
import { definitionFor, symbolAt, symbolOccurrences } from '../localSymbols'
|
||||
import { DocumentNames, resolveNames } from '../nameResolution'
|
||||
import { askClaude, askFailure } from '../qa/claudeAsk'
|
||||
import { askPrompt, followUpPrompt } from '../qa/prompt'
|
||||
import { DocumentLookups } from '../ruleLookups'
|
||||
import { definitionAction } from './definitionAction'
|
||||
@@ -137,6 +140,10 @@ class Preview {
|
||||
private readonly definitions: DefinitionSources<FoundLocation>
|
||||
/** webview 报来的视口顶部那块的起始行 */
|
||||
visibleLine: number | undefined
|
||||
/** 正在重写的注释(块的记号)。同一块没写完不再开一次 */
|
||||
private readonly rewriting = new Set<string>()
|
||||
/** 预览关掉时停掉还在跑的重写 */
|
||||
private readonly rewriteAbort = new AbortController()
|
||||
/** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */
|
||||
private sent: SentDocument | undefined
|
||||
/** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */
|
||||
@@ -374,6 +381,7 @@ class Preview {
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.rewriteAbort.abort()
|
||||
clearTimeout(this.timer)
|
||||
this.names.dispose()
|
||||
this.lookups.dispose()
|
||||
@@ -453,6 +461,9 @@ class Preview {
|
||||
case 'edit':
|
||||
await this.applyEdit(msg.startLine, msg.endLine, msg.text)
|
||||
return
|
||||
case 'rewriteComment':
|
||||
await this.rewriteComment(msg.anchor)
|
||||
return
|
||||
case 'selectTranslator':
|
||||
await this.registry.selectTranslator(this.languageId, msg.id)
|
||||
this.owner.refresh(this.languageId)
|
||||
@@ -539,6 +550,111 @@ class Preview {
|
||||
await this.owner.openAt(uri, reveal, () => commands.executeCommand('vscode.openWith', uri, PREVIEW_VIEW_TYPE, options))
|
||||
}
|
||||
|
||||
/** 用写注释的技能重写这一段,只把正文套回原来的定界符,再走块编辑那条写回。 */
|
||||
private async rewriteComment(anchor: string): Promise<void> {
|
||||
if (this.rewriting.has(anchor) || this.rewriteAbort.signal.aborted)
|
||||
return
|
||||
this.rewriting.add(anchor)
|
||||
try {
|
||||
await this.rewriteCommentNow(anchor)
|
||||
}
|
||||
finally {
|
||||
this.rewriting.delete(anchor)
|
||||
this.post({ type: 'commentRewritten', anchor })
|
||||
}
|
||||
}
|
||||
|
||||
private failRewrite(message: string): void {
|
||||
void window.showErrorMessage(`代码阅读:${message}`)
|
||||
}
|
||||
|
||||
private async rewriteCommentNow(anchor: string): Promise<void> {
|
||||
const found = this.sent && findPlaced(this.sent.blocks, anchor)
|
||||
if (!found || found.block.kind !== 'comment') {
|
||||
this.failRewrite('这块注释刚改过,找不到了。等预览刷新后再重写。')
|
||||
return
|
||||
}
|
||||
const { block, siblings, index } = found
|
||||
const { document } = this
|
||||
if (block.startLine < 0 || block.endLine < block.startLine || block.endLine >= document.lineCount) {
|
||||
this.failRewrite('这块注释的位置已经变了,没有写回。')
|
||||
return
|
||||
}
|
||||
const raw = Array.from({ length: block.endLine - block.startLine + 1 }, (_, i) => document.lineAt(block.startLine + i).text)
|
||||
const snapshot = raw.join('\n')
|
||||
const comment = commentSource(raw)
|
||||
const source = document.getText()
|
||||
const next = siblings[index + 1]
|
||||
const following = next && next.startLine >= 0 && next.endLine >= next.startLine && next.endLine < document.lineCount
|
||||
? Array.from({ length: next.endLine - next.startLine + 1 }, (_, i) => document.lineAt(next.startLine + i).text).join('\n')
|
||||
: undefined
|
||||
let skill: string | undefined
|
||||
try {
|
||||
skill = await loadCommentSkill()
|
||||
}
|
||||
catch (err) {
|
||||
if (!this.rewriteAbort.signal.aborted)
|
||||
this.failRewrite(`读注释技能失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
return
|
||||
}
|
||||
if (this.rewriteAbort.signal.aborted)
|
||||
return
|
||||
const prompt = commentRewritePrompt({
|
||||
skill,
|
||||
comment,
|
||||
source,
|
||||
startLine: block.startLine,
|
||||
endLine: block.endLine,
|
||||
earlierKinds: siblings.slice(0, index).map(item => item.kind),
|
||||
...(following !== undefined && { following }),
|
||||
})
|
||||
if (!prompt) {
|
||||
this.failRewrite(skill === undefined ? '找不到写注释的技能' : '这段注释的定界符认不出来,没有改文件。')
|
||||
return
|
||||
}
|
||||
const at = locate(document.uri)
|
||||
const profile = await this.owner.qa.profiles.current()
|
||||
let text: string
|
||||
try {
|
||||
const answer = await askClaude({
|
||||
prompt,
|
||||
images: [],
|
||||
...(profile && { profilePath: profile.path }),
|
||||
cwd: at?.folder.uri.fsPath ?? dirname(document.uri.fsPath),
|
||||
signal: this.rewriteAbort.signal,
|
||||
onEvent: () => {},
|
||||
})
|
||||
text = answer.text
|
||||
}
|
||||
catch (err) {
|
||||
if (!this.rewriteAbort.signal.aborted)
|
||||
this.failRewrite(askFailure(err))
|
||||
return
|
||||
}
|
||||
if (this.rewriteAbort.signal.aborted)
|
||||
return
|
||||
const body = modelCommentBody(text)
|
||||
if (!body) {
|
||||
this.failRewrite('模型没有给出注释正文,没有改文件。')
|
||||
return
|
||||
}
|
||||
if (block.endLine >= document.lineCount) {
|
||||
this.failRewrite('这段注释在重写期间变了,没有写回。')
|
||||
return
|
||||
}
|
||||
const current = Array.from({ length: block.endLine - block.startLine + 1 }, (_, i) => document.lineAt(block.startLine + i).text).join('\n')
|
||||
if (current !== snapshot) {
|
||||
this.failRewrite('这段注释在重写期间变了,没有写回。')
|
||||
return
|
||||
}
|
||||
const filled = refillComment(comment, body)
|
||||
if (!filled) {
|
||||
this.failRewrite('这段注释的定界符认不出来,没有改文件。')
|
||||
return
|
||||
}
|
||||
await this.applyEdit(block.startLine, block.endLine, filled)
|
||||
}
|
||||
|
||||
private async applyEdit(startLine: number, endLine: number, text: string): Promise<void> {
|
||||
const { document } = this
|
||||
const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text)
|
||||
|
||||
@@ -37,6 +37,9 @@ export function App(): ReactElement | null {
|
||||
// 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答
|
||||
const qa = useQa()
|
||||
const [doc, setDoc] = useState<DocumentMessage | null>(null)
|
||||
/** 正在按注释规范重写的块(记号)。点下去立刻记上,扩展端写完或失败再发 commentRewritten */
|
||||
const rewritingRef = useRef(new Set<string>())
|
||||
const [rewriting, setRewriting] = useState<ReadonlySet<string>>(NONE)
|
||||
const [view, setView] = useState<BlockModes>(savedBlockModes)
|
||||
const [focusId, setFocusId] = useState<string | null>(null)
|
||||
/** 等着滚过去的位置:新预览第一份文档带的,或已开着的预览收到的 reveal */
|
||||
@@ -114,6 +117,10 @@ export function App(): ReactElement | null {
|
||||
return
|
||||
case 'diff':
|
||||
setDiff(msg.diff)
|
||||
return
|
||||
case 'commentRewritten':
|
||||
rewritingRef.current.delete(msg.anchor)
|
||||
setRewriting(new Set(rewritingRef.current))
|
||||
}
|
||||
})
|
||||
postMessage({ type: 'ready' })
|
||||
@@ -126,6 +133,13 @@ export function App(): ReactElement | null {
|
||||
}, [view])
|
||||
|
||||
const onFocused = useCallback(() => setFocusId(null), [])
|
||||
const onRewriteComment = useCallback((anchor: string) => {
|
||||
if (rewritingRef.current.has(anchor))
|
||||
return
|
||||
rewritingRef.current.add(anchor)
|
||||
setRewriting(new Set(rewritingRef.current))
|
||||
postMessage({ type: 'rewriteComment', anchor })
|
||||
}, [])
|
||||
const kindNames = useMemo(() => new Map(doc?.attributes.find(attribute => attribute.id === 'kind')?.values.map(value => [value.id, value.name])), [doc])
|
||||
const kindName = useCallback((kind: string) => kindNames.get(kind) ?? kind, [kindNames])
|
||||
const tree = useMemo((): BlockTreeState | null => doc && {
|
||||
@@ -147,11 +161,13 @@ export function App(): ReactElement | null {
|
||||
},
|
||||
onToggleFold: id => setFolded([id], !collapsed.has(id)),
|
||||
onSetFolds: folds => setView(prev => ({ ...prev, folds: { ...prev.folds, ...folds } })),
|
||||
rewriting,
|
||||
onRewriteComment,
|
||||
...(qa.state.available && { qa }),
|
||||
...(changes && { diff: changes }),
|
||||
...(folded && { folded }),
|
||||
onOpenRun: id => setOpenedRuns(prev => new Set([...prev, id])),
|
||||
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, qa, changes, folded])
|
||||
}, [doc, view, collapsed, filter, covered, kindName, focusId, onFocused, setFolded, rewriting, onRewriteComment, qa, changes, folded])
|
||||
|
||||
if (!doc || !tree)
|
||||
return null
|
||||
|
||||
@@ -2,8 +2,15 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockTools } from './BlockTools'
|
||||
|
||||
function render(editing: boolean, ask?: { asking: boolean }): string {
|
||||
return renderToStaticMarkup(<BlockTools editing={editing} onToggleEdit={() => {}} {...(ask && { asking: ask.asking, onToggleAsk: () => {} })} />)
|
||||
function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean }): string {
|
||||
return renderToStaticMarkup(
|
||||
<BlockTools
|
||||
editing={editing}
|
||||
onToggleEdit={() => {}}
|
||||
{...(ask && { asking: ask.asking, onToggleAsk: () => {} })}
|
||||
{...(rewrite && { rewriting: rewrite.rewriting, onRewrite: () => {} })}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
/** 标签之间的字:图标(svg)没有字,按钮上有字就会出现在这里 */
|
||||
@@ -31,4 +38,19 @@ describe('块右上角的图标按钮', () => {
|
||||
expect(buttons[0].includes('data-active')).toBe(asking)
|
||||
expect(buttons[1]).toContain('data-tooltip="编辑"')
|
||||
})
|
||||
|
||||
it('「按注释规范重写」排在「问 AI」左边;正在重写时禁用并转圈,不用原生 title', () => {
|
||||
const html = render(false, { asking: false }, { rewriting: false })
|
||||
expect(visibleText(html)).toBe('')
|
||||
expect(html).not.toContain(' title=')
|
||||
const buttons = [...html.matchAll(/<button[^>]*>/g)].map(match => match[0])
|
||||
expect(buttons).toHaveLength(3)
|
||||
expect(buttons[0]).toContain('data-tooltip="按注释规范重写" aria-label="按注释规范重写"')
|
||||
expect(buttons[0]).not.toContain('disabled')
|
||||
expect(buttons[1]).toContain('data-tooltip="问 AI"')
|
||||
expect(buttons[2]).toContain('data-tooltip="编辑"')
|
||||
const busy = render(false, { asking: false }, { rewriting: true })
|
||||
expect(busy).toContain('disabled=""')
|
||||
expect(busy).toContain('cr-qa-spin')
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,43 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { Eye, Pencil, Sparkles } from 'lucide-react'
|
||||
import { Eye, LoaderCircle, Pencil, RefreshCw, Sparkles } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* 块右上角的一排图标按钮(普通块、属性表、库规则的卡片共用):只放图标,名字写在悬停提示(data-tooltip)和读屏用的 aria-label 里。
|
||||
* 「按注释规范重写」:只给注释块。点了用写注释的技能重写这一段并写回文件;正在重写时转圈,不能再点。
|
||||
* 「问 AI」:点开 / 收起块下面的问答区,展开时带按下的底色。
|
||||
* 「编辑 / 预览」切换:预览时是铅笔「编辑」,编辑时是眼睛「预览」、带按下的底色。
|
||||
*/
|
||||
export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk }: {
|
||||
export function BlockTools({ editing, onToggleEdit, asking, onToggleAsk, rewriting, onRewrite }: {
|
||||
editing: boolean
|
||||
onToggleEdit: () => void
|
||||
/** 块下面的问答区展开着 */
|
||||
asking?: boolean
|
||||
/** 没给就不显示「问 AI」(文件不在工作区文件夹里,问答没地方存) */
|
||||
onToggleAsk?: () => void
|
||||
/** 这段注释正在重写 */
|
||||
rewriting?: boolean
|
||||
/** 没给就不显示「按注释规范重写」(不是注释块) */
|
||||
onRewrite?: () => void
|
||||
}): ReactElement {
|
||||
const label = editing ? '预览' : '编辑'
|
||||
const Icon = editing ? Eye : Pencil
|
||||
return (
|
||||
<div className="flex h-5 shrink-0 items-center gap-0.5" role="group" aria-label="块操作">
|
||||
{onRewrite && (
|
||||
<button
|
||||
type="button"
|
||||
className="cr-tool flex h-5 w-6 items-center justify-center rounded"
|
||||
data-tooltip="按注释规范重写"
|
||||
aria-label="按注释规范重写"
|
||||
aria-busy={rewriting === true}
|
||||
disabled={rewriting === true}
|
||||
onClick={onRewrite}
|
||||
>
|
||||
{rewriting
|
||||
? <LoaderCircle size={14} strokeWidth={1.8} className="cr-qa-spin" aria-hidden />
|
||||
: <RefreshCw size={14} strokeWidth={1.8} aria-hidden />}
|
||||
</button>
|
||||
)}
|
||||
{onToggleAsk && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -67,6 +67,8 @@ function render(folds: Record<string, boolean>): string {
|
||||
onToggleEdit: () => {},
|
||||
onToggleFold: () => {},
|
||||
onSetFolds: () => {},
|
||||
rewriting: new Set(),
|
||||
onRewriteComment: () => {},
|
||||
onOpenRun: () => {},
|
||||
}
|
||||
return renderToStaticMarkup(<BlockLevel blocks={[CARD]} tree={tree} />)
|
||||
|
||||
@@ -37,6 +37,9 @@ export interface BlockTreeState {
|
||||
onToggleFold: (id: string) => void
|
||||
/** 一次记下几块、几行折叠还是展开(点子行展开一块、同一行里别的收起) */
|
||||
onSetFolds: (folds: Record<string, boolean>) => void
|
||||
/** 正在按注释规范重写的注释块(记号) */
|
||||
rewriting: ReadonlySet<string>
|
||||
onRewriteComment: (anchor: string) => void
|
||||
/** 问 AI;文件不在工作区文件夹里(问答没地方存)时没有 */
|
||||
qa?: QaTree
|
||||
/** 代码差异:新增、修改的块和删掉的块;侧栏开着「未提交的更改」或审查工单、基准里有这个文件时才有 */
|
||||
@@ -276,6 +279,8 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
||||
onToggleEdit={() => tree.onToggleEdit(block.id)}
|
||||
asking={tree.qa?.state.open.includes(block.anchor)}
|
||||
onToggleAsk={tree.qa && (() => tree.qa?.onToggle(block.anchor))}
|
||||
rewriting={tree.rewriting.has(block.anchor)}
|
||||
onRewrite={block.kind === 'comment' ? () => tree.onRewriteComment(block.anchor) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -119,6 +119,9 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(-
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.cr-tool:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* 层级竖线:悬停一个块时,它子层的竖线换成它的种类色,标出这个块管到哪里为止(块和子层中间隔着问答区也算) */
|
||||
.cr-guide {
|
||||
|
||||
@@ -406,6 +406,7 @@ export type ExtensionToWebview
|
||||
| ({ type: 'askProfiles' } & ProfileChoice)
|
||||
| { type: 'answer', askId: string, text: string, activity?: string }
|
||||
| AnswerEnd
|
||||
| { type: 'commentRewritten', anchor: string }
|
||||
| { type: 'diff', diff: DocumentDiff | null }
|
||||
|
||||
/**
|
||||
@@ -429,6 +430,7 @@ export type WebviewToExtension
|
||||
| { type: 'setFilter', excluded: Excluded }
|
||||
| { type: 'setOnlyChanges', on: boolean }
|
||||
| { type: 'edit', startLine: number, endLine: number, text: string }
|
||||
| { type: 'rewriteComment', anchor: string }
|
||||
| { type: 'selectTranslator', id: string }
|
||||
| { type: 'visibleLine', line: number }
|
||||
| { type: 'probeDefinition', id: number, version: number, offset: number }
|
||||
|
||||
Reference in new issue
Block a user