diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts new file mode 100644 index 0000000..ddf2418 --- /dev/null +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -0,0 +1,67 @@ +import type { ExtensionToWebview, WebviewToExtension } from './messages' +import { describe, expect, it, vi } from 'vitest' +import { window } from 'vscode' +import { askCommentBody } from '../qa/claudeAsk' +import { PreviewEditorProvider } from './previewEditor' + +vi.mock('vscode', () => ({ + commands: { executeCommand: vi.fn() }, + window: { showErrorMessage: vi.fn() }, + workspace: { + onDidChangeTextDocument: () => ({ dispose: () => {} }), + onDidChangeConfiguration: () => ({ dispose: () => {} }), + }, +})) +vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } })) +vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: () => ({}) })) +vi.mock('../folderStore', () => ({ locate: () => undefined })) +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) { + const subscribe = () => ({ dispose: () => {} }) + const receive = vi.fn<(listener: (message: WebviewToExtension) => Promise) => { dispose: () => void }>(subscribe) + const post = vi.fn<(message: ExtensionToWebview) => void>() + const uri = { fsPath: '/workspace/a.ts', scheme: 'file', toString: () => 'file:///workspace/a.ts' } + const provider = new PreviewEditorProvider(...[ + {}, + {}, + {}, + { subscribe }, + async () => [], + { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, profiles: { subscribe, current } }, + async () => [], + async () => [], + { onDidChangeDiffBase: subscribe }, + { onDidChange: subscribe }, + async () => false, + ] as unknown as ConstructorParameters) + provider.resolveCustomTextEditor(...[ + { uri, isDirty: false }, + { webview: { onDidReceiveMessage: receive, postMessage: post }, onDidChangeViewState: subscribe, onDidDispose: subscribe }, + ] as unknown as Parameters) + 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() } +} + +describe('重写注释的模型选择失败', () => { + it('模型删除后仍发送失败结束消息和错误提示,不调用模型且允许再次尝试', async () => { + const error = '文件问答模型不可用:official。请重新选择。' + const current = vi.fn<() => Promise>().mockRejectedValue(new Error(error)) + const editor = preview(current) + 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) + } + finally { + editor.dispose() + } + }) +}) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 40c5072..98ce6ff 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,4 +1,5 @@ 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 { CommentEdit, RewriteOutcome, WriteResult } from '../commentRewrite' import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange, WorkspaceIndex } from '../contract' @@ -547,7 +548,13 @@ class Preview { await this.ask(msg.askId, msg.anchor, msg.question, msg.images) return case 'selectAskProfile': - await this.owner.qa.profiles.select(msg.id) + try { + await this.owner.qa.profiles.select(msg.id) + } + catch (err) { + void window.showErrorMessage(err instanceof Error ? err.message : String(err)) + await this.postProfiles() + } return case 'stopAsk': this.owner.qa.stop(msg.askId) @@ -968,7 +975,13 @@ class Preview { return failed('文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。') const at = locate(document.uri) const cwd = at?.folder.uri.fsPath ?? dirname(document.uri.fsPath) - const profile = await this.owner.qa.profiles.current() + let profile: ClaudeProfile | undefined + try { + profile = await this.owner.qa.profiles.current() + } + catch (err) { + return failed(err instanceof Error ? err.message : String(err)) + } if (signal.aborted) return { status: 'stopped' } let question = '' diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 4f31e90..9267c5b 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -86,7 +86,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { return runtime.parse(adapter, languageId, source) }, report)) const resolve = (reference: RuleReference): Promise => resolveReference(reference, registry.libraryRules(), indexes, report) - const qa = new CodeQa(context.globalState, context.workspaceState) + const qa = new CodeQa(context, context.workspaceState) const namesIn = async (languageId: string, name: string): Promise => { const adapter = registry.languageFor(languageId) return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : [] diff --git a/vscode/src/codeReader/qa/askProfiles.test.ts b/vscode/src/codeReader/qa/askProfiles.test.ts index 3689874..e061dd5 100644 --- a/vscode/src/codeReader/qa/askProfiles.test.ts +++ b/vscode/src/codeReader/qa/askProfiles.test.ts @@ -1,8 +1,7 @@ -import type { Memento } from 'vscode' import type { ClaudeProfile } from '../../cc/profiles' import type { ProfileChoice, ProfileSource } from './askProfiles' import { describe, expect, it } from 'vitest' -import { ASK_PROFILE_KEY, AskProfiles, DEFAULT_OPTION, profileChoice } from './askProfiles' +import { AskProfiles, DEFAULT_OPTION, profileChoice } from './askProfiles' import { askArgs } from './claudeAsk' const DIR = '/home/me/profiles' @@ -10,19 +9,8 @@ const profile = (name: string): ClaudeProfile => ({ name, path: `${DIR}/${name}. const PROFILES = [profile('dsflash'), profile('offical'), profile('sonnet')] const DEFAULT_PATH = `${DIR}/offical.json` -function memento(initial: Record = {}): Pick & { store: Record } { - const store = { ...initial } - return { - store, - get: (key: string): T | undefined => store[key] as T | undefined, - update: async (key: string, value: unknown) => { - store[key] = value - }, - } as Pick & { store: Record } -} - function source(profiles: ClaudeProfile[] = PROFILES): ProfileSource { - return { list: async () => profiles, defaultPath: () => DEFAULT_PATH } + return { list: async () => profiles, defaultPath: () => DEFAULT_PATH, directory: () => DIR } } describe('profile 列表 → 下拉的选项', () => { @@ -43,27 +31,129 @@ describe('profile 列表 → 下拉的选项', () => { }) it('一个 profile 都没有:只有「默认」', () => { - expect(profileChoice([], DEFAULT_PATH, 'sonnet')).toEqual({ options: [DEFAULT_OPTION], selected: '' }) + expect(profileChoice([], DEFAULT_PATH, undefined)).toEqual({ options: [DEFAULT_OPTION], selected: '' }) }) - it('选过的还在就选它;不在了(删了、目录换了)、记的是「默认」但已经有默认 profile,都回到默认那项', () => { - expect(profileChoice(PROFILES, DEFAULT_PATH, 'sonnet').selected).toBe('sonnet') - expect(profileChoice(PROFILES, DEFAULT_PATH, 'gone').selected).toBe('offical') - expect(profileChoice(PROFILES, DEFAULT_PATH, '').selected).toBe('offical') - expect(profileChoice(PROFILES, DEFAULT_PATH, 42).selected).toBe('offical') + it('临时选项保留;失效选项明确标记,不偷偷退回默认', () => { + expect(profileChoice(PROFILES, DEFAULT_PATH, `${DIR}/sonnet.json`).selected).toBe('sonnet') + const gone = `${DIR}/gone.json` + expect(profileChoice(PROFILES, DEFAULT_PATH, gone)).toMatchObject({ selected: gone, options: expect.arrayContaining([{ id: gone, label: `不可用:${gone}` }]) }) + expect(profileChoice(PROFILES, DEFAULT_PATH, '').selected).toBe('') + expect(profileChoice(PROFILES, DEFAULT_PATH, '').options).toContainEqual(DEFAULT_OPTION) }) }) -describe('记住最近选的 profile', () => { - it('选了记进 globalState,换个预览、重启 VS Code(新的实例)读得回来', async () => { - const state = memento() - await new AskProfiles(state, source()).select('sonnet') - expect(state.store[ASK_PROFILE_KEY]).toBe('sonnet') - expect((await new AskProfiles(state, source()).choice()).selected).toBe('sonnet') +describe('文件问答默认模型与临时选择', () => { + it.each(['offical', 'official'])('临时选空默认后,%s.json 增加或删除都保持不带配置文件', async (officialName) => { + let available = [profile('sonnet')] + let defaultPath = DEFAULT_PATH + const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, { + ...source(), + list: async () => available, + defaultPath: () => defaultPath, + }) + await profiles.select('') + for (const added of [true, false, true]) { + available = added ? [profile('sonnet'), profile(officialName)] : [profile('sonnet')] + defaultPath = added ? `${DIR}/${officialName}.json` : DEFAULT_PATH + await profiles.refresh() + const choice = await profiles.choice() + expect(choice.selected).toBe('') + expect(choice.options).toContainEqual(DEFAULT_OPTION) + expect(await profiles.current()).toBeUndefined() + expect(askArgs({ profilePath: (await profiles.current())?.path })).not.toContain('--settings') + } + }) + + it('真实目录变更才清除显式空选择并恢复设置默认', async () => { + let directory = DIR + const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, { + ...source([profile('sonnet')]), + directory: () => directory, + }) + await profiles.select('') + expect(await profiles.current()).toBeUndefined() + directory = '/new/profiles' + await profiles.refresh() + expect(await profiles.current()).toEqual(profile('sonnet')) + }) + + it('仅官方文件删除改变自动默认路径时,保留失效的临时选择并阻止发问', async () => { + let available = [profile('dsflash'), profile('official')] + let defaultPath = `${DIR}/official.json` + const profiles = new AskProfiles(() => `${DIR}/dsflash.json`, { + ...source(), + list: async () => available, + defaultPath: () => defaultPath, + }) + await profiles.select('official') + available = [profile('dsflash')] + defaultPath = `${DIR}/offical.json` + expect((await profiles.choice()).selected).toBe(`${DIR}/official.json`) + await expect(profiles.current()).rejects.toThrow(`文件问答模型不可用:${DIR}/official.json`) + }) + + it('没有官方配置文件时,可以从配置的默认模型临时切到内置默认', async () => { + const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, source([profile('sonnet')])) + await profiles.select('') + expect((await profiles.choice()).selected).toBe('') + expect(await profiles.current()).toBeUndefined() + }) + + it('配置默认实际用于命令行;临时切换不改默认,重新创建服务恢复配置', async () => { + const configured = `${DIR}/dsflash.json` + const profiles = new AskProfiles(() => configured, source()) + expect(await profiles.current()).toEqual(profile('dsflash')) + expect(askArgs({ profilePath: (await profiles.current())?.path }).slice(0, 2)).toEqual(['--settings', configured]) + await profiles.select('sonnet') + expect((await profiles.choice()).selected).toBe('sonnet') + expect(await profiles.current()).toEqual(profile('sonnet')) + expect(configured).toBe(`${DIR}/dsflash.json`) + expect(await new AskProfiles(() => configured, source()).current()).toEqual(profile('dsflash')) + }) + + it('配置变更清除临时选择并通知所有预览;相同配置刷新保留临时选择', async () => { + let configured = `${DIR}/dsflash.json` + const profiles = new AskProfiles(() => configured, source()) + const updates: string[] = [] + profiles.subscribe(choice => updates.push(choice.selected)) + await profiles.select('sonnet') + await profiles.refresh() + expect((await profiles.choice()).selected).toBe('sonnet') + configured = DEFAULT_PATH + await profiles.refresh() + expect(await profiles.current()).toEqual(profile('offical')) + expect(updates.at(-1)).toBe('offical') + configured = '' + await profiles.refresh() + expect((await profiles.choice()).selected).toBe('offical') + }) + + it('配置的模型已删除时保留不可用选项并报错,不会偷偷换成官方模型', async () => { + let available = [...PROFILES] + const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, { ...source(), list: async () => available }) + expect(await profiles.current()).toEqual(profile('sonnet')) + available = available.filter(item => item.name !== 'sonnet') + const choice = await profiles.choice() + expect(choice.options.find(item => item.id === choice.selected)?.label).toContain('不可用') + await expect(profiles.current()).rejects.toThrow('文件问答模型不可用') + await profiles.select('dsflash') + expect(await profiles.current()).toEqual(profile('dsflash')) + }) + + it('临时模型被删除和无效选择都会报错,不能静默换模型', async () => { + let available = [...PROFILES] + const profiles = new AskProfiles(() => '', { ...source(), list: async () => available }) + await profiles.select('sonnet') + available = available.filter(item => item.name !== 'sonnet') + await expect(profiles.current()).rejects.toThrow('文件问答模型不可用') + await expect(profiles.select('unknown')).rejects.toThrow('文件问答模型不可用') + await profiles.select('offical') + expect(await profiles.current()).toEqual(profile('offical')) }) it('一个预览选了,所有订阅的预览都收到新的选项和选中的那项;取消订阅后不再收到', async () => { - const profiles = new AskProfiles(memento(), source()) + const profiles = new AskProfiles(() => '', source()) const first: ProfileChoice[] = [] const second: string[] = [] profiles.subscribe(choice => first.push(choice)) @@ -77,17 +167,17 @@ describe('记住最近选的 profile', () => { }) it('下一个问题用选的那个 profile:命令行带它的 --settings;选「默认」时没有、不带 --settings', async () => { - const profiles = new AskProfiles(memento({ [ASK_PROFILE_KEY]: 'sonnet' }), source()) + const profiles = new AskProfiles(() => `${DIR}/sonnet.json`, source()) const chosen = await profiles.current() expect(chosen).toEqual(profile('sonnet')) expect(askArgs({ profilePath: chosen?.path }).slice(0, 2)).toEqual(['--settings', `${DIR}/sonnet.json`]) - const none = await new AskProfiles(memento(), source([])).current() + const none = await new AskProfiles(() => '', source([])).current() expect(none).toBeUndefined() expect(askArgs({ profilePath: none?.path })).not.toContain('--settings') }) it('没选过:用插件的默认 profile(和以前问 AI 用的一样)', async () => { - expect(await new AskProfiles(memento(), source()).current()).toEqual(profile('offical')) + expect(await new AskProfiles(() => '', source()).current()).toEqual(profile('offical')) }) }) diff --git a/vscode/src/codeReader/qa/askProfiles.ts b/vscode/src/codeReader/qa/askProfiles.ts index e49e959..31dc6ea 100644 --- a/vscode/src/codeReader/qa/askProfiles.ts +++ b/vscode/src/codeReader/qa/askProfiles.ts @@ -1,9 +1,6 @@ -import type { Disposable, Memento } from 'vscode' +import type { Disposable } from 'vscode' import type { ClaudeProfile } from '../../cc/profiles' -import { getDefaultProfilePath, listClaudeProfiles } from '../../cc/profiles' - -/** globalState 里记的「问 AI」最近选的 profile 名(「默认」记成 '')。 */ -export const ASK_PROFILE_KEY = 'superpowers.codeReader.askProfile' +import { getDefaultProfilePath, getProfilesDir, listClaudeProfiles } from '../../cc/profiles' /** 下拉里的一项:id 是 profile 名;「默认」(不带 `--settings`,claude 用 ~/.claude 的登录)的 id 是 ''。 */ export interface ProfileOption { @@ -18,60 +15,80 @@ export interface ProfileChoice { export const DEFAULT_OPTION: ProfileOption = { id: '', label: '默认' } -/** - * 下拉的选项和选中的那项。插件的默认 profile(getDefaultProfilePath)在列表里时,没选过就选它; - * 不在列表里(一个 profile 都没有,或没有 offical.json)时最前面加一项「默认」,没选过就选它。 - * 记着的那项不在列表里了(profile 删了、目录换了)也回到这一项。 - */ -export function profileChoice(profiles: readonly ClaudeProfile[], defaultPath: string, remembered: unknown): ProfileChoice { +/** 下拉显示临时选择或设置默认值,失效的选择明确标出,避免回答时偷偷换模型。 */ +export function profileChoice(profiles: readonly ClaudeProfile[], defaultPath: string, temporaryPath: string | undefined, configuredPath = ''): ProfileChoice { const fallback = profiles.find(profile => profile.path === defaultPath) - const options = [...(fallback ? [] : [DEFAULT_OPTION]), ...profiles.map(({ name }) => ({ id: name, label: name }))] - const selected = options.find(option => option.id === remembered)?.id ?? fallback?.name ?? DEFAULT_OPTION.id + const requestedPath = temporaryPath ?? (configuredPath || fallback?.path) + const selected = profiles.find(profile => profile.path === requestedPath)?.name ?? requestedPath ?? DEFAULT_OPTION.id + const options = [...(!fallback || selected === '' ? [DEFAULT_OPTION] : []), ...profiles.map(({ name }) => ({ id: name, label: name }))] + if (!options.some(option => option.id === selected)) + options.push({ id: selected, label: `不可用:${selected}` }) return { options, selected } } export interface ProfileSource { list: () => Promise defaultPath: () => string + directory: () => string } -const PROFILE_SOURCE: ProfileSource = { list: listClaudeProfiles, defaultPath: getDefaultProfilePath } +const PROFILE_SOURCE: ProfileSource = { list: listClaudeProfiles, defaultPath: getDefaultProfilePath, directory: getProfilesDir } -/** - * 「问 AI」用哪个 Claude Code profile:所有预览共用一份,记在 globalState 里,换个预览、重启 VS Code 都还是它; - * 一个预览改了,所有订阅的预览都收到(包括改的那个,webview 只按扩展端发来的显示)。 - * profile 列表每次现读(同看板:cc/profiles 的目录),不依赖 vscode 运行时,便于单元测试。 - */ +/** 所有预览共用临时模型选择,重新创建服务或修改默认配置后恢复设置里的默认值。 */ export class AskProfiles { private readonly listeners = new Set<(choice: ProfileChoice) => void>() + private temporary: string | undefined + private defaultKey: string | undefined - constructor(private readonly state: Pick, private readonly source: ProfileSource = PROFILE_SOURCE) {} + constructor(private readonly configuredPath: () => string, private readonly source: ProfileSource = PROFILE_SOURCE) {} async choice(): Promise { return (await this.read()).choice } - /** 下一个问题用的 profile;选的是「默认」时没有(不带 `--settings`)。 */ + /** 下一个问题使用当前显示的模型,失效时直接报错而不换成其他模型。 */ async current(): Promise { const { profiles, choice } = await this.read() - return profiles.find(profile => profile.name === choice.selected) + this.publish(choice) + const profile = profiles.find(profile => profile.name === choice.selected) + if (choice.selected && !profile) + throw new Error(`文件问答模型不可用:${choice.selected}。请在设置中重新选择默认模型,或在输入框旁临时切换。`) + return profile } - /** 选了就调 listener,带上新的选项和选中的那项。 */ + /** 选择变化时通知所有打开的预览,让显示和实际发送使用同一个模型。 */ subscribe(listener: (choice: ProfileChoice) => void): Disposable { this.listeners.add(listener) return { dispose: () => this.listeners.delete(listener) } } async select(id: string): Promise { - await this.state.update(ASK_PROFILE_KEY, id) - const choice = await this.choice() + const { profiles, choice } = await this.read() + const profile = profiles.find(profile => profile.name === id) + if (id ? !profile : !choice.options.some(option => option.id === '')) + throw new Error(`文件问答模型不可用:${id}。请重新选择。`) + this.temporary = profile?.path ?? '' + await this.refresh() + } + + async refresh(): Promise { + this.publish(await this.choice()) + } + + private publish(choice: ProfileChoice): void { for (const listener of [...this.listeners]) listener(choice) } private async read(): Promise<{ profiles: ClaudeProfile[], choice: ProfileChoice }> { const profiles = await this.source.list() - return { profiles, choice: profileChoice(profiles, this.source.defaultPath(), this.state.get(ASK_PROFILE_KEY)) } + const configuredPath = this.configuredPath().trim() + const defaultPath = this.source.defaultPath() + const key = JSON.stringify([configuredPath, this.source.directory()]) + if (key !== this.defaultKey) { + this.defaultKey = key + this.temporary = undefined + } + return { profiles, choice: profileChoice(profiles, defaultPath, this.temporary, configuredPath) } } } diff --git a/vscode/src/codeReader/qa/codeQa.test.ts b/vscode/src/codeReader/qa/codeQa.test.ts new file mode 100644 index 0000000..f9440ed --- /dev/null +++ b/vscode/src/codeReader/qa/codeQa.test.ts @@ -0,0 +1,136 @@ +import type { ExtensionContext, Memento, Uri } from 'vscode' +import type { AskRunner } from './claudeAsk' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { listClaudeProfiles } from '../../cc/profiles' +import { getSettings, saveSettings } from '../../settings/store' +import { CodeQa } from './codeQa' + +vi.mock('vscode', async () => { + const { EventEmitter } = await import('node:events') + return { + EventEmitter: class { + private readonly emitter = new EventEmitter() + event = (listener: (value: unknown) => void) => { + this.emitter.on('event', listener) + return { dispose: () => this.emitter.off('event', listener) } + } + + fire(value: unknown) { this.emitter.emit('event', value) } + dispose() { this.emitter.removeAllListeners() } + }, + Disposable: { from: () => ({ dispose: () => {} }) }, + Uri: {}, + window: { showErrorMessage: vi.fn() }, + } +}) + +vi.mock('../folderStore', () => ({ + FolderStore: class { + get() { return {} } + dispose() {} + }, + locate: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }), +})) +vi.mock('../editor/pathPreview', () => ({ + pathQaLocation: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }), +})) +vi.mock('./pathAskDescribe', () => ({ describePath: async () => ({ kind: 'file', relativePath: 'a.ts', head: { unavailable: '测试' } }) })) +vi.mock('../../cc/profiles', () => ({ + getDefaultProfilePath: () => '/p/offical.json', + getProfilesDir: () => '/p', + listClaudeProfiles: vi.fn(), +})) + +const AVAILABLE = ['offical', 'sonnet', 'dsflash'].map(name => ({ name, path: `/p/${name}.json` })) +const uri = { fsPath: '/workspace/a.ts', toString: () => 'file:///workspace/a.ts' } as Uri +const dirs: string[] = [] +const services: CodeQa[] = [] + +function context(): ExtensionContext { + const dir = mkdtempSync(path.join(tmpdir(), 'file-qa-settings-')) + dirs.push(dir) + return { + extensionPath: path.resolve(__dirname, '../../..'), + globalStorageUri: { fsPath: dir }, + globalState: { get: (key: string) => key === 'superpowers.codeReader.askProfile' ? 'sonnet' : undefined }, + workspaceState: { get: () => undefined, update: vi.fn() }, + } as unknown as ExtensionContext +} + +function service(ctx: ExtensionContext) { + const runner = vi.fn(run => new Promise((_resolve, reject) => { + run.signal.addEventListener('abort', () => reject(new Error('停止'))) + })) + const qa = new CodeQa(ctx, ctx.workspaceState as Memento, runner) + services.push(qa) + return { qa, runner } +} + +beforeEach(() => vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE)) +afterEach(() => { + services.splice(0).forEach(qa => qa.dispose()) + dirs.splice(0).forEach(dir => rmSync(dir, { recursive: true, force: true })) +}) + +describe('问这个文件实际使用设置的默认模型', () => { + it('新增官方文件和保存无关设置不改变显式空选择,真实默认变更才重置', async () => { + const ctx = context() + await saveSettings(ctx, { defaultAskProfilePath: '/p/sonnet.json' }) + vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE.filter(item => item.name !== 'offical')) + const { qa, runner } = service(ctx) + await qa.profiles.select('') + vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE) + const shown = vi.fn() + qa.profiles.subscribe(shown) + await saveSettings(ctx, { webhookPort: 9000 }) + await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: '' }))) + await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] }) + expect(runner.mock.calls[0]?.[0]).not.toHaveProperty('profilePath') + expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/sonnet.json') + qa.stop('1') + await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' }) + await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' }))) + await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] }) + expect(runner.mock.calls[1]?.[0].profilePath).toBe('/p/dsflash.json') + }) + + it('忽略旧选择,临时切换不写默认;重新创建后恢复配置', async () => { + const ctx = context() + await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' }) + const { qa, runner } = service(ctx) + await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] }) + expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json') + qa.stop('1') + await qa.profiles.select('sonnet') + await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] }) + expect(runner.mock.calls[1]?.[0].profilePath).toBe('/p/sonnet.json') + expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/dsflash.json') + const restarted = service(ctx) + await restarted.qa.askPath(uri, 'file', { askId: '3', question: '这是什么', images: [] }) + expect(restarted.runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json') + }) + + it('保存新默认后所有现有预览收到新选择,下一个问题和界面使用同一个模型', async () => { + const ctx = context() + const { qa, runner } = service(ctx) + await qa.profiles.select('sonnet') + const shown = vi.fn() + qa.profiles.subscribe(shown) + await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' }) + await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' }))) + await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] }) + expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json') + }) + + it('已删除配置以现有问答错误返回,不能启动模型;代码块问答同样阻止', async () => { + const ctx = context() + await saveSettings(ctx, { defaultAskProfilePath: '/p/deleted.json' }) + const { qa, runner } = service(ctx) + expect(await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })).toContain('文件问答模型不可用') + expect(await qa.ask(uri, { askId: '2', anchor: 'a', question: '问题', prompt: '问题', followUp: '继续', images: [] })).toContain('文件问答模型不可用') + expect(runner).not.toHaveBeenCalled() + }) +}) diff --git a/vscode/src/codeReader/qa/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts index 37d0443..5698cff 100644 --- a/vscode/src/codeReader/qa/codeQa.ts +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -1,4 +1,5 @@ -import type { Event, Memento } from 'vscode' +import type { Event, ExtensionContext, Memento } from 'vscode' +import type { ClaudeProfile } from '../../cc/profiles' import type { ClaudeImage } from '../../cc/spawnClaude' import type { QaMessage } from '../editor/messages' import type { PathSubject } from '../editor/pathPreview' @@ -7,6 +8,7 @@ import type { AskRunner } from './claudeAsk' import type { QaEntry } from './qaMap' import { randomUUID } from 'node:crypto' import { Disposable, EventEmitter, Uri, window } from 'vscode' +import { getSettings, onDidChangeSettings } from '../../settings/store' import { pathQaLocation } from '../editor/pathPreview' import { FolderStore, locate } from '../folderStore' import { AskProfiles } from './askProfiles' @@ -47,7 +49,8 @@ export class CodeQa implements Disposable { private readonly pathChanged = new EventEmitter() /** 顶部问答的读写排成一队,两次同时答完不会互相盖掉 */ private pathQueue: Promise = Promise.resolve() - /** 问的时候用哪个 profile(所有预览共用,记在 globalState 里) */ + private readonly settingsSubscription: Disposable + /** 所有预览共用当前模型,临时切换不会写入默认设置。 */ readonly profiles: AskProfiles /** 块下面或顶部的问答变了的那些文件 / 文件夹 */ readonly onDidChange: Event = (listener, thisArgs, disposables) => { @@ -62,11 +65,16 @@ export class CodeQa implements Disposable { readonly onDidAnswer = this.answered.event constructor( - profilesState: Pick, + context: ExtensionContext, private readonly paths: Pick, runner: AskRunner = askClaude, ) { - this.profiles = new AskProfiles(profilesState) + this.profiles = new AskProfiles(() => getSettings(context).defaultAskProfilePath) + this.settingsSubscription = onDidChangeSettings(context, () => { + void this.profiles.refresh().catch((err) => { + void window.showErrorMessage(`文件问答模型刷新失败:${err instanceof Error ? err.message : String(err)}`) + }) + }) this.asks = new QaAsks(runner) } @@ -118,7 +126,13 @@ export class CodeQa implements Disposable { const { askId, anchor, question, prompt, followUp, images } = request if (!images.every(image => isQaImageType(image.mediaType))) return '只能带 PNG、JPEG、GIF、WebP 图片。' - const profile = await this.profiles.current() + let profile: ClaudeProfile | undefined + try { + profile = await this.profiles.current() + } + catch (err) { + return err instanceof Error ? err.message : String(err) + } const owner = uri.toString() const sessionId = latestSession(this.store.get(at.folder, at.key), anchor) const run = { @@ -173,7 +187,13 @@ export class CodeQa implements Disposable { if (!images.every(image => isQaImageType(image.mediaType))) return '只能带 PNG、JPEG、GIF、WebP 图片。' const described = await describePath({ uri, folder: at.folder, isDirectory: kind === 'folder', relativePath: at.key }) - const profile = await this.profiles.current() + let profile: ClaudeProfile | undefined + try { + profile = await this.profiles.current() + } + catch (err) { + return err instanceof Error ? err.message : String(err) + } const owner = uri.toString() const key = pathQaKey(at.folder.uri.toString(), at.key) const sessionId = pathQaEntries(this.pathMap(), key).at(-1)?.sessionId @@ -242,6 +262,7 @@ export class CodeQa implements Disposable { } dispose(): void { + this.settingsSubscription.dispose() this.asks.stopAll() this.answered.dispose() this.pathChanged.dispose() diff --git a/vscode/src/panel/KanbanPanel.cache.test.ts b/vscode/src/panel/KanbanPanel.cache.test.ts new file mode 100644 index 0000000..5bbb227 --- /dev/null +++ b/vscode/src/panel/KanbanPanel.cache.test.ts @@ -0,0 +1,472 @@ +import type { ExtensionContext, Terminal, WebviewPanel } from 'vscode' +import type { Issue } from '../gitea/types' +import type { WebhookEvent } from '../webhook/eventParser' +import type { KanbanSnapshot } from './kanbanSnapshot' +import type { ExtensionToWebview, WebviewToExtension } from './messages' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getLocalIssueState } from '../issues/localState' +import { recoverOpenReviewSessions } from './handlers/reviewSessionCapture' +import { KanbanWebviewPanel } from './KanbanPanel' +import { KANBAN_SNAPSHOT_KEY } from './kanbanSnapshot' + +const mocks = vi.hoisted(() => ({ + createPanel: vi.fn(), + load: vi.fn(), + loadSingle: vi.fn(), + receiveEvent: undefined as ((event: WebhookEvent) => void) | undefined, + detect: vi.fn(), + annotate: vi.fn(), + recover: vi.fn(), + watchSession: vi.fn(), + exec: vi.fn(), + findTerminal: vi.fn(), + log: vi.fn(), + closeIssue: vi.fn(), + setActivePanel: vi.fn(), +})) +vi.mock('vscode', () => ({ + window: { + createWebviewPanel: mocks.createPanel, + onDidCloseTerminal: () => ({ dispose() {} }), + tabGroups: { onDidChangeTabs: () => ({ dispose() {} }), onDidChangeTabGroups: () => ({ dispose() {} }) }, + }, + workspace: { workspaceFolders: [{ uri: { fsPath: '/repo' } }] }, + ViewColumn: { Active: 1 }, + env: {}, + Uri: {}, + TabInputTerminal: class {}, + ThemeColor: class {}, +})) +vi.mock('node:child_process', () => ({ execFile: mocks.exec })) +vi.mock('../cc/codexSessionWatcher', () => ({ watchForCodexSession: mocks.watchSession })) +vi.mock('../auth/secrets', () => ({ getToken: async () => 'token', deleteToken: vi.fn() })) +vi.mock('../figma/importPlugin', () => ({})) +vi.mock('../git/remote', () => ({ detectRepo: mocks.detect })) +vi.mock('../gitea/api', () => ({ GiteaApiError: class extends Error {} })) +vi.mock('../gitea/issueLoader', () => ({ loadIssues: mocks.load, loadSingleIssue: mocks.loadSingle })) +vi.mock('../issues/localState', async importOriginal => ({ + ...await importOriginal(), + overlayLocalIssueState: (_ctx: unknown, issues: Issue[]) => issues, +})) +vi.mock('../issues/stateRouter', () => ({})) +vi.mock('../logging/logger', () => ({ logger: { add: mocks.log, onEntry: () => ({ dispose() {} }) } })) +vi.mock('../sessions/sessionFiles', () => ({ annotateMissingSessionIds: mocks.annotate, defaultCodexSessionsDir: () => '/sessions' })) +vi.mock('../settings/store', () => ({ + getSettings: () => ({ autoReview: true, youtrackBaseUrl: '', youtrackProjectShortName: '' }), +})) +vi.mock('../webhook/coordinator', () => ({ webhookCoordinator: { setActivePanel: mocks.setActivePanel } })) +vi.mock('../webhook/server', () => ({ + WebhookServer: class { + setSecret() {} + start() { return Promise.resolve() } + stop() { return Promise.resolve() } + onEvent(receive: (event: WebhookEvent) => void) { + mocks.receiveEvent = receive + return { dispose() {} } + } + }, +})) +vi.mock('../webview/html', () => ({ makeNonce: () => 'nonce', webviewHtml: () => '', webviewUiRoot: () => '/ui' })) +vi.mock('../youtrack/issueLoader', () => ({ loadYouTrackIssues: async () => [] })) +vi.mock('../cc/profiles', () => ({})) +vi.mock('./handlers/handoffFlow', () => ({})) +vi.mock('./handlers/issueChat', () => ({})) +vi.mock('./handlers/issues', () => ({ handleCloseIssue: mocks.closeIssue })) +vi.mock('./handlers/managedSessions', () => ({})) +vi.mock('./handlers/prFiles', () => ({})) +vi.mock('./handlers/profileAssets', () => ({})) +vi.mock('./handlers/release', () => ({})) +vi.mock('./handlers/sessionHandoff', () => ({})) +vi.mock('./handlers/sessions', async () => ({ + recoverOpenReviewSessions: mocks.recover, + stopReviewSessionCaptures: (await import('./handlers/reviewSessionCapture.js')).stopReviewSessionCaptures, +})) +vi.mock('./handlers/settings', () => ({ watchProfilesFile: () => undefined })) +vi.mock('./handlers/terminals', () => ({ + withLiveTerminalTabState: (_panel: unknown, issues: Issue[]) => issues, + findExistingTerminal: mocks.findTerminal, + trackSessionTerminal: vi.fn(), +})) +vi.mock('./handlers/toolbar', () => ({ setupGitWatcher: vi.fn() })) +vi.mock('./handlers/worktree', () => ({})) +vi.mock('./handlers/youtrackIssues', () => ({})) +vi.mock('./issueColor', () => ({})) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} + +function issue(number = 462, extra: Partial = {}): Issue { + return { id: `repo#${number}`, number, title: 'merged', column: 'review', htmlUrl: '', ...extra } +} + +function snapshot(issues = [issue()]): KanbanSnapshot { + return { issues, scope: 'all', me: 'me', avatars: { me: 'avatar' }, globalAutoReview: true, youtrackConfigured: false } +} + +function loaded(issues = [issue()]) { + return { issues, me: 'me', avatars: { me: 'avatar' } } +} + +function webviewPanel() { + let receive!: (message: WebviewToExtension) => void + let disposeListener: (() => void) | undefined + const send = vi.fn() + const panel = { + webview: { + html: '', + options: {}, + postMessage: send, + onDidReceiveMessage: (listener: typeof receive) => { + receive = listener + return { dispose() {} } + }, + }, + onDidDispose: (listener: () => void) => { + disposeListener = listener + return { + dispose: () => { disposeListener = undefined }, + } + }, + dispose: () => { + const listener = disposeListener + disposeListener = undefined + listener?.() + }, + reveal: vi.fn(), + } + return { panel, send, receive: (message: WebviewToExtension) => receive(message) } +} + +const views: ReturnType[] = [] +let stopCoordinator: (() => Promise) | undefined + +function fixture(initial: KanbanSnapshot | undefined = snapshot()) { + const disk = new Map() + if (initial) + disk.set(KANBAN_SNAPSHOT_KEY, initial) + const saveGate = deferred() + let holdSaves = false + const update = vi.fn(async (key: string, value: unknown) => { + if (holdSaves) + await saveGate.promise + if (value === undefined) + disk.delete(key) + else + disk.set(key, value) + }) + const context = { extensionUri: '/extension', workspaceState: { get: (key: string) => disk.get(key), update } } as unknown as ExtensionContext + const open = () => { + const board = KanbanWebviewPanel.createOrShow(context) + const view = views.at(-1)! + return { board, ...view } + } + return { + context, + disk, + update, + open, + holdSaves: () => { holdSaves = true }, + releaseSaves: () => saveGate.resolve(), + } +} + +function injected(view: ReturnType): KanbanSnapshot | undefined { + const json = view.panel.webview.html.match(/]+id="kanban-snapshot">(.*?)<\/script>/)?.[1] + return json ? JSON.parse(json) : undefined +} + +function updates(view: ReturnType) { + return view.send.mock.calls.map(([msg]) => msg as ExtensionToWebview).filter(msg => msg.type === 'issues/update') +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.setActivePanel.mockReset() + mocks.loadSingle.mockReset() + mocks.createPanel.mockImplementation(() => { + const view = webviewPanel() + views.push(view) + return view.panel as unknown as WebviewPanel + }) + mocks.detect.mockResolvedValue({ host: 'host', owner: 'owner', repo: 'repo' }) + mocks.load.mockImplementation(() => new Promise(() => {})) + mocks.annotate.mockImplementation(async (issues: Issue[]) => issues) + mocks.recover.mockReset().mockResolvedValue(undefined) + mocks.findTerminal.mockReset() + mocks.watchSession.mockReset() + mocks.exec.mockImplementation((_cmd, args, _options, callback) => { + callback(null, args.at(-1) === 'lstart=' ? 'Fri Oct 2 00:42:25 2026\n' : 'codex\n') + }) +}) + +afterEach(async () => { + for (const view of views.splice(0)) + view.panel.dispose() + await stopCoordinator?.() + stopCoordinator = undefined +}) + +describe('kanban panel cache integration', () => { + it('真实 postMessage 更新后立即关闭重开,保存未完成首屏也保持 done', async () => { + const f = fixture() + f.holdSaves() + const first = f.open() + first.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done', prMerged: true } }) + first.panel.dispose() + const reopened = f.open() + expect(injected(reopened)?.issues[0]).toMatchObject({ column: 'done', prMerged: true }) + expect(reopened.board.snapshotIssues()?.[0].column).toBe('done') + expect(KanbanWebviewPanel.issuesOnBoard(f.context)?.issues[0].column).toBe('done') + expect((f.disk.get(KANBAN_SNAPSHOT_KEY) as KanbanSnapshot).issues[0].column).toBe('review') + f.releaseSaves() + await vi.waitFor(() => expect((f.disk.get(KANBAN_SNAPSHOT_KEY) as KanbanSnapshot).issues[0].column).toBe('done')) + }) + + it('ready 前增量立即进内存,加载未完成也回放最新状态和待选工单,不重复保存', async () => { + const f = fixture() + const view = f.open() + view.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } }) + view.board.selectIssueAndTab(462, 'changes') + expect(view.send.mock.calls.some(([message]) => message.type === 'issue/patch')).toBe(false) + await vi.waitFor(() => expect(f.update).toHaveBeenCalledOnce()) + view.receive({ type: 'issues/refresh' }) + expect(updates(view).at(-1)?.issues[0].column).toBe('done') + expect(view.send).toHaveBeenCalledWith({ type: 'issue/select-by-number', issueNumber: 462, tab: 'changes' }) + expect(view.send).toHaveBeenCalledWith({ type: 'issues/loading' }) + await Promise.resolve() + expect(f.update).toHaveBeenCalledOnce() + }) + + it('完整列表在 ready 前也保存,ready 回放不用再次保存或重拉', async () => { + mocks.load.mockResolvedValue(loaded([issue(462, { column: 'done' })])) + const f = fixture() + const view = f.open() + await view.board.loadAndPush() + expect(view.board.snapshotIssues()?.[0].column).toBe('done') + expect(updates(view)).toEqual([]) + view.receive({ type: 'issues/refresh' }) + expect(updates(view).at(-1)?.issues[0].column).toBe('done') + expect(mocks.load).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(f.update).toHaveBeenCalledOnce()) + }) + + it('加载第一个等待之前记录增量,旧完整列表返回时不会撤销 done', async () => { + const remote = deferred<{ host: string, owner: string, repo: string }>() + mocks.detect.mockReturnValue(remote.promise) + mocks.load.mockResolvedValue(loaded()) + const f = fixture() + const view = f.open() + view.receive({ type: 'issues/refresh' }) + view.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done', prMerged: true } }) + remote.resolve({ host: 'host', owner: 'owner', repo: 'repo' }) + await view.board.loadAndPush() + expect(view.board.snapshotIssues()?.[0]).toMatchObject({ column: 'done', prMerged: true }) + expect(updates(view).at(-1)?.issues[0].column).toBe('done') + }) + + it('未知卡补丁也参与当次重放,append/remove 按消息顺序重放且不重复选卡', async () => { + const response = deferred>() + mocks.load.mockReturnValue(response.promise) + const f = fixture(snapshot([])) + const view = f.open() + view.receive({ type: 'issues/refresh' }) + view.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } }) + view.board.postMessage({ type: 'issue/remove', issueNumber: 463 }) + view.board.postMessage({ type: 'issue/append', issue: issue(464, { pr: 'old' }), select: true }) + view.board.postMessage({ type: 'issue/patch', issueNumber: 464, patch: { pr: null, column: 'done' } }) + response.resolve(loaded([issue(), issue(463)])) + await view.board.loadAndPush() + expect(view.board.snapshotIssues()).toEqual([issue(462, { column: 'done' }), issue(464, { column: 'done' })]) + expect(view.send.mock.calls.filter(([msg]) => msg.type === 'issue/append')).toHaveLength(1) + }) + + it('首次没有快照也记录未知卡补丁,完整加载后才生成快照', async () => { + const response = deferred>() + mocks.load.mockReturnValue(response.promise) + const f = fixture() + f.disk.delete(KANBAN_SNAPSHOT_KEY) + const view = f.open() + view.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } }) + expect(view.board.snapshotIssues()).toBeUndefined() + expect(f.update).not.toHaveBeenCalled() + response.resolve(loaded()) + await view.board.loadAndPush() + expect(view.board.snapshotIssues()?.[0].column).toBe('done') + }) + + it('销毁后的旧加载晚到,不改新面板内存、持久快照或发送消息', async () => { + const stale = deferred>() + mocks.load.mockReturnValueOnce(stale.promise) + const f = fixture() + const first = f.open() + const oldLoad = first.board.loadAndPush() + await vi.waitFor(() => expect(mocks.load).toHaveBeenCalledOnce()) + first.panel.dispose() + mocks.load.mockResolvedValue(loaded([issue(462, { column: 'done' })])) + const second = f.open() + await second.board.loadAndPush() + await vi.waitFor(() => expect((f.disk.get(KANBAN_SNAPSHOT_KEY) as KanbanSnapshot).issues[0].column).toBe('done')) + const sends = first.send.mock.calls.length + const writes = f.update.mock.calls.length + stale.resolve(loaded()) + await oldLoad + expect(second.board.snapshotIssues()?.[0].column).toBe('done') + expect((f.disk.get(KANBAN_SNAPSHOT_KEY) as KanbanSnapshot).issues[0].column).toBe('done') + expect(f.update).toHaveBeenCalledTimes(writes) + expect(first.send).toHaveBeenCalledTimes(sends) + }) + + it('事件等待时关闭重开,旧事件和新面板旧读取均不发布,随后补读最新状态', async () => { + const { webhookCoordinator } = await vi.importActual('../webhook/coordinator') + const eventRead = deferred() + const reopenedRead = deferred>() + mocks.load.mockResolvedValueOnce(loaded()).mockReturnValueOnce(reopenedRead.promise).mockResolvedValue(loaded([issue(462, { column: 'done' })])) + mocks.loadSingle.mockReturnValue(eventRead.promise) + mocks.setActivePanel.mockImplementation(panel => webhookCoordinator.setActivePanel(panel)) + const f = fixture() + webhookCoordinator.init(f.context) + stopCoordinator = () => webhookCoordinator.dispose() + const first = f.open() + await first.board.loadAndPush() + mocks.receiveEvent!({ kind: 'issue', action: 'closed', issueNumber: 462, title: '', body: '', htmlUrl: '', raw: {} }) + await vi.waitFor(() => expect(mocks.loadSingle).toHaveBeenCalledOnce()) + first.panel.dispose() + const reopened = f.open() + expect(injected(reopened)).toBeUndefined() + reopened.receive({ type: 'issues/refresh' }) + await vi.waitFor(() => expect(mocks.load).toHaveBeenCalledTimes(2)) + const oldLoad = reopened.board.loadAndPush() + reopened.send.mockClear() + f.update.mockClear() + eventRead.resolve(issue(462, { column: 'done' })) + await vi.waitFor(() => expect(f.update).toHaveBeenCalledWith(KANBAN_SNAPSHOT_KEY, undefined)) + expect(reopened.send.mock.calls.some(([message]) => message.type === 'issue/append')).toBe(false) + reopenedRead.resolve(loaded()) + await oldLoad + await vi.waitFor(() => expect(mocks.load).toHaveBeenCalledTimes(3)) + await vi.waitFor(() => expect(reopened.board.snapshotIssues()?.[0].column).toBe('done')) + expect(updates(reopened).map(message => message.issues[0]?.column)).toEqual(['done']) + const saved = f.update.mock.calls.filter(([key, value]) => key === KANBAN_SNAPSHOT_KEY && value !== undefined) + expect(saved.map(([, value]) => (value as KanbanSnapshot).issues[0]?.column)).toEqual(['done']) + }) + + it('最后一个异步步骤之后也拦截已销毁面板的结果', async () => { + const annotation = deferred() + mocks.load.mockResolvedValue(loaded()) + mocks.annotate.mockReturnValueOnce(annotation.promise) + const f = fixture() + const view = f.open() + const loading = view.board.loadAndPush() + await vi.waitFor(() => expect(mocks.annotate).toHaveBeenCalledOnce()) + view.panel.dispose() + annotation.resolve([issue()]) + await loading + expect(f.update).not.toHaveBeenCalled() + }) + + it('加载失败保留已收到的增量,下次全量不再受旧日志约束', async () => { + const response = deferred>() + mocks.load.mockReturnValueOnce(response.promise) + const f = fixture() + const view = f.open() + view.board.postMessage({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } }) + response.reject(new Error('network unavailable')) + await view.board.loadAndPush() + expect(view.board.snapshotIssues()?.[0].column).toBe('done') + mocks.load.mockResolvedValue(loaded([issue(462, { column: 'todo' })])) + await view.board.loadAndPush() + expect(view.board.snapshotIssues()?.[0].column).toBe('todo') + }) + + it('空快照下两张 YouTrack 审查错峰恢复,第一张补丁不得改变第二张保存来源', async () => { + const secondProcess = deferred() + const issues = [issue(41, { source: 'youtrack', externalId: 'YT-41' }), issue(42, { source: 'youtrack', externalId: 'YT-42' })] + const terminals = issues.map((item, index) => ({ + creationOptions: { + cwd: `/worktree/${item.number}`, + shellArgs: ['-ilc', 'exec "$@"', 'zsh', 'codex', '--dangerously-bypass-approvals-and-sandbox', `review ${item.number}`], + }, + processId: index === 0 ? Promise.resolve(41) : secondProcess.promise, + exitStatus: undefined, + } as unknown as Terminal)) + mocks.findTerminal.mockImplementation((_panel, name) => terminals[issues.findIndex(item => name === `issue-${item.number}-审查`)]) + mocks.watchSession.mockImplementation(async ({ cwd }) => { + const id = `session-${cwd.split('/').at(-1)}` + return { initialSessionId: id, sessionId: Promise.resolve(id) } + }) + mocks.recover.mockImplementation(recoverOpenReviewSessions) + mocks.load.mockResolvedValue(loaded(issues)) + const f = fixture() + f.disk.delete(KANBAN_SNAPSHOT_KEY) + const view = f.open() + const loading = view.board.loadAndPush() + await vi.waitFor(() => expect(getLocalIssueState(f.context, 'youtrack', 41).reviewSessionId).toBe('session-41')) + await vi.waitFor(() => expect(mocks.log).toHaveBeenCalledWith(expect.objectContaining({ message: '已捕获审查会话 session-41 (#41)' }))) + expect(view.board.snapshotIssues()).toBeUndefined() + secondProcess.resolve(42) + await loading + expect(getLocalIssueState(f.context, 'youtrack', 42).reviewSessionId).toBe('session-42') + expect(getLocalIssueState(f.context, 'gitea', 42).reviewSessionId).toBeUndefined() + expect(f.disk.has('superpowers.reviewSessionLaunch.youtrack:42')).toBe(false) + expect(f.disk.has('superpowers.reviewSessionLaunch.gitea:42')).toBe(false) + }) + + it('恢复会话失败时工单来源仍与当前快照一致', async () => { + const yt = issue(42, { source: 'youtrack', externalId: 'YT-42' }) + mocks.load.mockResolvedValue(loaded([])) + mocks.recover.mockRejectedValueOnce(new Error('recovery unavailable')) + const f = fixture(snapshot([yt])) + const view = f.open() + await view.board.loadAndPush() + expect(view.board.snapshotIssues()).toEqual([yt]) + expect(view.board.refFor(42)).toMatchObject({ source: 'youtrack', externalId: 'YT-42' }) + }) + + it('只有缓存且首次加载失败时,ready 会重新加载而不是永久只回放缓存', async () => { + mocks.load.mockRejectedValueOnce(new Error('network unavailable')) + const f = fixture() + const view = f.open() + await view.board.loadAndPush() + mocks.load.mockResolvedValue(loaded([issue(462, { column: 'done' })])) + view.receive({ type: 'issues/refresh' }) + await view.board.loadAndPush() + expect(mocks.load).toHaveBeenCalledTimes(2) + expect(view.board.snapshotIssues()?.[0].column).toBe('done') + }) + + it('成功加载只重放当次增量,下次完整刷新仍可移除卡片', async () => { + mocks.load.mockResolvedValue(loaded()) + const f = fixture() + const view = f.open() + view.board.postMessage({ type: 'issue/append', issue: issue(463) }) + await view.board.loadAndPush() + expect(view.board.snapshotIssues()?.map(i => i.number)).toEqual([462, 463]) + mocks.load.mockResolvedValue(loaded([])) + await view.board.loadAndPush() + expect(view.board.snapshotIssues()).toEqual([]) + }) + + it('缓存与增量保持工单来源和归属索引同步', () => { + const yt = issue(42, { source: 'youtrack', externalId: 'YT-42', assignees: ['other'] }) + const f = fixture(snapshot([yt])) + const view = f.open() + expect(view.board.refFor(42)).toMatchObject({ source: 'youtrack', externalId: 'YT-42' }) + expect(view.board.isYouTrackIssue(42)).toBe(true) + view.receive({ type: 'issue/close', issueNumber: 42 }) + expect(mocks.closeIssue).not.toHaveBeenCalled() + view.board.postMessage({ type: 'issue/patch', issueNumber: 42, patch: { assignees: ['me'] } }) + view.receive({ type: 'issue/close', issueNumber: 42 }) + expect(mocks.closeIssue).toHaveBeenCalledWith(view.board, 42) + view.board.postMessage({ type: 'issue/append', issue: { ...yt, externalId: 'NEW-42', assignees: ['me'] } }) + expect(view.board.refFor(42).externalId).toBe('NEW-42') + view.board.postMessage({ type: 'issue/remove', issueNumber: 42 }) + expect(view.board.isYouTrackIssue(42)).toBe(false) + }) +}) diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index be14361..c0496ee 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -7,6 +7,7 @@ import type { import type { HookContext } from '../git/worktreeHooks' import type { Issue } from '../gitea/types' import type { IssueRef } from '../issues/stateRouter' +import type { IssueUpdate } from '../shared/kanbanUpdates' import type { SessionTabKind } from './handlers/terminals' import type { IssueOwnershipFacts } from './issueOwnership' import type { KanbanScope } from './kanbanScope' @@ -25,6 +26,7 @@ import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stat import { logger } from '../logging/logger' import { annotateMissingSessionIds } from '../sessions/sessionFiles' import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, getSettings } from '../settings/store' +import { applyIssueUpdate, isIssueUpdate } from '../shared/kanbanUpdates' import { webhookCoordinator } from '../webhook/coordinator' import { makeNonce, webviewHtml, webviewUiRoot } from '../webview/html' import { loadYouTrackIssues } from '../youtrack/issueLoader' @@ -46,12 +48,18 @@ import * as youtrackIssues from './handlers/youtrackIssues' import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor' import { isIssueMine } from './issueOwnership' import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from './kanbanScope' -import { boardIssues, KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot, snapshotScriptTag } from './kanbanSnapshot' +import { boardIssues, snapshotScriptTag } from './kanbanSnapshot' +import { getKanbanState } from './kanbanState' import { isOwnerOnlyMessage } from './readOnlyGuard' /** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */ export { getDefaultProfilePath } from '../cc/profiles' +interface KanbanLoad { + updates: IssueUpdate[] + invalidated?: boolean +} + export class KanbanWebviewPanel { static readonly viewType = 'superpowers.kanbanPanel' @@ -59,23 +67,20 @@ export class KanbanWebviewPanel { private readonly panel: WebviewPanel private readonly disposables: { dispose: () => void }[] = [] - /** - * `issue.number` → its source identity, rebuilt on every `loadAndPush`. - * Lets the workflow handlers (which only receive a number from the webview) - * route state persistence to the right tracker. YouTrack numbers are - * synthetic and collision-free (see `youtrack/issueLoader`), so the number - * alone is an unambiguous key. - */ + /** 按工单号保存来源,确保状态修改发往正确的工单系统。 */ private issueRefs = new Map() /** 工单号 → 归属信息,供写操作门禁判定「这是不是我的工单」。 */ private issueOwners = new Map() /** 本机 token 对应的 Gitea login;解析失败为 undefined(此时不锁任何工单)。 */ private me: string | undefined - /** webview 已发过 issues/refresh,此后才 postMessage,避免启动时消息丢。 */ + /** 页面发过首次刷新请求后才推送卡片,避免页面启动时丢消息。 */ private webviewReady = false private pendingSelectOnReady?: { issueNumber: number, tab?: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes' } private loadInFlight: Promise | undefined - private lastIssuesUpdate: Extract | undefined + private readonly kanbanState: ReturnType + private activeLoad: KanbanLoad | undefined + private hasLoaded = false + private disposed = false /** * sessionId → its dedicated terminal in the editor area. Populated when the * user presses Enter on a card; entry is removed when the terminal is @@ -237,6 +242,8 @@ export class KanbanWebviewPanel { // internal: context 供 handler 模块访问 private constructor(public readonly context: ExtensionContext, panel: WebviewPanel) { this.panel = panel + this.kanbanState = getKanbanState(context.workspaceState) + this.syncIssueIndexes() this.panel.webview.options = { enableScripts: true, @@ -376,7 +383,10 @@ export class KanbanWebviewPanel { /** 看板上现在摆着的工单(见 boardIssues),给别处(代码阅读的「选工单」)直接用,不再加载一遍。 */ static issuesOnBoard(context: ExtensionContext): BoardIssues | undefined { - return boardIssues(KanbanWebviewPanel.current?.snapshotIssues(), context.workspaceState.get(KANBAN_SNAPSHOT_KEY)) + const current = KanbanWebviewPanel.current + const snapshot = getKanbanState(context.workspaceState).snapshot + const loaded = current?.context.workspaceState === context.workspaceState && current.hasLoaded + return boardIssues(loaded ? snapshot?.issues : undefined, snapshot) } /** @@ -409,18 +419,14 @@ export class KanbanWebviewPanel { if (msg.type === 'issues/refresh') { const first = !this.webviewReady this.webviewReady = true - if (this.loadInFlight) - return - if (first && this.lastIssuesUpdate) { - this.postMessage(this.lastIssuesUpdate) - this.postMessage({ type: 'commit/has-changes', value: this.hasGitChanges }) - if (this.pendingSelectOnReady) { - const { issueNumber, tab } = this.pendingSelectOnReady - this.pendingSelectOnReady = undefined - this.postMessage({ type: 'issue/select-by-number', issueNumber, tab }) - } + if (first) + this.replayState() + if (this.loadInFlight) { + this.sendMessage({ type: 'issues/loading' }) return } + if (first && this.hasLoaded) + return void this.loadAndPush() return } @@ -974,7 +980,7 @@ export class KanbanWebviewPanel { */ // internal: handler 模块访问 snapshotIssues(): Issue[] | undefined { - return this.lastIssuesUpdate?.issues + return this.kanbanState.snapshot?.issues } // internal: handler 模块访问 @@ -1020,19 +1026,36 @@ export class KanbanWebviewPanel { * always-on webhook coordinator can refresh us when a PR event arrives. */ async loadAndPush(): Promise { + if (this.disposed) + return if (this.loadInFlight) return this.loadInFlight - // 每次真正开跑都成对推 loading/loaded,webview 据此转刷新图标; - // 无论成功、报错还是缺 token 中途返回,finally 都会收尾。 + const load: KanbanLoad = { updates: [] } + this.activeLoad = load this.postMessage({ type: 'issues/loading' }) - this.loadInFlight = this.loadAndPushInner().finally(() => { + this.loadInFlight = this.loadAndPushInner(load).finally(() => { + if (this.activeLoad !== load) + return + this.syncIssueIndexes() + this.activeLoad = undefined this.loadInFlight = undefined this.postMessage({ type: 'issues/loaded' }) }) return this.loadInFlight } - private async loadAndPushInner(): Promise { + async reloadAfterInvalidation(): Promise { + if (this.activeLoad) + this.activeLoad.invalidated = true + try { + await this.loadInFlight + } + finally { + await this.loadAndPush() + } + } + + private async loadAndPushInner(load: KanbanLoad): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath // eslint-disable-next-line no-console console.log('[superpowers/panel] loadAndPush workspaceRoot=', workspaceRoot) @@ -1045,6 +1068,8 @@ export class KanbanWebviewPanel { } const remote = await detectRepo(workspaceRoot) + if (this.activeLoad !== load || load.invalidated) + return if (!remote) { this.postMessage({ type: 'issues/error', @@ -1055,6 +1080,8 @@ export class KanbanWebviewPanel { const { host, owner, repo } = remote const token = await getToken(this.context, host) + if (this.activeLoad !== load || load.invalidated) + return if (!token) { const s = getSettings(this.context) this.postMessage({ @@ -1085,6 +1112,7 @@ export class KanbanWebviewPanel { defaultImplementProfilePath: s.defaultImplementProfilePath, defaultTestProfilePath: s.defaultTestProfilePath, defaultPrototypeProfilePath: s.defaultPrototypeProfilePath, + defaultAskProfilePath: s.defaultAskProfilePath, conflictResolutionProfilePath: s.conflictResolutionProfilePath, commitProfilePath: await getEffectiveCommitProfilePath(this.context), prDiffSummaryProfilePath: await getEffectivePrDiffSummaryProfilePath(this.context), @@ -1101,10 +1129,15 @@ export class KanbanWebviewPanel { loadIssues({ host, token, owner, repo, workspaceRoot, scope }), this.loadYouTrackSafe(), ]) + if (this.activeLoad !== load || load.invalidated) + return + const recoveryIssues = load.updates.reduce(applyIssueUpdate, [...giteaIssues, ...youtrackList]) this.issueRefs = new Map( - [...giteaIssues, ...youtrackList].map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const), + recoveryIssues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const), ) - await sessions.recoverOpenReviewSessions(this, [...giteaIssues, ...youtrackList].map(i => i.number)) + await sessions.recoverOpenReviewSessions(this, recoveryIssues.map(i => i.number)) + if (this.activeLoad !== load || load.invalidated) + return // 本机记录(会话 id / worktree 等)叠加在共享状态之上,标出本机没有 // 会话文件的会话 id,再补 live terminal 态。 const issues = this.withLiveTerminalTabState( @@ -1112,31 +1145,21 @@ export class KanbanWebviewPanel { overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot), ), ) - this.me = me - this.issueOwners = new Map( - issues.map(i => [i.number, { poster: i.poster, assignees: i.assignees }] as const), - ) + if (this.activeLoad !== load || load.invalidated) + return const ytSettings = getSettings(this.context) const payload: Extract = { type: 'issues/update', - issues, + issues: load.updates.reduce(applyIssueUpdate, issues), scope, globalAutoReview: ytSettings.autoReview, youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '', me, avatars, } - this.lastIssuesUpdate = payload - void this.context.workspaceState.update(KANBAN_SNAPSHOT_KEY, { - issues, - scope, - me, - avatars, - globalAutoReview: payload.globalAutoReview, - youtrackConfigured: payload.youtrackConfigured, - }) + this.hasLoaded = true + this.postMessage(payload) if (this.webviewReady) { - this.postMessage(payload) // Re-publish the cached git state so a slow webview boot (or a // refresh after git events already fired) still picks up the latest // value. Cheap — the webview discards if equal. @@ -1149,6 +1172,8 @@ export class KanbanWebviewPanel { } } catch (err) { + if (this.activeLoad !== load || load.invalidated) + return if (err instanceof GiteaApiError && err.status === 401) { await deleteToken(this.context, host) const s = getSettings(this.context) @@ -1181,6 +1206,7 @@ export class KanbanWebviewPanel { defaultImplementProfilePath: s.defaultImplementProfilePath, defaultTestProfilePath: s.defaultTestProfilePath, defaultPrototypeProfilePath: s.defaultPrototypeProfilePath, + defaultAskProfilePath: s.defaultAskProfilePath, conflictResolutionProfilePath: s.conflictResolutionProfilePath, commitProfilePath: await getEffectiveCommitProfilePath(this.context), prDiffSummaryProfilePath: await getEffectivePrDiffSummaryProfilePath(this.context), @@ -1285,7 +1311,44 @@ export class KanbanWebviewPanel { } postMessage(msg: ExtensionToWebview): void { - void this.panel.webview.postMessage(msg) + if (this.disposed) + return + const incremental = isIssueUpdate(msg) + if (incremental) + this.activeLoad?.updates.push(msg) + void this.kanbanState.apply(msg) + if (incremental || msg.type === 'issues/update') { + this.syncIssueIndexes() + if (!this.webviewReady) + return + } + this.sendMessage(msg) + } + + private sendMessage(msg: ExtensionToWebview): void { + if (!this.disposed) + void this.panel.webview.postMessage(msg) + } + + private syncIssueIndexes(): void { + const snapshot = this.kanbanState.snapshot + const issues = snapshot?.issues ?? [] + this.me = snapshot?.me + this.issueRefs = new Map(issues.map(issue => [issue.number, { source: issue.source ?? 'gitea', externalId: issue.externalId }])) + this.issueOwners = new Map(issues.map(issue => [issue.number, { poster: issue.poster, assignees: issue.assignees }])) + } + + private replayState(): void { + const snapshot = this.kanbanState.snapshot + if (!this.webviewReady || !snapshot) + return + this.sendMessage({ type: 'issues/update', ...snapshot }) + this.sendMessage({ type: 'commit/has-changes', value: this.hasGitChanges }) + if (this.pendingSelectOnReady) { + const { issueNumber, tab } = this.pendingSelectOnReady + this.pendingSelectOnReady = undefined + this.sendMessage({ type: 'issue/select-by-number', issueNumber, tab }) + } } /** @@ -1355,6 +1418,10 @@ export class KanbanWebviewPanel { } private dispose(): void { + if (this.disposed) + return + this.disposed = true + this.activeLoad = undefined sessions.stopReviewSessionCaptures(this) KanbanWebviewPanel.current = undefined webhookCoordinator.setActivePanel(undefined) @@ -1370,7 +1437,7 @@ export class KanbanWebviewPanel { private buildHtml(): string { const nonce = makeNonce() const html = webviewHtml(this.panel.webview, this.context.extensionUri, 'index.html', nonce) - const snapshot = parseKanbanSnapshot(this.context.workspaceState.get(KANBAN_SNAPSHOT_KEY)) + const snapshot = this.kanbanState.snapshot if (!snapshot) return html return html.replace( diff --git a/vscode/src/panel/handlers/reviewSessionCapture.ts b/vscode/src/panel/handlers/reviewSessionCapture.ts index ddb3a00..07065a3 100644 --- a/vscode/src/panel/handlers/reviewSessionCapture.ts +++ b/vscode/src/panel/handlers/reviewSessionCapture.ts @@ -1,5 +1,6 @@ import type { Terminal } from 'vscode' import type { CodexSessionLaunch } from '../../cc/codexSessionWatcher' +import type { IssueRef } from '../../issues/stateRouter' import type { KanbanWebviewPanel } from '../KanbanPanel' import { execFile } from 'node:child_process' import * as path from 'node:path' @@ -11,6 +12,7 @@ import { logger } from '../../logging/logger' import { defaultCodexSessionsDir } from '../../sessions/sessionFiles' interface ReviewCapture { + source: NonNullable abort: AbortController launch: CodexSessionLaunch terminal?: Terminal @@ -18,7 +20,7 @@ interface ReviewCapture { } const captures = new WeakMap>() -const recoveries = new WeakMap }>>() +const recoveries = new WeakMap }>>() function captureMap(panel: KanbanWebviewPanel): Map { let map = captures.get(panel) @@ -29,17 +31,20 @@ function captureMap(panel: KanbanWebviewPanel): Map { return map } -function launchKey(panel: KanbanWebviewPanel, issueNumber: number): string { - return `superpowers.reviewSessionLaunch.${panel.refFor(issueNumber).source ?? 'gitea'}:${issueNumber}` +function launchKey(source: ReviewCapture['source'], issueNumber: number): string { + return `superpowers.reviewSessionLaunch.${source}:${issueNumber}` } /** 用户清除关联时先中止捕获,防止稍后落盘的文件把关联补回来。 */ export async function cancelReviewSessionCapture(panel: KanbanWebviewPanel, issueNumber: number): Promise { const map = captureMap(panel) - recoveries.get(panel)?.get(issueNumber)?.abort.abort() - map.get(issueNumber)?.abort.abort() + const capture = map.get(issueNumber) + const recovery = recoveries.get(panel)?.get(issueNumber) + const source = capture?.source ?? recovery?.source ?? panel.refFor(issueNumber).source ?? 'gitea' + recovery?.abort.abort() + capture?.abort.abort() map.delete(issueNumber) - await panel.context.workspaceState.update(launchKey(panel, issueNumber), undefined) + await panel.context.workspaceState.update(launchKey(source, issueNumber), undefined) } export function stopReviewSessionCaptures(panel: KanbanWebviewPanel): void { @@ -64,27 +69,32 @@ function reportCaptureFailure(issueNumber: number, err: unknown): void { async function persistCapture(panel: KanbanWebviewPanel, issueNumber: number, capture: ReviewCapture, id: string): Promise { if (capture.abort.signal.aborted || captureMap(panel).get(issueNumber) !== capture || !capture.terminal) return null - await mergeLocalIssueState(panel.context, panel.refFor(issueNumber).source, issueNumber, { reviewSessionId: id }) + await mergeLocalIssueState(panel.context, capture.source, issueNumber, { reviewSessionId: id }) if (capture.abort.signal.aborted) return null panel.reviewTerminals.set(id, capture.terminal) panel.trackSessionTerminal(capture.terminal, issueNumber, 'review') panel.postMessage({ type: 'issue/patch', issueNumber, patch: { reviewSessionId: id } }) - await panel.context.workspaceState.update(launchKey(panel, issueNumber), undefined) + await panel.context.workspaceState.update(launchKey(capture.source, issueNumber), undefined) logger.add({ level: 'info', source: 'review', message: `已捕获审查会话 ${id} (#${issueNumber})` }) return id } /** 返回前完成目录确认和首次扫描,调用方随后才创建终端。 */ -export async function prepareReviewSessionCapture(panel: KanbanWebviewPanel, issueNumber: number, launch: CodexSessionLaunch): Promise<{ +export async function prepareReviewSessionCapture( + panel: KanbanWebviewPanel, + issueNumber: number, + launch: CodexSessionLaunch, + source: ReviewCapture['source'] = panel.refFor(issueNumber).source ?? 'gitea', +): Promise<{ attach: (terminal: Terminal) => Promise initialSessionId: string | null }> { captureMap(panel).get(issueNumber)?.abort.abort() - const capture: ReviewCapture = { abort: new AbortController(), launch } + const capture: ReviewCapture = { source, abort: new AbortController(), launch } captureMap(panel).set(issueNumber, capture) try { - await panel.context.workspaceState.update(launchKey(panel, issueNumber), launch) + await panel.context.workspaceState.update(launchKey(source, issueNumber), launch) const watch = await watchForCodexSession({ ...launch, baseDir: defaultCodexSessionsDir(), @@ -214,8 +224,8 @@ function terminalProcessId(terminal: Terminal, signal: AbortSignal): Promise { - const local = getLocalIssueState(panel.context, panel.refFor(issueNumber).source, issueNumber) +async function recoverReviewSessionCaptureInner(panel: KanbanWebviewPanel, issueNumber: number, terminal: Terminal, signal: AbortSignal, source: ReviewCapture['source']): Promise { + const local = getLocalIssueState(panel.context, source, issueNumber) if (local.reviewSessionId === '' || captureMap(panel).has(issueNumber)) return null if (local.reviewSessionId) @@ -230,11 +240,11 @@ async function recoverReviewSessionCaptureInner(panel: KanbanWebviewPanel, issue const processStartedAt = await codexProcessStartedAt(pid, signal) if (signal.aborted || terminal.exitStatus !== undefined) return null - const stored = panel.context.workspaceState.get(launchKey(panel, issueNumber)) + const stored = panel.context.workspaceState.get(launchKey(source, issueNumber)) const startedAt = stored?.prompt === launch.prompt && stored.cwd === launch.cwd && Number.isFinite(stored.startedAt) ? Math.max(stored.startedAt, processStartedAt) : processStartedAt - const prepared = await prepareReviewSessionCapture(panel, issueNumber, { ...launch, startedAt }) + const prepared = await prepareReviewSessionCapture(panel, issueNumber, { ...launch, startedAt }, source) if (signal.aborted) { await cancelReviewSessionCapture(panel, issueNumber) return null @@ -259,12 +269,13 @@ export function recoverReviewSessionCapture(panel: KanbanWebviewPanel, issueNumb if (existing) return existing.result const abort = new AbortController() - const result = recoverReviewSessionCaptureInner(panel, issueNumber, terminal, abort.signal) + const source = panel.refFor(issueNumber).source ?? 'gitea' + const result = recoverReviewSessionCaptureInner(panel, issueNumber, terminal, abort.signal, source) .finally(() => { if (map.get(issueNumber)?.result === result) map.delete(issueNumber) }) - map.set(issueNumber, { abort, result }) + map.set(issueNumber, { source, abort, result }) return result } diff --git a/vscode/src/panel/handlers/settings.ts b/vscode/src/panel/handlers/settings.ts index db96628..1114d91 100644 --- a/vscode/src/panel/handlers/settings.ts +++ b/vscode/src/panel/handlers/settings.ts @@ -45,6 +45,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { defaultImplementProfilePath: string defaultTestProfilePath: string defaultPrototypeProfilePath: string + defaultAskProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string @@ -85,6 +86,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { const trimmedDefaultImplementProfile = payload.defaultImplementProfilePath.trim() const trimmedDefaultTestProfile = payload.defaultTestProfilePath.trim() const trimmedDefaultPrototypeProfile = payload.defaultPrototypeProfilePath.trim() + const trimmedDefaultAskProfile = payload.defaultAskProfilePath.trim() // conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH). const trimmedConflictProfile = payload.conflictResolutionProfilePath.trim() // 提交代码 profile:'' 有意义(= 未配置 → 提交严格失败),只 trim。 @@ -134,6 +136,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { defaultImplementProfilePath: trimmedDefaultImplementProfile, defaultTestProfilePath: trimmedDefaultTestProfile, defaultPrototypeProfilePath: trimmedDefaultPrototypeProfile, + defaultAskProfilePath: trimmedDefaultAskProfile, conflictResolutionProfilePath: trimmedConflictProfile, commitProfilePath: trimmedCommitProfile, prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile, @@ -168,6 +171,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { defaultImplementProfilePath: trimmedDefaultImplementProfile, defaultTestProfilePath: trimmedDefaultTestProfile, defaultPrototypeProfilePath: trimmedDefaultPrototypeProfile, + defaultAskProfilePath: trimmedDefaultAskProfile, conflictResolutionProfilePath: trimmedConflictProfile, commitProfilePath: trimmedCommitProfile, prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile, @@ -178,8 +182,6 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: { youtrackProjectShortName: trimmedYtProject, youtrackCloseCommand: trimmedYtClose, }) - // 保存后立刻让 listClaudeProfiles / getDefaultProfilePath 吃到新目录。 - setProfilesDirOverride(trimmedProfilesDirectory || undefined) void handleProfilesList(panel) if (!keepExisting) { await setToken(panel.context, trimmedHost, trimmedToken) @@ -279,6 +281,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom defaultImplementProfilePath: s.defaultImplementProfilePath, defaultTestProfilePath: s.defaultTestProfilePath, defaultPrototypeProfilePath: s.defaultPrototypeProfilePath, + defaultAskProfilePath: s.defaultAskProfilePath, conflictResolutionProfilePath: s.conflictResolutionProfilePath, commitProfilePath, prDiffSummaryProfilePath, diff --git a/vscode/src/panel/kanbanState.test.ts b/vscode/src/panel/kanbanState.test.ts new file mode 100644 index 0000000..fcecbc9 --- /dev/null +++ b/vscode/src/panel/kanbanState.test.ts @@ -0,0 +1,129 @@ +import type { Memento } from 'vscode' +import type { Issue } from '../gitea/types' +import type { KanbanSnapshot } from './kanbanSnapshot' +import { describe, expect, it, vi } from 'vitest' +import { KANBAN_SNAPSHOT_KEY } from './kanbanSnapshot' +import { getKanbanState } from './kanbanState' + +const log = vi.hoisted(() => vi.fn()) +vi.mock('../logging/logger', () => ({ logger: { add: log } })) + +const snapshot: KanbanSnapshot = { + issues: [{ id: 'repo#462', number: 462, title: 'merged', column: 'review', htmlUrl: '', pr: '470' }], + scope: 'all', + me: 'owner', + avatars: { owner: 'data:image/png;base64,a' }, + globalAutoReview: true, + youtrackConfigured: true, +} + +function fixture(initial: unknown = snapshot) { + const disk = new Map([[KANBAN_SNAPSHOT_KEY, initial]]) + const pending: Array<{ key: string, value: unknown, resolve: () => void, reject: (error: Error) => void }> = [] + const workspaceState = { + keys: () => [...disk.keys()], + get: vi.fn((key: string) => disk.get(key)), + update: vi.fn((key: string, value: unknown) => new Promise((resolve, reject) => { + pending.push({ + key, + value, + resolve: () => { + if (value === undefined) + disk.delete(key) + else + disk.set(key, value) + resolve() + }, + reject, + }) + })), + } as unknown as Memento + return { disk, workspaceState, pending, state: getKanbanState(workspaceState) } +} + +describe('workspace kanban state', () => { + it('同工作区共享最新内存,保存严格串行且保留快照元数据', async () => { + const f = fixture() + const first = f.state.apply({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done', pr: null } }) + const second = f.state.apply({ type: 'issue/append', issue: { ...snapshot.issues[0], number: 463 } }) + expect(getKanbanState(f.workspaceState)).toBe(f.state) + expect(f.state.snapshot).toEqual({ ...snapshot, issues: [expect.objectContaining({ column: 'done' }), expect.objectContaining({ number: 463 })] }) + expect(f.state.snapshot!.issues[0]).not.toHaveProperty('pr') + await vi.waitFor(() => expect(f.pending).toHaveLength(1)) + expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(snapshot) + f.pending[0].resolve() + await first + await vi.waitFor(() => expect(f.pending).toHaveLength(2)) + f.pending[1].resolve() + await second + expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toEqual(f.state.snapshot) + }) + + it('写失败有日志且不丢最新内存,后续保存仍可成功', async () => { + const f = fixture() + const first = f.state.apply({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } }) + const second = f.state.apply({ type: 'issue/remove', issueNumber: 462 }) + await vi.waitFor(() => expect(f.pending).toHaveLength(1)) + f.pending[0].reject(new Error('disk unavailable')) + await first + expect(log).toHaveBeenCalledWith(expect.objectContaining({ level: 'error', details: expect.stringContaining('disk unavailable') })) + await vi.waitFor(() => expect(f.pending).toHaveLength(2)) + expect(f.state.snapshot!.issues).toEqual([]) + f.pending[1].resolve() + await second + expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toEqual({ ...snapshot, issues: [] }) + }) + + it('失效立即清内存,删除排在旧保存后,旧保存不能复活快照', async () => { + const f = fixture() + const saving = f.state.apply({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } }) + const invalidating = f.state.invalidate() + expect(getKanbanState(f.workspaceState).snapshot).toBeUndefined() + await vi.waitFor(() => expect(f.pending).toHaveLength(1)) + f.pending[0].resolve() + await saving + await vi.waitFor(() => expect(f.pending).toHaveLength(2)) + expect(f.pending[1].value).toBeUndefined() + expect(getKanbanState(f.workspaceState).snapshot).toBeUndefined() + f.pending[1].resolve() + await invalidating + expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false) + }) + + it.each([undefined, { issues: [], scope: 'invalid' }])('无有效快照时增量与无关消息不凭空创建快照 (%s)', async (initial) => { + const f = fixture(initial === undefined ? null : initial) + await f.state.apply({ type: 'issue/append', issue: snapshot.issues[0] }) + await f.state.apply({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } }) + await f.state.apply({ type: 'issue/remove', issueNumber: 462 }) + await f.state.apply({ type: 'issues/loaded' }) + expect(f.state.snapshot).toBeUndefined() + expect(f.workspaceState.update).not.toHaveBeenCalled() + }) + + it('完整列表可初始化空快照,无关消息不再次保存', async () => { + const f = fixture(null) + const saving = f.state.apply({ type: 'issues/update', ...snapshot, issues: [] }) + expect(f.state.snapshot).toEqual({ ...snapshot, issues: [] }) + await vi.waitFor(() => expect(f.pending).toHaveLength(1)) + f.pending[0].resolve() + await saving + await f.state.apply({ type: 'issues/loading' }) + expect(f.workspaceState.update).toHaveBeenCalledOnce() + }) + + it('完整列表替换快照,未知工单补丁不新增,工作区互相隔离', async () => { + const f = fixture() + const other = fixture() + const issues: Issue[] = [{ ...snapshot.issues[0], number: 99, column: 'todo' }] + const saving = f.state.apply({ type: 'issues/update', ...snapshot, issues, scope: 'mine' }) + const patching = f.state.apply({ type: 'issue/patch', issueNumber: 462, patch: { column: 'done' } }) + expect(f.state.snapshot).toEqual({ ...snapshot, scope: 'mine', issues }) + expect(other.state.snapshot).toEqual(snapshot) + await vi.waitFor(() => expect(f.pending).toHaveLength(1)) + f.pending[0].resolve() + await saving + await vi.waitFor(() => expect(f.pending).toHaveLength(2)) + f.pending[1].resolve() + await patching + }) +}) diff --git a/vscode/src/panel/kanbanState.ts b/vscode/src/panel/kanbanState.ts new file mode 100644 index 0000000..2e385e9 --- /dev/null +++ b/vscode/src/panel/kanbanState.ts @@ -0,0 +1,63 @@ +import type { Memento } from 'vscode' +import type { KanbanSnapshot } from './kanbanSnapshot' +import type { ExtensionToWebview } from './messages' +import { logger } from '../logging/logger' +import { applyIssueUpdate, isIssueUpdate } from '../shared/kanbanUpdates' +import { KANBAN_SNAPSHOT_KEY, parseKanbanSnapshot } from './kanbanSnapshot' + +class KanbanState { + private current: KanbanSnapshot | undefined + private writes: Promise = Promise.resolve() + + constructor(private readonly workspaceState: Memento) { + this.current = parseKanbanSnapshot(workspaceState.get(KANBAN_SNAPSHOT_KEY)) ?? undefined + } + + get snapshot(): KanbanSnapshot | undefined { + return this.current + } + + apply(message: ExtensionToWebview): Promise { + if (message.type === 'issues/update') { + const { type: _, ...snapshot } = message + this.current = snapshot + } + else if (isIssueUpdate(message) && this.current) { + this.current = { ...this.current, issues: applyIssueUpdate(this.current.issues, message) } + } + else { + return this.writes + } + return this.persist(this.current) + } + + invalidate(): Promise { + this.current = undefined + return this.persist(undefined) + } + + private persist(snapshot: KanbanSnapshot | undefined): Promise { + this.writes = this.writes + .then(() => this.workspaceState.update(KANBAN_SNAPSHOT_KEY, snapshot)) + .catch((error: unknown) => { + logger.add({ + level: 'error', + source: 'kanban', + message: '保存看板快照失败', + details: error instanceof Error ? error.message : String(error), + }) + }) + return this.writes + } +} + +const states = new WeakMap() + +export function getKanbanState(workspaceState: Memento): KanbanState { + let state = states.get(workspaceState) + if (!state) { + state = new KanbanState(workspaceState) + states.set(workspaceState, state) + } + return state +} diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 924a99a..e8c3843 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -95,6 +95,7 @@ export type ExtensionToWebview defaultImplementProfilePath: string defaultTestProfilePath: string defaultPrototypeProfilePath: string + defaultAskProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string @@ -180,6 +181,7 @@ export type WebviewToExtension defaultImplementProfilePath: string defaultTestProfilePath: string defaultPrototypeProfilePath: string + defaultAskProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string diff --git a/vscode/src/settings/store.test.ts b/vscode/src/settings/store.test.ts index 95c7374..03c6771 100644 --- a/vscode/src/settings/store.test.ts +++ b/vscode/src/settings/store.test.ts @@ -38,6 +38,51 @@ function fakeCtx(dir: string, memento: Record = {}): ExtensionC } describe('saveSettings 落盘', () => { + it('目录配置落盘后才切换候选目录,随后通知问答刷新', async () => { + const ctx = fakeCtx(tempDir()) + const profileModule = await import('../cc/profiles.js') + const setDirectory = vi.spyOn(profileModule, 'setProfilesDirOverride').mockImplementation(() => { + expect(getSettings(ctx).profilesDirectory).toBe('/new/profiles') + }) + const { onDidChangeSettings } = await import('./store.js') + const subscription = onDidChangeSettings(ctx, () => { + expect(setDirectory).toHaveBeenCalledWith('/new/profiles') + }) + try { + await saveSettings(ctx, { profilesDirectory: '/new/profiles' }) + expect(setDirectory).toHaveBeenCalledOnce() + } + finally { + subscription.dispose() + setDirectory.mockRestore() + } + }) + + it('文件问答默认模型独立落盘,重启读取且不受旧的上次选择影响', async () => { + const dir = tempDir() + const ctx = fakeCtx(dir, { 'superpowers.codeReader.askProfile': 'sonnet' }) + expect(getSettings(ctx).defaultAskProfilePath).toBe('') + await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' }) + expect(JSON.parse(readFileSync(path.join(dir, 'settings.json'), 'utf8')).defaultAskProfilePath).toBe('/p/dsflash.json') + expect(getSettings(fakeCtx(dir)).defaultAskProfilePath).toBe('/p/dsflash.json') + await saveSettings(ctx, { webhookPort: 9000 }) + expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/dsflash.json') + await saveSettings(ctx, { defaultAskProfilePath: '' }) + expect(getSettings(fakeCtx(dir)).defaultAskProfilePath).toBe('') + }) + + it('默认配置保存后通知订阅者,取消订阅后不再通知', async () => { + const ctx = fakeCtx(tempDir()) + const { onDidChangeSettings } = await import('./store.js') + const listener = vi.fn(() => getSettings(ctx).defaultAskProfilePath) + const subscription = onDidChangeSettings(ctx, listener) + await saveSettings(ctx, { defaultAskProfilePath: '/p/sonnet.json' }) + expect(listener).toHaveLastReturnedWith('/p/sonnet.json') + subscription.dispose() + await saveSettings(ctx, { defaultAskProfilePath: '' }) + expect(listener).toHaveBeenCalledTimes(1) + }) + it('写入 globalStorage 的 settings.json,清空 memento 后仍能读到 commitProfilePath', async () => { const dir = tempDir() const ctx = fakeCtx(dir) diff --git a/vscode/src/settings/store.ts b/vscode/src/settings/store.ts index fdc29f0..9865154 100644 --- a/vscode/src/settings/store.ts +++ b/vscode/src/settings/store.ts @@ -11,12 +11,13 @@ * a field. */ -import type { ExtensionContext } from 'vscode' +import type { Disposable, ExtensionContext } from 'vscode' import type { MigrationNote } from './promptMigration' +import { EventEmitter } from 'node:events' import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import path from 'node:path' import { seedCommitProfilePath } from '../cc/commitProfile' -import { listClaudeProfiles } from '../cc/profiles' +import { listClaudeProfiles, setProfilesDirOverride } from '../cc/profiles' import { normalizeReleasePrefix } from '../git/release' import { PREVIOUS_DEFAULT_REVIEW_PROMPT } from './previousReviewPrompt' import { planPromptMigration, planReviewPromptMigration } from './promptMigration' @@ -210,6 +211,7 @@ export interface Settings { defaultTestProfilePath: string /** 原型会话的默认 Claude profile;工单级「原型配置文件」为空时用它。 */ defaultPrototypeProfilePath: string + defaultAskProfilePath: string /** * Claude settings profile path for the conflict-resolution cc session * (drag to 完成 when PR merge conflicts). Empty = DEFAULT_PROFILE_PATH. @@ -314,6 +316,7 @@ function defaults(ctx: ExtensionContext): Settings { defaultImplementProfilePath: '', defaultTestProfilePath: '', defaultPrototypeProfilePath: '', + defaultAskProfilePath: '', conflictResolutionProfilePath: '', commitProfilePath: '', prDiffSummaryProfilePath: '', @@ -429,6 +432,9 @@ export function getSettings(ctx: ExtensionContext): Settings { const defaultPrototypeProfilePath = typeof stored.defaultPrototypeProfilePath === 'string' ? stored.defaultPrototypeProfilePath : base.defaultPrototypeProfilePath + const defaultAskProfilePath = typeof stored.defaultAskProfilePath === 'string' + ? stored.defaultAskProfilePath.trim() + : base.defaultAskProfilePath // conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH at // launch time), so don't coerce. Non-string legacy values collapse to ''. const conflictResolutionProfilePath = typeof stored.conflictResolutionProfilePath === 'string' @@ -489,6 +495,7 @@ export function getSettings(ctx: ExtensionContext): Settings { defaultImplementProfilePath, defaultTestProfilePath, defaultPrototypeProfilePath, + defaultAskProfilePath, conflictResolutionProfilePath, commitProfilePath, prDiffSummaryProfilePath, @@ -524,9 +531,24 @@ export async function getEffectivePrDiffSummaryProfilePath(ctx: ExtensionContext return seedCommitProfilePath(undefined, profiles) } +const settingsChanges = new EventEmitter() + +export function onDidChangeSettings(ctx: ExtensionContext, listener: () => void): Disposable { + const key = settingsFilePath(ctx) + settingsChanges.on(key, listener) + return { + dispose: () => { + settingsChanges.off(key, listener) + }, + } +} + export async function saveSettings(ctx: ExtensionContext, next: Partial): Promise { const merged: Partial = { ...readStoredSettings(ctx), ...next } writeStoredSettings(ctx, merged) + if (next.profilesDirectory !== undefined) + setProfilesDirOverride(next.profilesDirectory.trim() || undefined) + settingsChanges.emit(settingsFilePath(ctx)) } /** diff --git a/vscode/src/shared/kanbanUpdates.test.ts b/vscode/src/shared/kanbanUpdates.test.ts new file mode 100644 index 0000000..52995b8 --- /dev/null +++ b/vscode/src/shared/kanbanUpdates.test.ts @@ -0,0 +1,49 @@ +import type { Issue } from '../gitea/types' +import { describe, expect, it } from 'vitest' +import { applyIssueUpdate } from './kanbanUpdates' + +function issue(number: number, extra: Partial = {}): Issue { + return { id: `repo#${number}`, number, title: `Issue ${number}`, column: 'review', htmlUrl: '', ...extra } +} + +describe('applyIssueUpdate', () => { + it('按 number 修改并删除 null 字段,不改变原数组、卡片或补丁', () => { + const first = Object.freeze(issue(1, { pr: '12', reviewSessionId: 'session' })) + const second = Object.freeze(issue(2)) + const current = Object.freeze([first, second]) + const patch = Object.freeze({ column: 'done', pr: null, reviewSessionId: null }) + const result = applyIssueUpdate(current, { type: 'issue/patch', issueNumber: 1, patch }) + expect(result).toEqual([issue(1, { column: 'done' }), second]) + expect(result[0]).not.toHaveProperty('pr') + expect(result[0]).not.toHaveProperty('reviewSessionId') + expect(result[1]).toBe(second) + expect(first.pr).toBe('12') + expect(patch.pr).toBeNull() + }) + + it('未知工单的补丁不新增卡片', () => { + const current = [issue(1)] + expect(applyIssueUpdate(current, { type: 'issue/patch', issueNumber: 2, patch: { column: 'done' } })).toEqual(current) + }) + + it('append 同号卡片原位整对象替换,不保留旧字段', () => { + const current = Object.freeze([issue(1), issue(2, { pr: '12' }), issue(3)]) + const replacement = Object.freeze(issue(2, { id: 'new-id', column: 'done' })) + const result = applyIssueUpdate(current, { type: 'issue/append', issue: replacement }) + expect(result).toEqual([current[0], replacement, current[2]]) + expect(result[1]).not.toHaveProperty('pr') + expect(current[1].pr).toBe('12') + }) + + it('append 新编号即追加,不按 id 去重', () => { + const first = issue(1) + const next = issue(2, { id: first.id }) + expect(applyIssueUpdate([first], { type: 'issue/append', issue: next })).toEqual([first, next]) + }) + + it('remove 删除所有同号卡片,不改变原数组', () => { + const current = Object.freeze([issue(1), issue(2), issue(1, { id: 'another' })]) + expect(applyIssueUpdate(current, { type: 'issue/remove', issueNumber: 1 })).toEqual([current[1]]) + expect(current).toHaveLength(3) + }) +}) diff --git a/vscode/src/shared/kanbanUpdates.ts b/vscode/src/shared/kanbanUpdates.ts new file mode 100644 index 0000000..f994a16 --- /dev/null +++ b/vscode/src/shared/kanbanUpdates.ts @@ -0,0 +1,32 @@ +import type { Issue } from '../gitea/types' + +export type IssueUpdate + = | { type: 'issue/patch', issueNumber: number, patch: Record } + | { type: 'issue/append', issue: Issue } + | { type: 'issue/remove', issueNumber: number } + +export function isIssueUpdate(message: { type: string }): message is IssueUpdate { + return message.type === 'issue/patch' || message.type === 'issue/append' || message.type === 'issue/remove' +} + +export function applyIssueUpdate(issues: readonly Issue[], message: IssueUpdate): Issue[] { + switch (message.type) { + case 'issue/patch': + return issues.map((issue) => { + if (issue.number !== message.issueNumber) + return issue + const next = { ...issue, ...message.patch } as Issue & Record + for (const [key, value] of Object.entries(message.patch)) { + if (value === null) + delete next[key] + } + return next + }) + case 'issue/append': + return issues.some(issue => issue.number === message.issue.number) + ? issues.map(issue => issue.number === message.issue.number ? message.issue : issue) + : [...issues, message.issue] + case 'issue/remove': + return issues.filter(issue => issue.number !== message.issueNumber) + } +} diff --git a/vscode/src/webhook/coordinator.cache.test.ts b/vscode/src/webhook/coordinator.cache.test.ts new file mode 100644 index 0000000..84a693a --- /dev/null +++ b/vscode/src/webhook/coordinator.cache.test.ts @@ -0,0 +1,198 @@ +import type { ExtensionContext } from 'vscode' +import type { KanbanWebviewPanel } from '../panel/KanbanPanel' +import type { WebhookEvent } from './eventParser' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { KANBAN_SNAPSHOT_KEY } from '../panel/kanbanSnapshot' +import { getKanbanState } from '../panel/kanbanState' +import { webhookCoordinator } from './coordinator' + +const mocks = vi.hoisted(() => ({ + receive: undefined as ((event: WebhookEvent) => void) | undefined, + token: vi.fn(), + loadSingle: vi.fn(), + readState: vi.fn(), + log: vi.fn(), +})) +vi.mock('vscode', () => ({ env: {}, Uri: {}, window: { showInformationMessage: async () => undefined }, workspace: { workspaceFolders: [{ uri: { fsPath: '/repo' } }] } })) +vi.mock('../logging/logger', () => ({ logger: { add: mocks.log } })) +vi.mock('../auth/secrets', () => ({ getToken: mocks.token })) +vi.mock('../auth/identity', () => ({ loginForToken: async () => 'me' })) +vi.mock('../cc/prompts', () => ({ renderReviewPrompt: () => 'review' })) +vi.mock('../git/remote', () => ({ detectRepo: async () => ({ host: 'host', owner: 'owner', repo: 'repo' }) })) +vi.mock('../gitea/api', () => ({ getIssue: async () => ({ assignees: [{ login: 'me' }] }) })) +vi.mock('../gitea/issueLoader', () => ({ loadSingleIssue: mocks.loadSingle })) +vi.mock('../gitea/stateJson', async importOriginal => ({ + hasStateColumn: (await importOriginal()).hasStateColumn, + readStateJsonComment: mocks.readState, + mergeStateJsonComment: async () => undefined, +})) +vi.mock('../issues/localState', () => ({ overlayLocalIssueState: (_ctx: unknown, issues: unknown[]) => issues, getLocalIssueState: () => ({}) })) +vi.mock('../panel/handlers/issueChat', () => ({})) +vi.mock('../panel/handlers/terminals', () => ({ hasLiveIssueSessionTerminal: () => false })) +vi.mock('../sessions/sessionFiles', () => ({ annotateMissingSessionIds: async (issues: unknown[]) => issues })) +vi.mock('../settings/store', () => ({ getSettings: () => ({ webhookSecret: '', webhookPort: 12345, devBranch: 'main', releaseBranchPrefix: 'release/', autoReview: true }) })) +vi.mock('./server', () => ({ + WebhookServer: class { + setSecret() {} + start() { return Promise.resolve() } + stop() { return Promise.resolve() } + onEvent(receive: (event: WebhookEvent) => void) { + mocks.receive = receive + return { dispose() {} } + } + }, +})) + +const cached = { issues: [], scope: 'all', globalAutoReview: false, youtrackConfigured: false } +const events: WebhookEvent[] = [ + { kind: 'issue', action: 'closed', issueNumber: 462, title: '', body: '', htmlUrl: '', raw: {} }, + { kind: 'issue_comment', action: 'created', issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', raw: {} }, + { kind: 'pr', action: 'closed', issueNumber: 462, pr: '470', branch: '', title: '', body: '', htmlUrl: '', raw: {} }, +] + +function fixture() { + const disk = new Map([[KANBAN_SNAPSHOT_KEY, cached]]) + const update = vi.fn(async (key: string, value: unknown) => { + if (value === undefined) + disk.delete(key) + else + disk.set(key, value) + }) + const context = { workspaceState: { get: (key: string) => disk.get(key), update } } as unknown as ExtensionContext + webhookCoordinator.init(context) + return { disk, update, context } +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.token.mockReset().mockResolvedValue(undefined) + mocks.loadSingle.mockReset() + mocks.readState.mockReset().mockResolvedValue({ column: 'review', autoReview: true }) +}) +afterEach(async () => { + await webhookCoordinator.dispose() +}) + +describe('coordinator cache invalidation', () => { + it.each(events)('无面板收到 $kind 后删除持久快照', async (event) => { + const f = fixture() + mocks.receive!(event) + await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false)) + expect(f.update).toHaveBeenCalledWith(KANBAN_SNAPSHOT_KEY, undefined) + }) + + it('无面板事件立即清共享内存,删除必须等待正在保存的快照', async () => { + const f = fixture() + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + f.update.mockImplementationOnce(async (key, value) => { + await gate + f.disk.set(key, value) + }) + const state = getKanbanState(f.context.workspaceState) + const saving = state.apply({ type: 'issues/update', issues: [], scope: 'mine', globalAutoReview: true, youtrackConfigured: false }) + await vi.waitFor(() => expect(f.update).toHaveBeenCalledOnce()) + mocks.receive!(events[0]) + expect(getKanbanState(f.context.workspaceState).snapshot).toBeUndefined() + expect(f.update).toHaveBeenCalledOnce() + release() + await saving + await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false)) + expect(f.update).toHaveBeenNthCalledWith(2, KANBAN_SNAPSHOT_KEY, undefined) + }) + + it.each(['edited', 'deleted'])('无面板收到状态评论 %s 清除缓存', async (action) => { + const f = fixture() + const state = getKanbanState(f.context.workspaceState) + mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '{"column":"done"}', commentAuthor: 'me', commentHtmlUrl: '', raw: {} }) + expect(state.snapshot).toBeUndefined() + await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false)) + }) + + it.each(['created', 'edited', 'deleted'])('无面板收到普通讨论 %s 保留有效缓存', async (action) => { + const f = fixture() + const state = getKanbanState(f.context.workspaceState) + const snapshot = state.snapshot + mocks.receive!({ kind: 'issue_comment', action, issueNumber: 462, prNumber: undefined, commentBody: '谢谢', commentAuthor: 'other', commentHtmlUrl: '', raw: {} }) + await Promise.resolve() + expect(state.snapshot).toBe(snapshot) + expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached) + expect(f.update).not.toHaveBeenCalled() + }) + + it('读取单卡期间关闭面板,事件结束后不保留过期快照', async () => { + const f = fixture() + const state = getKanbanState(f.context.workspaceState) + let release!: (value: unknown) => void + mocks.token.mockResolvedValue('token') + mocks.loadSingle.mockReturnValue(new Promise((resolve) => { + release = resolve + })) + const panel = { noteIssueOwnership: vi.fn(), postMessage: vi.fn() } as unknown as KanbanWebviewPanel + webhookCoordinator.setActivePanel(panel) + mocks.receive!(events[0]) + await vi.waitFor(() => expect(mocks.loadSingle).toHaveBeenCalledOnce()) + webhookCoordinator.setActivePanel(undefined) + release({ id: 'repo#462', number: 462, title: 'merged', column: 'done', htmlUrl: '' }) + await vi.waitFor(() => expect(state.snapshot).toBeUndefined()) + await vi.waitFor(() => expect(f.disk.has(KANBAN_SNAPSHOT_KEY)).toBe(false)) + }) + + it('同一面板持续打开时仍可启动自动审查', async () => { + fixture() + mocks.token.mockResolvedValue('token') + const panel = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined) } + webhookCoordinator.setActivePanel(panel as unknown as KanbanWebviewPanel) + mocks.receive!({ kind: 'pr', action: 'opened', issueNumber: 462, pr: '470', branch: 'feature', title: '', body: 'Closes #462', htmlUrl: '', raw: {} }) + await vi.waitFor(() => expect(panel.triggerAutoReviewTab).toHaveBeenCalledOnce()) + }) + + it.each([ + { pendingRead: 1, reopen: false }, + { pendingRead: 1, reopen: true }, + { pendingRead: 2, reopen: false }, + { pendingRead: 2, reopen: true }, + { pendingRead: 3, reopen: false }, + { pendingRead: 3, reopen: true }, + ])('自动审查第 $pendingRead 次状态读取期间关闭,重开=$reopen,不启动或转移旧任务', async ({ pendingRead, reopen }) => { + fixture() + let release!: (value: Record) => void + const pending = new Promise>((resolve) => { + release = resolve + }) + let reads = 0 + mocks.token.mockResolvedValue('token') + mocks.readState.mockImplementation(() => ++reads === pendingRead ? pending : Promise.resolve({ column: 'review', autoReview: true })) + const first = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined) } + const second = { postMessage: vi.fn(), triggerAutoReviewTab: vi.fn().mockResolvedValue(undefined), reloadAfterInvalidation: vi.fn().mockResolvedValue(undefined) } + webhookCoordinator.setActivePanel(first as unknown as KanbanWebviewPanel) + mocks.receive!({ kind: 'pr', action: 'opened', issueNumber: 462, pr: '470', branch: 'feature', title: '', body: 'Closes #462', htmlUrl: '', raw: {} }) + await vi.waitFor(() => expect(mocks.readState).toHaveBeenCalledTimes(pendingRead)) + webhookCoordinator.setActivePanel(undefined) + if (reopen) + webhookCoordinator.setActivePanel(second as unknown as KanbanWebviewPanel) + release({ column: 'review', autoReview: true }) + await vi.waitFor(() => expect(mocks.log.mock.calls.some(([entry]) => entry.message.includes('跳过审查 #462') || entry.message.includes('审查 tab 已就绪 #462'))).toBe(true)) + expect(first.triggerAutoReviewTab).not.toHaveBeenCalled() + expect(second.triggerAutoReviewTab).not.toHaveBeenCalled() + }) + + it('普通 push 不清看板', async () => { + const f = fixture() + mocks.receive!({ kind: 'push', branch: 'main', raw: {} }) + await Promise.resolve() + expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached) + expect(f.update).not.toHaveBeenCalled() + }) + + it('有面板时保留快照,由现有事件处理路径更新', async () => { + const f = fixture() + webhookCoordinator.setActivePanel({} as KanbanWebviewPanel) + mocks.receive!(events[2]) + await Promise.resolve() + expect(f.disk.get(KANBAN_SNAPSHOT_KEY)).toBe(cached) + expect(f.update).not.toHaveBeenCalled() + }) +}) diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index fa71f33..3c246db 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -13,6 +13,7 @@ import type { ExtensionContext } from 'vscode' import type { KanbanWebviewPanel } from '../panel/KanbanPanel' import type { KanbanScope } from '../panel/kanbanScope' import type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, WebhookEvent } from './server' +import { AsyncLocalStorage } from 'node:async_hooks' import { execFile } from 'node:child_process' import { promises as fsp } from 'node:fs' import { env, Uri, window, workspace } from 'vscode' @@ -31,6 +32,7 @@ import { handoffRequestSystemNotice } from '../panel/handlers/handoffRequest' import * as issueChat from '../panel/handlers/issueChat' import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals' import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope' +import { getKanbanState } from '../panel/kanbanState' import { annotateMissingSessionIds } from '../sessions/sessionFiles' import { getSettings } from '../settings/store' import { decideEventOwnership } from './eventOwnership' @@ -100,12 +102,29 @@ function describeMarkers(patch: MarkerPatch): string { */ type ResolvedWebhookEvent = Omit & { issueNumber: number } +function changesKanban(event: WebhookEvent): boolean { + switch (event.kind) { + case 'push': + return false + case 'issue': + return ['opened', 'reopened', 'edited', 'closed', 'assigned', 'unassigned'].includes(event.action) + case 'issue_comment': + return ['created', 'edited', 'deleted'].includes(event.action) + && event.prNumber === undefined && hasStateColumn(event.commentBody) + case 'pr': + return ['opened', 'reopened', 'synchronize', 'synchronized', 'closed', 'deleted'].includes(event.action) + && (event.issueNumber !== undefined || parseClosingIssueNumber(event.body) !== undefined || event.branch !== '') + } +} + class WebhookCoordinator { private ctx?: ExtensionContext private server?: WebhookServer // kept for legacy cleanup; new flow uses one shared webhook private pendingHooks = new Map() - private activePanel: KanbanWebviewPanel | undefined + private livePanel: KanbanWebviewPanel | undefined + private readonly eventPanels = new AsyncLocalStorage<{ panel: KanbanWebviewPanel | undefined }>() + private boardEventsInFlight = 0 private initialized = false private eventSubscription?: { dispose: () => void } /** 同 issue 自动审查启动中不重入(opened 与 synchronize 常连发)。 */ @@ -190,7 +209,7 @@ class WebhookCoordinator { }) } } - this.activePanel = undefined + this.livePanel = undefined this.initialized = false logger.add({ level: 'info', @@ -252,7 +271,16 @@ class WebhookCoordinator { /** Webview panel binding for refresh + toast. Pass undefined when closing. */ setActivePanel(p: KanbanWebviewPanel | undefined): void { - this.activePanel = p + if (this.livePanel && this.livePanel !== p && this.boardEventsInFlight > 0 && this.ctx) + void getKanbanState(this.ctx.workspaceState).invalidate() + this.livePanel = p + } + + private get activePanel(): KanbanWebviewPanel | undefined { + const event = this.eventPanels.getStore() + if (event && event.panel !== this.livePanel) + return undefined + return this.livePanel } private async persist(): Promise { @@ -432,8 +460,31 @@ class WebhookCoordinator { return } + const ctx = this.ctx + const panel = this.livePanel + const affectsBoard = changesKanban(event) + if (affectsBoard) { + this.boardEventsInFlight++ + if (!panel) + void getKanbanState(ctx.workspaceState).invalidate() + } + try { + await this.eventPanels.run({ panel }, () => this.dispatchEvent(event, ctx)) + } + finally { + if (affectsBoard) { + this.boardEventsInFlight-- + if (this.livePanel !== panel) { + void getKanbanState(ctx.workspaceState).invalidate() + void this.livePanel?.reloadAfterInvalidation() + } + } + } + } + + private async dispatchEvent(event: WebhookEvent, ctx: ExtensionContext): Promise { if (event.kind === 'push') { - const s = getSettings(this.ctx) + const s = getSettings(ctx) logger.add({ level: 'info', source: 'webhook', @@ -693,11 +744,12 @@ class WebhookCoordinator { issueNumber: event.issueNumber, }) const issue = loaded ? (await annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null - if (issue) { - this.activePanel.noteIssueOwnership(issue) - this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined }) + const panel = this.activePanel + if (issue && panel) { + panel.noteIssueOwnership(issue) + panel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined }) if (pending) { - this.activePanel.postMessage({ + panel.postMessage({ type: 'toast/show', id: `issue-created-${event.issueNumber}`, level: 'success', @@ -708,7 +760,7 @@ class WebhookCoordinator { // L462 的 linkPendingTerminalToIssue 也会推 brainstormTabOpen, // 但那时 #N 还不在前端 issues 列表里、patch 会被丢弃; // issue/append 之后再补发一次,让 X 关闭按钮立即出现。 - this.activePanel.postMessage({ + panel.postMessage({ type: 'issue/patch', issueNumber: event.issueNumber, patch: { brainstormTabOpen: true }, @@ -797,7 +849,7 @@ class WebhookCoordinator { details: `poster=${poster ?? '<未知>'} assignees=[${assignees.join(',')}] me=${me ?? '<未知>'}`, }) // 卡本来不在板上时前端 remove 是无害 no-op。 - this.activePanel.postMessage({ type: 'issue/remove', issueNumber }) + this.activePanel?.postMessage({ type: 'issue/remove', issueNumber }) return } try { @@ -812,9 +864,10 @@ class WebhookCoordinator { const issue = loaded ? (await annotateMissingSessionIds(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null // 前端对 issue/append 按 number 去重原位替换:已在板上的卡就地刷新 // (含所在列),不在板上的新增——指派给我的工单借此实时上卡。 - if (issue) { - this.activePanel.noteIssueOwnership(issue) - this.activePanel.postMessage({ type: 'issue/append', issue }) + const panel = this.activePanel + if (issue && panel) { + panel.noteIssueOwnership(issue) + panel.postMessage({ type: 'issue/append', issue }) } } catch (err) { @@ -1739,7 +1792,8 @@ class WebhookCoordinator { }) } - if (!this.activePanel) { + const panel = this.activePanel + if (!panel) { logger.add({ level: 'warn', source: 'webhook', @@ -1794,7 +1848,15 @@ class WebhookCoordinator { const prompt = renderReviewPrompt(this.ctx, { state, issueNumber: String(issueNumber), prNumber }) - await this.activePanel.triggerAutoReviewTab({ + if (this.activePanel !== panel) { + logger.add({ + level: 'info', + source: 'webhook', + message: `面板已关闭或重建,跳过审查 #${issueNumber}`, + }) + return + } + await panel.triggerAutoReviewTab({ issueNumber, prNumber, prompt, diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index 30cc57b..9a36a6c 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -605,6 +605,7 @@ export function App() { initialDefaultImplementProfilePath={settings?.defaultImplementProfilePath ?? ''} initialDefaultTestProfilePath={settings?.defaultTestProfilePath ?? ''} initialDefaultPrototypeProfilePath={settings?.defaultPrototypeProfilePath ?? ''} + initialDefaultAskProfilePath={settings?.defaultAskProfilePath ?? ''} initialConflictResolutionProfilePath={settings?.conflictResolutionProfilePath ?? ''} initialCommitProfilePath={settings?.commitProfilePath ?? ''} initialPrDiffSummaryProfilePath={settings?.prDiffSummaryProfilePath ?? ''} diff --git a/vscode/webview-ui/src/components/SettingsModal.test.tsx b/vscode/webview-ui/src/components/SettingsModal.test.tsx new file mode 100644 index 0000000..6b8d99d --- /dev/null +++ b/vscode/webview-ui/src/components/SettingsModal.test.tsx @@ -0,0 +1,71 @@ +import type { SettingsModalProps } from './SettingsModal' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { SettingsModal } from './SettingsModal' + +const PROPS: SettingsModalProps = { + open: true, + host: 'gitea.example.com', + initialTokenSaved: true, + initialWebhookPort: 8080, + initialWebhookSecret: '', + initialBrainstormPrompt: '', + initialImplementPlanPrompt: '', + initialAutoReview: false, + initialReviewPrompt: '', + initialTestPrompt: '', + initialPrototypePrompt: '', + initialDevBranch: 'main', + initialReleaseBranchPrefix: 'release/', + initialJenkinsJob: '', + initialJenkinsProdHost: '', + initialWorktreeDirectory: '', + initialWorktreeOpenWith: 'vscode', + initialPycharmCommand: '', + initialWorktreePostCreateScript: '', + initialWorktreePreRemoveScript: '', + initialImplTabPreCreateScript: '', + initialImplTabPostCloseScript: '', + initialSplitNewTerminals: false, + initialDefaultBrainstormProfilePath: '', + initialDefaultImplementProfilePath: '', + initialDefaultTestProfilePath: '', + initialDefaultPrototypeProfilePath: '', + initialDefaultAskProfilePath: '/p/sonnet.json', + initialConflictResolutionProfilePath: '', + initialCommitProfilePath: '', + initialPrDiffSummaryProfilePath: '', + initialCodexModel: '', + initialCodexReasoningEffort: '', + initialProfilesDirectory: '', + initialYoutrackBaseUrl: '', + initialYoutrackProjectShortName: '', + initialYoutrackCloseCommand: '', + initialYoutrackTokenSaved: false, + profiles: [{ name: 'sonnet', path: '/p/sonnet.json' }], + youtrackProjects: { status: 'idle', projects: [] }, + onListYouTrackProjects: () => {}, + onSubmit: () => {}, +} + +function field(props: Partial = {}): string { + const html = renderToStaticMarkup() + expect(html).toContain('文件问答默认模型') + return html.slice(html.indexOf('文件问答默认模型'), html.indexOf('冲突解决配置文件')) +} + +describe('设置面板的文件问答默认模型', () => { + it('与其他默认配置在同一区域,显示保存的名称并说明临时切换不会改默认', () => { + const html = field() + expect(html).toContain('sonnet') + expect(html).toContain('问这个文件') + expect(html).toContain('临时切换') + expect(html).toContain('不会修改这里的默认值') + expect(html).toContain('aria-haspopup="listbox"') + }) + + it('未配置时显示官方默认,失效路径不能伪装成默认', () => { + expect(field({ initialDefaultAskProfilePath: '' })).toContain('(默认)') + expect(field({ initialDefaultAskProfilePath: '/p/deleted.json' })).toContain('不可用:/p/deleted.json') + }) +}) diff --git a/vscode/webview-ui/src/components/SettingsModal.tsx b/vscode/webview-ui/src/components/SettingsModal.tsx index 299b762..3a221a5 100644 --- a/vscode/webview-ui/src/components/SettingsModal.tsx +++ b/vscode/webview-ui/src/components/SettingsModal.tsx @@ -42,6 +42,7 @@ interface SubmitValues { defaultImplementProfilePath: string defaultTestProfilePath: string defaultPrototypeProfilePath: string + defaultAskProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string @@ -97,6 +98,7 @@ export interface SettingsModalProps { initialDefaultImplementProfilePath: string initialDefaultTestProfilePath: string initialDefaultPrototypeProfilePath: string + initialDefaultAskProfilePath: string initialConflictResolutionProfilePath: string initialCommitProfilePath: string initialPrDiffSummaryProfilePath: string @@ -189,6 +191,7 @@ export function SettingsModal({ initialDefaultImplementProfilePath, initialDefaultTestProfilePath, initialDefaultPrototypeProfilePath, + initialDefaultAskProfilePath, initialConflictResolutionProfilePath, initialCommitProfilePath, initialPrDiffSummaryProfilePath, @@ -232,6 +235,7 @@ export function SettingsModal({ const [defaultImplementProfilePath, setDefaultImplementProfilePath] = useState(initialDefaultImplementProfilePath) const [defaultTestProfilePath, setDefaultTestProfilePath] = useState(initialDefaultTestProfilePath) const [defaultPrototypeProfilePath, setDefaultPrototypeProfilePath] = useState(initialDefaultPrototypeProfilePath) + const [defaultAskProfilePath, setDefaultAskProfilePath] = useState(initialDefaultAskProfilePath) const [conflictResolutionProfilePath, setConflictResolutionProfilePath] = useState(initialConflictResolutionProfilePath) const [commitProfilePath, setCommitProfilePath] = useState(initialCommitProfilePath) const [prDiffSummaryProfilePath, setPrDiffSummaryProfilePath] = useState(initialPrDiffSummaryProfilePath) @@ -277,6 +281,7 @@ export function SettingsModal({ setDefaultImplementProfilePath(initialDefaultImplementProfilePath) setDefaultTestProfilePath(initialDefaultTestProfilePath) setDefaultPrototypeProfilePath(initialDefaultPrototypeProfilePath) + setDefaultAskProfilePath(initialDefaultAskProfilePath) setConflictResolutionProfilePath(initialConflictResolutionProfilePath) setCommitProfilePath(initialCommitProfilePath) setPrDiffSummaryProfilePath(initialPrDiffSummaryProfilePath) @@ -374,6 +379,7 @@ export function SettingsModal({ defaultImplementProfilePath: defaultImplementProfilePath.trim(), defaultTestProfilePath: defaultTestProfilePath.trim(), defaultPrototypeProfilePath: defaultPrototypeProfilePath.trim(), + defaultAskProfilePath: defaultAskProfilePath.trim(), // 冲突解决 profile:空串 = 用 DEFAULT_PROFILE_PATH conflictResolutionProfilePath: conflictResolutionProfilePath.trim(), // 提交代码 profile:空串 = 未配置 → 提交严格失败 @@ -656,6 +662,26 @@ export function SettingsModal({ /> + + ({ value: p.path, label: p.name })), + ...(defaultAskProfilePath + && !profiles.some(p => p.path === defaultAskProfilePath) + ? [{ value: defaultAskProfilePath, label: `不可用:${defaultAskProfilePath}` }] + : []), + ]} + /> + + ): Issue { - const next = { ...issue, ...patch } as Issue & Record - for (const [k, v] of Object.entries(patch)) { - if (v === null) - delete next[k] - } - return next as Issue -} - export interface SettingsValues { host: string token: string @@ -61,6 +52,7 @@ export interface SettingsValues { defaultImplementProfilePath: string defaultTestProfilePath: string defaultPrototypeProfilePath: string + defaultAskProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string @@ -102,6 +94,7 @@ export interface SettingsOverlayState { defaultImplementProfilePath: string defaultTestProfilePath: string defaultPrototypeProfilePath: string + defaultAskProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string @@ -465,6 +458,7 @@ export function useIssues(): UseIssuesResult { defaultImplementProfilePath: values.defaultImplementProfilePath, defaultTestProfilePath: values.defaultTestProfilePath, defaultPrototypeProfilePath: values.defaultPrototypeProfilePath, + defaultAskProfilePath: values.defaultAskProfilePath, conflictResolutionProfilePath: values.conflictResolutionProfilePath, commitProfilePath: values.commitProfilePath, prDiffSummaryProfilePath: values.prDiffSummaryProfilePath, @@ -890,9 +884,7 @@ export function useIssues(): UseIssuesResult { return prev return { status: 'ready', - issues: prev.issues.map(i => - i.number === msg.issueNumber ? applyIssuePatch(i, msg.patch) : i, - ), + issues: applyIssueUpdate(prev.issues, msg), } }) if (msg.patch.prDiffFile) @@ -915,7 +907,7 @@ export function useIssues(): UseIssuesResult { if (prev.status !== 'ready') return prev const removed = prev.issues.find(i => i.number === msg.issueNumber) - const remaining = prev.issues.filter(i => i.number !== msg.issueNumber) + const remaining = applyIssueUpdate(prev.issues, msg) if (removed) { const sameCol = remaining .filter(i => i.column === removed.column) @@ -931,12 +923,9 @@ export function useIssues(): UseIssuesResult { setState((prev) => { if (prev.status !== 'ready') return prev - const exists = prev.issues.some(i => i.number === msg.issue.number) return { status: 'ready', - issues: exists - ? prev.issues.map(i => i.number === msg.issue.number ? msg.issue : i) - : [...prev.issues, msg.issue], + issues: applyIssueUpdate(prev.issues, msg), } }) if (msg.select) @@ -991,6 +980,7 @@ export function useIssues(): UseIssuesResult { defaultImplementProfilePath: msg.defaultImplementProfilePath, defaultTestProfilePath: msg.defaultTestProfilePath, defaultPrototypeProfilePath: msg.defaultPrototypeProfilePath, + defaultAskProfilePath: msg.defaultAskProfilePath, conflictResolutionProfilePath: msg.conflictResolutionProfilePath, commitProfilePath: msg.commitProfilePath, prDiffSummaryProfilePath: msg.prDiffSummaryProfilePath, diff --git a/vscode/webview-ui/src/lib/messages.ts b/vscode/webview-ui/src/lib/messages.ts index 38480bc..05ee577 100644 --- a/vscode/webview-ui/src/lib/messages.ts +++ b/vscode/webview-ui/src/lib/messages.ts @@ -134,6 +134,7 @@ export type ExtensionToWebview defaultImplementProfilePath: string defaultTestProfilePath: string defaultPrototypeProfilePath: string + defaultAskProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string @@ -219,6 +220,7 @@ export type WebviewToExtension defaultImplementProfilePath: string defaultTestProfilePath: string defaultPrototypeProfilePath: string + defaultAskProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string