🐛 fix(vscode): 修复看板缓存并增加问答默认模型
This commit is contained in:
1 parent
c38f1dc468
commit
012daf95b5
25 files changed
+1753
-164
No files matched your search
@@ -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<typeof import('../issues/localState')>(),
|
||||
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: () => '<html><body></body></html>', 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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function issue(number = 462, extra: Partial<Issue> = {}): 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<typeof webviewPanel>[] = []
|
||||
let stopCoordinator: (() => Promise<void>) | undefined
|
||||
|
||||
function fixture(initial: KanbanSnapshot | undefined = snapshot()) {
|
||||
const disk = new Map<string, unknown>()
|
||||
if (initial)
|
||||
disk.set(KANBAN_SNAPSHOT_KEY, initial)
|
||||
const saveGate = deferred<void>()
|
||||
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<typeof webviewPanel>): KanbanSnapshot | undefined {
|
||||
const json = view.panel.webview.html.match(/<script[^>]+id="kanban-snapshot">(.*?)<\/script>/)?.[1]
|
||||
return json ? JSON.parse(json) : undefined
|
||||
}
|
||||
|
||||
function updates(view: ReturnType<typeof webviewPanel>) {
|
||||
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<ReturnType<typeof loaded>>()
|
||||
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<ReturnType<typeof loaded>>()
|
||||
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<ReturnType<typeof loaded>>()
|
||||
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<typeof import('../webhook/coordinator')>('../webhook/coordinator')
|
||||
const eventRead = deferred<Issue>()
|
||||
const reopenedRead = deferred<ReturnType<typeof loaded>>()
|
||||
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<Issue[]>()
|
||||
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<ReturnType<typeof loaded>>()
|
||||
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<number>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
+112
-45
@@ -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<number, { source: 'gitea' | 'youtrack', externalId?: string }>()
|
||||
/** 工单号 → 归属信息,供写操作门禁判定「这是不是我的工单」。 */
|
||||
private issueOwners = new Map<number, IssueOwnershipFacts>()
|
||||
/** 本机 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<void> | undefined
|
||||
private lastIssuesUpdate: Extract<ExtensionToWebview, { type: 'issues/update' }> | undefined
|
||||
private readonly kanbanState: ReturnType<typeof getKanbanState>
|
||||
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<void> {
|
||||
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<void> {
|
||||
async reloadAfterInvalidation(): Promise<void> {
|
||||
if (this.activeLoad)
|
||||
this.activeLoad.invalidated = true
|
||||
try {
|
||||
await this.loadInFlight
|
||||
}
|
||||
finally {
|
||||
await this.loadAndPush()
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAndPushInner(load: KanbanLoad): Promise<void> {
|
||||
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<ExtensionToWebview, { type: 'issues/update' }> = {
|
||||
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(
|
||||
|
||||
@@ -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<IssueRef['source']>
|
||||
abort: AbortController
|
||||
launch: CodexSessionLaunch
|
||||
terminal?: Terminal
|
||||
@@ -18,7 +20,7 @@ interface ReviewCapture {
|
||||
}
|
||||
|
||||
const captures = new WeakMap<KanbanWebviewPanel, Map<number, ReviewCapture>>()
|
||||
const recoveries = new WeakMap<KanbanWebviewPanel, Map<number, { abort: AbortController, result: Promise<string | null> }>>()
|
||||
const recoveries = new WeakMap<KanbanWebviewPanel, Map<number, { source: ReviewCapture['source'], abort: AbortController, result: Promise<string | null> }>>()
|
||||
|
||||
function captureMap(panel: KanbanWebviewPanel): Map<number, ReviewCapture> {
|
||||
let map = captures.get(panel)
|
||||
@@ -29,17 +31,20 @@ function captureMap(panel: KanbanWebviewPanel): Map<number, ReviewCapture> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<string | null>
|
||||
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<num
|
||||
}
|
||||
|
||||
/** 已有文件立即补关联;尚未落盘则后台等待,刷新不会等完整审查运行。 */
|
||||
async function recoverReviewSessionCaptureInner(panel: KanbanWebviewPanel, issueNumber: number, terminal: Terminal, signal: AbortSignal): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<CodexSessionLaunch>(launchKey(panel, issueNumber))
|
||||
const stored = panel.context.workspaceState.get<CodexSessionLaunch>(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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>([[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<void>((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
|
||||
})
|
||||
})
|
||||
@@ -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<void> = 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<void> {
|
||||
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<void> {
|
||||
this.current = undefined
|
||||
return this.persist(undefined)
|
||||
}
|
||||
|
||||
private persist(snapshot: KanbanSnapshot | undefined): Promise<void> {
|
||||
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<Memento, KanbanState>()
|
||||
|
||||
export function getKanbanState(workspaceState: Memento): KanbanState {
|
||||
let state = states.get(workspaceState)
|
||||
if (!state) {
|
||||
state = new KanbanState(workspaceState)
|
||||
states.set(workspaceState, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user