✨ feat(vscode): 重写注释留会话记录、能接着说,允许画原型图
每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。 重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。 进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
This commit is contained in:
1 parent
ae4e2160ef
commit
c9f60f5b30
32 files changed
+1654
-860
No files matched your search
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in new issue
Block a user