✨ feat(vscode): 重写注释留会话记录、能接着说,允许画原型图

每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。
重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。
进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
This commit is contained in:
cruldra committed 2026-10-12 13:45:37 +11:00
1 parent ae4e2160ef
commit c9f60f5b30
32 files changed
+1654 -860

No files matched your search

+29 -16
View File
@@ -10,7 +10,7 @@ import type { CallTree } from '../callTree/model'
import type { DocumentDiff } from '../diff/previewDiff'
import type { ShownName } from '../nameResolution'
import type { ProfileChoice } from '../qa/askProfiles'
import type { RunningAsk } from '../qa/asks'
import type { AskRound } from '../qa/asks'
import type { Excluded, FilterState } from './blockFilter'
export type { Excluded, ShownName }
@@ -64,7 +64,9 @@ export interface DocumentMessage {
names: ShownName[]
}
/** 问过的一次(给预览看的):问题、回答(Markdown)、什么时候问的(ISO 时间)、用哪个 profile 答的。 */
/**
* 问过、重写过的一次(给预览看的):说的话、回答(Markdown;重写记录是交回的注释正文,没交回时是空的)、什么时候(ISO 时间)、用哪个 profile 答的。
*/
export interface QaEntryView {
id: string
question: string
@@ -72,23 +74,25 @@ export interface QaEntryView {
createdAt: string
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
profile?: string
/** 问题带的图片,预览里能直接用的地址(webview.asWebviewUri);没带图片时没有 */
/** 带的图片,预览里能直接用的地址(webview.asWebviewUri);没带图片时没有 */
images?: string[]
/** 重写记录才有:没写进文件的原因 */
error?: string
}
/** 挂在一块上的问答;记号对不上这份文件里任何一块的不显示。 */
/** 挂在一块上的问答或重写记录;记号对不上这份文件里任何一块的不显示。 */
export interface QaThreadView {
anchor: string
entries: QaEntryView[]
}
/**
* 这份文件的问答:available 为假时文件不在工作区文件夹里、问答没地方存,不给「问 AI」;
* running 是正在答的问题(页面重新加载后靠它接上)。
* 这份文件的问答和重写记录:available 为假时文件不在工作区文件夹里、没地方存,不给「问 AI」和「重写注释」;
* current、rewriteCurrent 是当前这一轮:正在答的问题、正在重写的注释,和出错、被叫停没记下留着的(页面重新加载后靠它们接上)。
*/
export interface QaMessage {
type: 'qa'
/** 不在工作区文件夹里时没地方存,问 AI 不可用 */
/** 不在工作区文件夹里时没地方存,问 AI、重写注释不可用 */
available: boolean
/** 块下面的问答,按块的记号 */
threads: QaThreadView[]
@@ -96,7 +100,10 @@ export interface QaMessage {
path: QaEntryView[]
/** 顶部问的是这个文件还是这个文件夹 */
subject: 'file' | 'folder'
running: RunningAsk[]
current: AskRound[]
/** 块下面的重写记录,按块的记号 */
rewrites: QaThreadView[]
rewriteCurrent: AskRound[]
}
/**
@@ -121,8 +128,9 @@ export interface StickyScrollSettings {
* askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停
* rewriteProgress:anchor 那块的注释正在按规范重写。question 是发给模型的那一句(技能命令和块在哪),text 是注释正文到现在为止写出来的,activity 是正在读什么
* rewriteEnd:这段重写结束了。done 是正文已经写进文件;failed 带给人看的原因,文件没改;stopped 是被叫停,文件没改
* rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
* rewriteEnd:askId 那一轮重写结束了:done 是模型答完、这一轮记下了,带交回的正文(text)、存下的那条的 id,没写进文件时带原因(error);
* failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览
* displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理
* diff:这份文件相比对比基准改了什么(侧栏开着「未提交的更改」或审查工单时才有,见 DiffBase):每发一份文档之后算好了补发,
@@ -150,8 +158,8 @@ 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: 'rewriteProgress', anchor: string, question: string, text: string, activity?: string }
| { type: 'rewriteEnd', anchor: string, status: 'done' | 'failed' | 'stopped', error?: string }
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string }
| { type: 'rewriteEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
| { type: 'diff', diff: DocumentDiff | null }
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
| { type: 'repoLinkBroken', path?: string, symbol?: string }
@@ -174,8 +182,11 @@ export type ExtensionToWebview
* setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
* rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段)。过程用 rewriteProgress / rewriteEnd 发回页面,不写入问答
* stopRewrite:叫停 anchor 这段正在进行的重写,不写文件
* rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段);askId 由 webview 起,过程用 rewriteProgress / rewriteEnd 带回去。
* 没有 request 是点了按钮:发技能命令,开新会话;有 request 是在输入框里说的修改意见,接着这块最近一次重写的会话说,images 是贴的图片
* stopRewrite:叫停 askId 那一轮重写,不写文件、不记
* forgetRound:页面上收起了 anchor 那块的问答(ask)或重写(rewrite),扩展端去掉这块留着的那一轮(出错、被叫停的)
* deleteRewrite:删掉 anchor 那块下面的一条重写记录
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
* selectAskProfile:「问 AI」换 profile(id 是 profile 名,「默认」是 ''),扩展端记下后发 askProfiles 给所有预览
@@ -193,8 +204,10 @@ export type WebviewToExtension
| { type: 'setFilter', excluded: Excluded }
| { type: 'setOnlyChanges', on: boolean }
| { type: 'edit', startLine: number, endLine: number, text: string }
| { type: 'rewriteComment', anchor: string }
| { type: 'stopRewrite', anchor: string }
| { type: 'rewriteComment', askId: string, anchor: string, request?: string, images: ClaudeImage[] }
| { type: 'stopRewrite', askId: string }
| { type: 'deleteRewrite', anchor: string, entryId: string }
| { type: 'forgetRound', kind: 'ask' | 'rewrite', anchor: string }
| { type: 'selectTranslator', id: string }
| { type: 'visibleLine', line: number }
| { type: 'probeDefinition', id: number, version: number, offset: number }
@@ -1,7 +1,7 @@
import type { RewriteRequest } from '../qa/codeQa'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { describe, expect, it, vi } from 'vitest'
import { window } from 'vscode'
import { askCommentBody } from '../qa/claudeAsk'
import { workspace } from 'vscode'
import { PreviewEditorProvider } from './previewEditor'
vi.mock('vscode', () => ({
@@ -10,27 +10,42 @@ vi.mock('vscode', () => ({
workspace: {
onDidChangeTextDocument: () => ({ dispose: () => {} }),
onDidChangeConfiguration: () => ({ dispose: () => {} }),
openTextDocument: vi.fn(),
applyEdit: vi.fn(async () => true),
},
/** 记下换了哪儿、换成什么 */
WorkspaceEdit: class {
readonly replaced: { range: unknown, text: string }[] = []
replace(_uri: unknown, range: unknown, text: string) { this.replaced.push({ range, text }) }
},
Range: class {
constructor(readonly startLine: number, readonly startCharacter: number, readonly endLine: number, readonly endCharacter: number) {}
},
EndOfLine: { LF: 1, CRLF: 2 },
}))
vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } }))
vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: () => ({}) }))
vi.mock('../folderStore', () => ({ locate: () => undefined }))
vi.mock('../folderStore', () => ({ locate: () => ({ folder: { uri: { fsPath: '/workspace' } }, key: 'web/a.ts' }) }))
vi.mock('./pathPreview', () => ({ pathPreviewOf: () => undefined }))
vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() }))
vi.mock('../qa/claudeAsk', () => ({ askCommentBody: vi.fn(), askFailure: (error: Error) => error.message }))
function preview(current: () => Promise<never>) {
const SOURCE = ['// 旧说明', 'export function load() {}']
function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise<string | undefined>, dirty = false) {
const subscribe = () => ({ dispose: () => {} })
const receive = vi.fn<(listener: (message: WebviewToExtension) => Promise<void>) => { dispose: () => void }>(subscribe)
const post = vi.fn<(message: ExtensionToWebview) => void>()
const uri = { fsPath: '/workspace/a.ts', scheme: 'file', toString: () => 'file:///workspace/a.ts' }
const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' }
const document = { uri, isDirty: dirty, lineAt: (line: number) => ({ text: SOURCE[line] }), lineCount: SOURCE.length, eol: 1 }
vi.mocked(workspace.openTextDocument).mockResolvedValue(document as never)
const qa = { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { subscribe }, rewrite: vi.fn(rewrite) }
const provider = new PreviewEditorProvider(...[
{},
{},
{},
{ subscribe },
async () => [],
{ imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, profiles: { subscribe, current } },
qa,
async () => [],
async () => [],
{ onDidChangeDiffBase: subscribe },
@@ -38,30 +53,74 @@ function preview(current: () => Promise<never>) {
async () => false,
] as unknown as ConstructorParameters<typeof PreviewEditorProvider>)
provider.resolveCustomTextEditor(...[
{ uri, isDirty: false },
document,
{ webview: { onDidReceiveMessage: receive, postMessage: post }, onDidChangeViewState: subscribe, onDidDispose: subscribe },
] as unknown as Parameters<PreviewEditorProvider['resolveCustomTextEditor']>)
const opened = (provider as unknown as { previews: Set<{ sent: unknown, dispose: () => void }> }).previews.values().next().value!
opened.sent = { blocks: [{ anchor: 'block', children: [], commentTarget: {}, startLine: 0, endLine: 1 }] }
return { send: receive.mock.calls[0]![0], post, dispose: () => opened.dispose() }
opened.sent = { blocks: [{ anchor: 'block', children: [], commentTarget: { kind: 'replace', startLine: 0, endLine: 0 }, startLine: 0, endLine: 1 }] }
return { send: receive.mock.calls[0]![0], post, qa, dispose: () => opened.dispose() }
}
describe('重写注释的模型选择失败', () => {
it('模型删除后仍发送失败结束消息和错误提示,不调用模型且允许再次尝试', async () => {
describe('预览里重写注释', () => {
it('点按钮:发技能命令、开新会话,记在这一轮上的就是这句命令;开不了时回一条带 askId 的失败,能再试', async () => {
const error = '文件问答模型不可用:official。请重新选择。'
const current = vi.fn<() => Promise<never>>().mockRejectedValue(new Error(error))
const editor = preview(current)
const editor = preview(async () => error)
try {
await expect(editor.send({ type: 'rewriteComment', anchor: 'block' })).resolves.toBeUndefined()
expect(editor.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', anchor: 'block', status: 'failed', error })
expect(window.showErrorMessage).toHaveBeenCalledWith(`代码阅读:${error}`)
expect(askCommentBody).not.toHaveBeenCalled()
await editor.send({ type: 'rewriteComment', anchor: 'block' })
expect(current).toHaveBeenCalledTimes(2)
expect(editor.post.mock.calls.filter(([message]) => message.type === 'rewriteEnd')).toHaveLength(2)
await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] })
expect(editor.qa.rewrite).toHaveBeenCalledWith(expect.objectContaining({ fsPath: '/workspace/web/a.ts' }), expect.objectContaining({
askId: 'r1',
anchor: 'block',
question: '/ecc:writing-code-comments web/a.ts:1-2',
prompt: '/ecc:writing-code-comments web/a.ts:1-2',
images: [],
}))
expect(editor.qa.rewrite.mock.calls[0][1]).not.toHaveProperty('followUp')
expect(editor.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', askId: 'r1', status: 'failed', error })
await editor.send({ type: 'rewriteComment', askId: 'r2', anchor: 'block', images: [] })
expect(editor.qa.rewrite).toHaveBeenCalledTimes(2)
}
finally {
editor.dispose()
}
})
it('输入框里说的话:接着这块的重写会话说,带上块现在在哪;会话找不到时开新会话用的是技能命令加这句话', async () => {
const editor = preview(async () => undefined)
try {
const image = { mediaType: 'image/png', base64: 'iVBORw0KGgo=' }
await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', request: '加上原型图', images: [image] })
expect(editor.qa.rewrite.mock.calls[0][1]).toMatchObject({
question: '加上原型图',
prompt: '/ecc:writing-code-comments web/a.ts:1-2\n\n加上原型图',
followUp: 'web/a.ts:1-2\n\n加上原型图',
images: [image],
})
}
finally {
editor.dispose()
}
})
it('模型答完写回:套回原来的 // 换掉那几行;文件还有没保存的修改时不开始', async () => {
const editor = preview(async () => undefined)
try {
await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] })
expect(await editor.qa.rewrite.mock.calls[0][1].writeBack('新说明')).toBeUndefined()
const [edit] = vi.mocked(workspace.applyEdit).mock.calls.at(-1)! as unknown as [{ replaced: { text: string }[] }]
expect(edit.replaced.map(item => item.text)).toEqual(['// 新说明'])
expect(await editor.qa.rewrite.mock.calls[0][1].writeBack(undefined)).toBe('模型没有按要求交回注释正文,没有改文件。')
}
finally {
editor.dispose()
}
const dirty = preview(async () => undefined, true)
try {
await dirty.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] })
expect(dirty.qa.rewrite).not.toHaveBeenCalled()
expect(dirty.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', askId: 'r1', status: 'failed', error: '文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。' })
}
finally {
dirty.dispose()
}
})
})
+110 -134
View File
@@ -1,8 +1,7 @@
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
import type { ClaudeProfile } from '../../cc/profiles'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { IndexFind } from '../callTree/endpoint'
import type { CommentEdit, RewriteOutcome, WriteResult } from '../commentRewrite'
import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange } from '../contract'
import type { DiffBase } from '../diff/diffBase'
import type { OldDocument, SentDocument } from '../diff/previewDiff'
@@ -18,20 +17,18 @@ import type { FoundLocation } from './definitions'
import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
import type { TargetSpot } from './openTarget'
import { realpath } from 'node:fs/promises'
import { dirname, relative, sep } from 'node:path'
import { commands, EndOfLine, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
import { webviewHtml, webviewUiRoot } from '../../webview/html'
import { DocumentCallees } from '../calleeKinds'
import { loadBlockCalls } from '../callTree/query'
import { CallTreeSession } from '../callTree/session'
import { findBlock, rewriteBlockComment } from '../commentRewrite'
import { commentFollowUp, commentJob, commentRewritePrompt, findBlock, writeCommentBody } 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 { askCommentBody, askFailure } from '../qa/claudeAsk'
import { PATH_ASK_ANCHOR } from '../qa/pathQa'
import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups'
@@ -181,12 +178,6 @@ class Preview {
private readonly definitions: DefinitionSources<FoundLocation>
/** webview 报来的视口顶部那块的起始行 */
visibleLine: number | undefined
/** 正在重写的注释(块的记号)。同一块没写完不再开一次 */
private readonly rewriting = new Set<string>()
/** 预览关掉时停掉还在跑的重写 */
private readonly rewriteAbort = new AbortController()
/** 每段正在重写的注释各自的停止开关;预览关掉时 [rewriteAbort] 会把它们都停掉 */
private readonly rewriteStops = new Map<string, AbortController>()
/** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */
private sent: SentDocument | undefined
/** 能看调用树的块(函数、类、接口卡片)落在几棵调用树里。文档发出去之后再数,不挡住第一次预览 */
@@ -314,6 +305,25 @@ class Preview {
})
this.postQa()
}),
owner.qa.onDidRewrite((event) => {
if (event.owner !== this.subject().uri.toString() || !this.ready)
return
if (event.kind === 'update') {
const { askId, question, text, activity } = event.ask
this.post({ type: 'rewriteProgress', askId, question, text, ...(activity !== undefined && { activity }) })
return
}
const { end, entryId } = event
this.post({
type: 'rewriteEnd',
askId: event.ask.askId,
status: end.status,
...(end.status !== 'stopped' && end.error !== undefined && { error: end.error }),
...(end.status === 'done' && end.body !== undefined && { text: end.body }),
...(entryId !== undefined && { entryId }),
})
this.postQa()
}),
]
}
@@ -474,7 +484,6 @@ class Preview {
dispose(): void {
this.callTrees.dispose()
this.rewriteAbort.abort()
clearTimeout(this.timer)
this.names.dispose()
this.lookups.dispose()
@@ -588,13 +597,22 @@ class Preview {
await this.filter.setOnlyChanges(msg.on)
return
case 'edit':
await this.applyEdit(msg.startLine, msg.endLine, msg.text)
await replaceDocumentLines(this.document, msg.startLine, msg.endLine, msg.text)
return
case 'rewriteComment':
await this.rewriteComment(msg.anchor)
await this.rewrite(msg.askId, msg.anchor, msg.request, msg.images)
return
case 'stopRewrite':
this.rewriteStops.get(msg.anchor)?.abort()
this.owner.qa.stopRewrite(msg.askId)
return
case 'deleteRewrite':
await this.owner.qa.deleteRewrite(this.subject().uri, msg.anchor, msg.entryId)
return
case 'forgetRound':
if (msg.kind === 'ask')
this.owner.qa.forgetAsk(this.subject().uri, msg.anchor)
else
this.owner.qa.forgetRewrite(this.subject().uri, msg.anchor)
return
case 'selectTranslator':
await this.registry.selectTranslator(this.languageId, msg.id)
@@ -970,132 +988,90 @@ class Preview {
this.post({ type: 'repoLinkOpened', ...(path !== undefined && { path }), ...(symbol !== undefined && { symbol }) })
}
/** 用写注释的技能重写这一段,只把正文套回原来的定界符,再走块编辑那条写回。 */
private async rewriteComment(anchor: string): Promise<void> {
if (this.rewriting.has(anchor) || this.rewriteAbort.signal.aborted)
return
this.rewriting.add(anchor)
const stop = new AbortController()
const onDispose = (): void => stop.abort()
this.rewriteAbort.signal.addEventListener('abort', onDispose)
this.rewriteStops.set(anchor, stop)
try {
const outcome = await this.rewriteCommentNow(anchor, stop.signal)
if (!this.rewriteAbort.signal.aborted)
this.post({ type: 'rewriteEnd', anchor, status: outcome.status, ...(outcome.status === 'failed' && { error: outcome.error }) })
}
finally {
this.rewriteAbort.signal.removeEventListener('abort', onDispose)
this.rewriteStops.delete(anchor)
this.rewriting.delete(anchor)
}
}
private failRewrite(message: string): void {
void window.showErrorMessage(`代码阅读:${message}`)
}
private async rewriteCommentNow(anchor: string, signal: AbortSignal): Promise<RewriteOutcome> {
const failed = (message: string): RewriteOutcome => {
this.failRewrite(message)
return { status: 'failed', error: message }
}
const block = this.sent && findBlock(this.sent.blocks, anchor)
if (!block)
return failed('这块代码刚改过,找不到了。等预览刷新后再重写。')
const target = block.commentTarget
if (!target)
return failed('这块不能写注释。')
/**
* 重写 anchor 那块的注释:按最近一次发出去的块树找到它,记下它现在的样子(写回前比对),把发给模型的话和写回的办法交给 CodeQa。
* 点按钮(没有 request)发技能命令、开新会话;输入框里说的话接着这块最近一次重写的会话说,带上块现在在哪。
* 找不到这块、文件有没保存的修改(模型读的是磁盘上的文件)、开不了时,直接回一条失败。
*/
private async rewrite(askId: string, anchor: string, request: string | undefined, images: ClaudeImage[]): Promise<void> {
const fail = (error: string): void => this.post({ type: 'rewriteEnd', askId, status: 'failed', error })
const { document } = this
if (document.isDirty)
return failed('文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。')
const block = this.sent && findBlock(this.sent.blocks, anchor)
const job = block?.commentTarget && commentJob(block.commentTarget, block.startLine, block.endLine, line => document.lineAt(line).text, () => document.lineCount)
if (!block || !job) {
fail(block && !block.commentTarget ? '这块不能写注释。' : '这块代码刚改过,找不到了。等预览刷新后再重写。')
return
}
const at = locate(document.uri)
const cwd = at?.folder.uri.fsPath ?? dirname(document.uri.fsPath)
let profile: ClaudeProfile | undefined
try {
profile = await this.owner.qa.profiles.current()
if (!at) {
fail('这个文件不在工作区文件夹里,重写记录没地方存。')
return
}
catch (err) {
return failed(err instanceof Error ? err.message : String(err))
if (document.isDirty) {
fail('文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。')
return
}
if (signal.aborted)
return { status: 'stopped' }
let question = ''
let text = ''
let activity: string | undefined
const publish = (): void => {
this.post({ type: 'rewriteProgress', anchor, question, text, ...(activity !== undefined && { activity }) })
const prompt = commentRewritePrompt(at.key, block.startLine, block.endLine, request)
const question = request ?? prompt
const refused = await this.owner.qa.rewrite(document.uri, {
askId,
anchor,
question,
prompt,
...(request !== undefined && { followUp: commentFollowUp(at.key, block.startLine, block.endLine, request) }),
images,
writeBack: body => this.writeBack(job, body),
})
if (refused !== undefined) {
fail(refused)
return
}
const outcome = await rewriteBlockComment({
target,
startLine: block.startLine,
endLine: block.endLine,
path: relative(cwd, document.uri.fsPath).split(sep).join('/'),
}, {
// 页面点按钮时还不知道那句技能命令,先报上去
this.post({ type: 'rewriteProgress', askId, question, text: '' })
this.postQa()
}
/** 把交回的正文写回这份文件;预览关了也照样按 uri 打开文档来写。 */
private async writeBack(job: CommentJob, body: string | undefined): Promise<string | undefined> {
const document = await workspace.openTextDocument(this.document.uri)
return writeCommentBody(job, body, {
lineAt: line => document.lineAt(line).text,
lineCount: () => document.lineCount,
ask: (prompt) => {
question = prompt
publish()
return askCommentBody({
prompt,
...(profile && { profilePath: profile.path }),
cwd,
signal,
onEvent: (event) => {
if (event.kind === 'text') {
text = event.text
activity = undefined
}
else {
activity = event.label
}
publish()
},
})
},
failure: askFailure,
write: edit => this.applyCommentEdit(edit),
signal,
write: edit => writeCommentEdit(document, edit),
})
if (outcome.status === 'failed')
this.failRewrite(outcome.error)
return outcome
}
/** 写回重写好的注释:换掉原来那几行(同块编辑),或在块上面插进新写的几行。 */
private async applyCommentEdit(edit: CommentEdit): Promise<WriteResult> {
if (edit.kind === 'replace')
return this.applyEdit(edit.startLine, edit.endLine, edit.text)
const { document } = this
if (edit.line >= document.lineCount)
return 'missing'
const eol = document.eol === EndOfLine.CRLF ? '\r\n' : '\n'
const change = new WorkspaceEdit()
change.insert(document.uri, new Position(edit.line, 0), edit.text.split('\n').join(eol) + eol)
if (!await workspace.applyEdit(change)) {
void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`)
return 'rejected'
}
return 'written'
}
/** 把这几行换成 [text](见 WriteResult)。 */
private async applyEdit(startLine: number, endLine: number, text: string): Promise<WriteResult> {
const { document } = this
const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text)
if (!replacement) {
logger.add({ level: 'warn', source: 'codeReader', message: `块内修改的行号 ${startLine}-${endLine} 已不在 ${document.uri.fsPath} 里,没有写回` })
return 'missing'
}
if (replacement.unchanged)
return 'unchanged'
const edit = new WorkspaceEdit()
edit.replace(document.uri, new Range(replacement.startLine, 0, replacement.endLine, replacement.endCharacter), replacement.text)
if (!await workspace.applyEdit(edit)) {
void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`)
return 'rejected'
}
return 'written'
}
}
/** 写回重写好的注释:换掉原来那几行(同块编辑),或在块上面插进新写的几行。 */
async function writeCommentEdit(document: TextDocument, edit: CommentEdit): Promise<WriteResult> {
if (edit.kind === 'replace')
return replaceDocumentLines(document, edit.startLine, edit.endLine, edit.text)
if (edit.line >= document.lineCount)
return 'missing'
const eol = document.eol === EndOfLine.CRLF ? '\r\n' : '\n'
const change = new WorkspaceEdit()
change.insert(document.uri, new Position(edit.line, 0), edit.text.split('\n').join(eol) + eol)
if (!await workspace.applyEdit(change)) {
void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`)
return 'rejected'
}
return 'written'
}
/** 把这几行换成 [text](见 WriteResult):块编辑和重写注释都走这条。 */
async function replaceDocumentLines(document: TextDocument, startLine: number, endLine: number, text: string): Promise<WriteResult> {
const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text)
if (!replacement) {
logger.add({ level: 'warn', source: 'codeReader', message: `块内修改的行号 ${startLine}-${endLine} 已不在 ${document.uri.fsPath} 里,没有写回` })
return 'missing'
}
if (replacement.unchanged)
return 'unchanged'
const edit = new WorkspaceEdit()
edit.replace(document.uri, new Range(replacement.startLine, 0, replacement.endLine, replacement.endCharacter), replacement.text)
if (!await workspace.applyEdit(edit)) {
void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`)
return 'rejected'
}
return 'written'
}