diff --git a/vscode/src/codeReader/commentRewrite.test.ts b/vscode/src/codeReader/commentRewrite.test.ts new file mode 100644 index 0000000..0ac8f98 --- /dev/null +++ b/vscode/src/codeReader/commentRewrite.test.ts @@ -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 => { + 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() + }) +}) diff --git a/vscode/src/codeReader/commentRewrite.ts b/vscode/src/codeReader/commentRewrite.ts new file mode 100644 index 0000000..a5b1214 --- /dev/null +++ b/vscode/src/codeReader/commentRewrite.ts @@ -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 = path => readFile(path, 'utf8')): Promise { + 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 { + block: T + siblings: readonly T[] + index: number +} + +/** 按记号在块树里找到这块,以及它和哪些块排在同一层。 */ +export function findPlaced(blocks: readonly T[], anchor: string): CommentPlace | 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 +} diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index ae8bcc3..56968d8 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -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 } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 6cdf28c..0251d12 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -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 /** webview 报来的视口顶部那块的起始行 */ visibleLine: number | undefined + /** 正在重写的注释(块的记号)。同一块没写完不再开一次 */ + private readonly rewriting = new Set() + /** 预览关掉时停掉还在跑的重写 */ + 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 { + 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 { + 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 { const { document } = this const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text) diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 4954d37..8a9d3a7 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -37,6 +37,9 @@ export function App(): ReactElement | null { // 先于下面发 ready 的那个 effect 挂上收消息:ready 后扩展端马上发问答 const qa = useQa() const [doc, setDoc] = useState(null) + /** 正在按注释规范重写的块(记号)。点下去立刻记上,扩展端写完或失败再发 commentRewritten */ + const rewritingRef = useRef(new Set()) + const [rewriting, setRewriting] = useState>(NONE) const [view, setView] = useState(savedBlockModes) const [focusId, setFocusId] = useState(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 diff --git a/vscode/webview-ui/src/codeReader/BlockTools.test.tsx b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx index b20b77b..cae8784 100644 --- a/vscode/webview-ui/src/codeReader/BlockTools.test.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTools.test.tsx @@ -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( {}} {...(ask && { asking: ask.asking, onToggleAsk: () => {} })} />) +function render(editing: boolean, ask?: { asking: boolean }, rewrite?: { rewriting: boolean }): string { + return renderToStaticMarkup( + {}} + {...(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(/]*>/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') + }) }) diff --git a/vscode/webview-ui/src/codeReader/BlockTools.tsx b/vscode/webview-ui/src/codeReader/BlockTools.tsx index a1878b5..8150226 100644 --- a/vscode/webview-ui/src/codeReader/BlockTools.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTools.tsx @@ -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 (
+ {onRewrite && ( + + )} {onToggleAsk && (
) diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 1224910..6b6b9e4 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -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 { diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 65f69a5..f60ea7f 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -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 }