⚡ perf(vscode): 开会话去掉多余远程往返,spinner 跟随真实进度
开会话原先要先全量拉工单(含每张工单的评论、依赖、PR)做前置锁检查, 再各读一次 state JSON 给 pre-create 钩子和终端图标,几秒后才 createTerminal。 - 锁检查改用主进程已算好的工单快照,快照里没有该工单才回落全量拉取 - state JSON 一次读两处用,且提前发起与本地准备重叠 - 会话行 spinner 由扩展的 session/tab-settled 消息驱动,不再是固定 1.5s 假动画, 消息丢失时 60s 超时兜底 Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
This commit is contained in:
1 parent
b182e0ac5b
commit
70b826123c
12 files changed
+218
-71
No files matched your search
@@ -696,7 +696,10 @@ export class KanbanWebviewPanel {
|
||||
* Persistence failures are logged and swallowed.
|
||||
*/
|
||||
// internal: handler 模块访问
|
||||
async resolveIssueIcon(issueNumber: number): Promise<{ themeColor: ThemeColor, iconUri: Uri }> {
|
||||
async resolveIssueIcon(
|
||||
issueNumber: number,
|
||||
presetState?: Record<string, unknown> | null,
|
||||
): Promise<{ themeColor: ThemeColor, iconUri: Uri }> {
|
||||
// Helper: produce both representations from a palette id.
|
||||
const pack = (id: string) => ({
|
||||
themeColor: new ThemeColor(id),
|
||||
@@ -707,24 +710,34 @@ export class KanbanWebviewPanel {
|
||||
// pick so the icon is still stable across sessions.
|
||||
const fallbackId = PALETTE[((issueNumber % PALETTE.length) + PALETTE.length) % PALETTE.length]
|
||||
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!workspaceRoot)
|
||||
return pack(fallbackId)
|
||||
const remote = await detectRepo(workspaceRoot)
|
||||
if (!remote)
|
||||
return pack(fallbackId)
|
||||
const token = await getToken(this.context, remote.host)
|
||||
if (!token)
|
||||
return pack(fallbackId)
|
||||
|
||||
let stored: string | undefined
|
||||
try {
|
||||
const state = await this.readIssueState(issueNumber)
|
||||
if (typeof state.color === 'string' && state.color.length > 0)
|
||||
stored = state.color
|
||||
// 调用方已经读过 state(开会话热路径就一次)时直接复用,省一次远程往返;
|
||||
// presetState 为 null 表示那次读取失败,按 tracker 不可达处理。
|
||||
if (presetState !== undefined) {
|
||||
if (presetState === null)
|
||||
return pack(fallbackId)
|
||||
if (typeof presetState.color === 'string' && presetState.color.length > 0)
|
||||
stored = presetState.color
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[superpowers] failed to read state JSON for color:', err)
|
||||
else {
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!workspaceRoot)
|
||||
return pack(fallbackId)
|
||||
const remote = await detectRepo(workspaceRoot)
|
||||
if (!remote)
|
||||
return pack(fallbackId)
|
||||
const token = await getToken(this.context, remote.host)
|
||||
if (!token)
|
||||
return pack(fallbackId)
|
||||
|
||||
try {
|
||||
const state = await this.readIssueState(issueNumber)
|
||||
if (typeof state.color === 'string' && state.color.length > 0)
|
||||
stored = state.color
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[superpowers] failed to read state JSON for color:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const { id, isNew } = resolveIssueColor(stored)
|
||||
@@ -871,6 +884,15 @@ export class KanbanWebviewPanel {
|
||||
* `{ locked: false }` so a transient Gitea hiccup can't lock the user out
|
||||
* of starting a session.
|
||||
*/
|
||||
/**
|
||||
* 主进程上一次算好的工单快照(issues/update 的 payload)。锁检查等只读
|
||||
* 判断直接用它,省掉一次全量 loadIssues 往返;面板还没加载过时为 undefined。
|
||||
*/
|
||||
// internal: handler 模块访问
|
||||
snapshotIssues(): Issue[] | undefined {
|
||||
return this.lastIssuesUpdate?.issues
|
||||
}
|
||||
|
||||
// internal: handler 模块访问
|
||||
async resolveLockedReason(
|
||||
issueNumber: number,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IssueColumn } from '../../gitea/types'
|
||||
import type { LockedReason } from '../issueLock'
|
||||
import type { KanbanWebviewPanel } from '../KanbanPanel'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -35,6 +36,7 @@ import { logger } from '../../logging/logger'
|
||||
import { getEffectivePrDiffSummaryProfilePath, getSettings } from '../../settings/store'
|
||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||
import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor'
|
||||
import { evaluateLockedReason } from '../issueLock'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { cleanupFeatureBranch } from './branchCleanup'
|
||||
import * as sessions from './sessions'
|
||||
@@ -1572,12 +1574,10 @@ export async function syncCloseGiteaIssue(panel: KanbanWebviewPanel, opts: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side lock check for prerequisite gating. Fetches a fresh issues
|
||||
* snapshot (we don't trust webview state) and reports whether
|
||||
* `issueNumber` is blocked by an unfinished prerequisite. Mirrors the
|
||||
* webview-side `isIssueLocked` predicate: locked iff the issue has a
|
||||
* prerequisite that exists in the current snapshot and is not yet in the
|
||||
* `done` column.
|
||||
* Server-side lock check for prerequisite gating(不信 webview 传来的状态)。
|
||||
* 优先用主进程自己算好的工单快照——开会话是热路径,全量 loadIssues 要拉工单
|
||||
* 列表、每张工单的评论、依赖和 PR,几秒起步;快照没有该工单(面板还没加载
|
||||
* 完)时才退回一次全量拉取。
|
||||
*
|
||||
* Fail-open: any error fetching issues is logged and we return
|
||||
* `{ locked: false }` so a transient Gitea hiccup can't lock the user out
|
||||
@@ -1586,8 +1586,12 @@ export async function syncCloseGiteaIssue(panel: KanbanWebviewPanel, opts: {
|
||||
export async function resolveLockedReason(
|
||||
panel: KanbanWebviewPanel,
|
||||
issueNumber: number,
|
||||
): Promise<{ locked: boolean, prerequisiteNumber?: number, prerequisiteColumn?: string }> {
|
||||
): Promise<LockedReason> {
|
||||
try {
|
||||
const snapshot = panel.snapshotIssues()
|
||||
if (snapshot?.some(i => i.number === issueNumber))
|
||||
return evaluateLockedReason(snapshot, issueNumber)
|
||||
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!workspaceRoot)
|
||||
return { locked: false }
|
||||
@@ -1604,21 +1608,7 @@ export async function resolveLockedReason(
|
||||
repo: remote.repo,
|
||||
workspaceRoot,
|
||||
})
|
||||
const issue = issues.find(i => i.number === issueNumber)
|
||||
if (!issue)
|
||||
return { locked: false }
|
||||
if (issue.prerequisite === undefined)
|
||||
return { locked: false }
|
||||
const prereq = issues.find(i => i.number === issue.prerequisite)
|
||||
if (!prereq)
|
||||
return { locked: false }
|
||||
if (prereq.column === 'done')
|
||||
return { locked: false }
|
||||
return {
|
||||
locked: true,
|
||||
prerequisiteNumber: prereq.number,
|
||||
prerequisiteColumn: prereq.column,
|
||||
}
|
||||
return evaluateLockedReason(issues, issueNumber)
|
||||
}
|
||||
catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -73,6 +73,20 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
|
||||
existing.show(false)
|
||||
return
|
||||
}
|
||||
// state JSON 一次读、两处用(钩子的 branch + 终端图标颜色)。提前发起,
|
||||
// 让这趟远程往返和后面的本地准备工作重叠;读失败为 null,两处各自退化。
|
||||
const statePromise: Promise<Record<string, unknown> | null> | undefined
|
||||
= issueNumber !== undefined
|
||||
? panel.readIssueState(issueNumber).catch((err) => {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'panel',
|
||||
message: `resume #${issueNumber} 读 state JSON 失败`,
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
})
|
||||
: undefined
|
||||
// 实施 / 头脑风暴各自独立:调用方传入对应字段,空 = DEFAULT。
|
||||
const effectiveProfilePath
|
||||
= sessionKind === 'implement'
|
||||
@@ -127,28 +141,10 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
|
||||
// (issueNumber === undefined) 钩子签名要 issueNumber,同样跳过。
|
||||
if (sessionKind === 'implement' && !worktreeMissing && issueNumber !== undefined && workspaceRoot) {
|
||||
const settingsForHook = getSettings(panel.context)
|
||||
// 从 state JSON 读 branch;任何一步读不到就用空字符串(脚本里
|
||||
// BRANCH env 仍能传,只是为空),不阻塞 resume 主流程。
|
||||
let branchForHook = ''
|
||||
try {
|
||||
const remote = await detectRepo(workspaceRoot)
|
||||
if (remote) {
|
||||
const token = await getToken(panel.context, remote.host)
|
||||
if (token) {
|
||||
const stateObj = await panel.readIssueState(issueNumber)
|
||||
if (stateObj && typeof stateObj.branch === 'string')
|
||||
branchForHook = stateObj.branch
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({
|
||||
level: 'warn',
|
||||
source: 'panel',
|
||||
message: `resume #${issueNumber} 读 state JSON 失败,钩子 branch 用空字符串`,
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
// 从 state JSON 读 branch;读不到就用空字符串(脚本里 BRANCH env 仍能
|
||||
// 传,只是为空),不阻塞 resume 主流程。
|
||||
const stateObj = await statePromise
|
||||
const branchForHook = typeof stateObj?.branch === 'string' ? stateObj.branch : ''
|
||||
await panel.dispatchWorktreeHook('impl-tab-pre-create', {
|
||||
workspaceRoot,
|
||||
worktreePath: effectiveCwd!,
|
||||
@@ -164,7 +160,7 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
|
||||
// harmless in the editor tab, still useful if the same terminal ever
|
||||
// gets rendered in panel-view mode.
|
||||
const issueIcon = issueNumber !== undefined
|
||||
? await panel.resolveIssueIcon(issueNumber)
|
||||
? await panel.resolveIssueIcon(issueNumber, await statePromise)
|
||||
: undefined
|
||||
const terminal = window.createTerminal({
|
||||
name: terminalName,
|
||||
@@ -198,6 +194,8 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
|
||||
finally {
|
||||
if (lockKey !== undefined)
|
||||
panel.resumeInFlight.delete(lockKey)
|
||||
// 无论开成、复用已有 tab 还是被前置锁拒绝,都要告诉 webview 收 spinner。
|
||||
panel.postMessage({ type: 'session/tab-settled', sessionId })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +301,7 @@ export async function handleResumeReviewSession(panel: KanbanWebviewPanel, sessi
|
||||
}
|
||||
finally {
|
||||
panel.resumeInFlight.delete(lockKey)
|
||||
panel.postMessage({ type: 'session/tab-settled', sessionId })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1379,6 +1378,7 @@ export async function handleResumeTestSession(panel: KanbanWebviewPanel, session
|
||||
}
|
||||
finally {
|
||||
panel.resumeInFlight.delete(lockKey)
|
||||
panel.postMessage({ type: 'session/tab-settled', sessionId })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Issue } from '../gitea/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { evaluateLockedReason } from './issueLock'
|
||||
|
||||
function issue(number: number, column: Issue['column'], prerequisite?: number): Issue {
|
||||
return { number, title: `#${number}`, column, prerequisite } as Issue
|
||||
}
|
||||
|
||||
describe('evaluateLockedReason', () => {
|
||||
it('没有前置 → 放行', () => {
|
||||
expect(evaluateLockedReason([issue(1, 'todo')], 1)).toEqual({ locked: false })
|
||||
})
|
||||
|
||||
it('前置未完成 → 锁定并带上前置号和所在列', () => {
|
||||
const issues = [issue(2, 'todo', 1), issue(1, 'in-progress')]
|
||||
expect(evaluateLockedReason(issues, 2)).toEqual({
|
||||
locked: true,
|
||||
prerequisiteNumber: 1,
|
||||
prerequisiteColumn: 'in-progress',
|
||||
})
|
||||
})
|
||||
|
||||
it('前置已完成 → 放行', () => {
|
||||
const issues = [issue(2, 'todo', 1), issue(1, 'done')]
|
||||
expect(evaluateLockedReason(issues, 2)).toEqual({ locked: false })
|
||||
})
|
||||
|
||||
it('快照里没有前置工单 → 放行(不误锁)', () => {
|
||||
expect(evaluateLockedReason([issue(2, 'todo', 99)], 2)).toEqual({ locked: false })
|
||||
})
|
||||
|
||||
it('快照里没有这张工单 → 放行', () => {
|
||||
expect(evaluateLockedReason([issue(1, 'todo')], 7)).toEqual({ locked: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Issue } from '../gitea/types'
|
||||
|
||||
export interface LockedReason {
|
||||
locked: boolean
|
||||
prerequisiteNumber?: number
|
||||
prerequisiteColumn?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行,
|
||||
* 与 webview 的 `isIssueLocked` 保持同一语义。
|
||||
*/
|
||||
export function evaluateLockedReason(issues: Issue[], issueNumber: number): LockedReason {
|
||||
const issue = issues.find(i => i.number === issueNumber)
|
||||
if (!issue || issue.prerequisite === undefined)
|
||||
return { locked: false }
|
||||
const prereq = issues.find(i => i.number === issue.prerequisite)
|
||||
if (!prereq || prereq.column === 'done')
|
||||
return { locked: false }
|
||||
return {
|
||||
locked: true,
|
||||
prerequisiteNumber: prereq.number,
|
||||
prerequisiteColumn: prereq.column,
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,8 @@ export type ExtensionToWebview
|
||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
|
||||
| { type: 'issues/error', message: string }
|
||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null } }
|
||||
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
|
||||
| { type: 'session/tab-settled', sessionId: string }
|
||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||
| { type: 'handoff/users-result', issueNumber: number, users: string[] }
|
||||
| { type: 'handoff/done', issueNumber: number }
|
||||
|
||||
Reference in new issue
Block a user