feat(vscode): 多人协作二期:状态分层/写校验/签名/团队视图

- 本机状态(会话id/worktree/profile/prDiffFile)迁 workspaceState,
  共享 state JSON 只留团队字段;读侧本地记录叠加,旧数据兜底
- mergeStateJsonComment 写后读回校验,并发覆盖时在最新状态上重放一次
- webhook 支持 Gitea secret,校验 X-Gitea-Signature(HMAC-SHA256),
  不匹配 401;设置面板新增 Webhook Secret 字段
- 看板新增 我的/全部 范围切换(团队视图),按工作区持久化
This commit is contained in:
2026-08-19 00:32:06 +08:00
parent c370214d12
commit faf227412e
18 changed files with 665 additions and 202 deletions
+29 -11
View File
@@ -4,7 +4,9 @@ import type {
TerminalEditorLocationOptions,
WebviewPanel,
} from 'vscode'
import type { HookContext } from '../git/worktreeHooks'
import type { Issue } from '../gitea/types'
import type { IssueRef } from '../issues/stateRouter'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { randomBytes } from 'node:crypto'
import * as fs from 'node:fs'
@@ -12,13 +14,11 @@ import * as path from 'node:path'
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
import { deleteToken, getToken } from '../auth/secrets'
import { detectRepo } from '../git/remote'
import type { HookContext } from '../git/worktreeHooks'
import {
GiteaApiError,
postIssueComment,
} from '../gitea/api'
import { loadIssues } from '../gitea/issueLoader'
import type { IssueRef } from '../issues/stateRouter'
import { overlayLocalIssueState } from '../issues/localState'
import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter'
import { logger } from '../logging/logger'
import { getEffectiveCommitProfilePath, getSettings } from '../settings/store'
@@ -39,6 +39,9 @@ import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor'
/** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */
export { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../cc/profiles'
/** workspaceState key:看板范围('mine' 只看我的 / 'all' 团队全部)。 */
const SCOPE_KEY = 'superpowers.kanbanScope'
export class KanbanWebviewPanel {
static readonly viewType = 'superpowers.kanbanPanel'
@@ -108,9 +111,11 @@ export class KanbanWebviewPanel {
workspaceRoot: string
inboxDir: string
terminalName: string
/** The brainstorm terminal created synchronously in `handleIssueCreate`.
/**
* The brainstorm terminal created synchronously in `handleIssueCreate`.
* Stored here so `linkPendingTerminalToIssue` can promote it into
* `newIssueTerminals` once the webhook tells us the issueNumber. */
* `newIssueTerminals` once the webhook tells us the issueNumber.
*/
terminal: Terminal
createdAt: number
}>()
@@ -360,6 +365,12 @@ export class KanbanWebviewPanel {
void this.loadAndPush()
return
}
if (msg.type === 'issues/set-scope') {
// 视图偏好按工作区持久化;切换即重拉。
void this.context.workspaceState.update(SCOPE_KEY, msg.scope)
void this.loadAndPush()
return
}
if (msg.type === 'settings/save') {
void settings.handleSettingsSave(this, msg)
return
@@ -594,7 +605,6 @@ export class KanbanWebviewPanel {
}
if (msg.type === 'pr-diff-mode/set') {
void prFiles.handleSetPrDiffMode(this, msg.mode)
return
}
}
@@ -777,7 +787,6 @@ export class KanbanWebviewPanel {
return worktree.dispatchImplTabPostCloseAsync(this, issueNumber)
}
/**
* Server-side lock check for prerequisite gating. Fetches a fresh issues
* snapshot (we don't trust webview state) and reports whether
@@ -846,6 +855,7 @@ export class KanbanWebviewPanel {
host,
tokenSaved: false,
webhookPort: s.webhookPort,
webhookSecret: s.webhookSecret,
brainstormPrompt: s.brainstormPrompt,
brainstormContinuePrompt: s.brainstormContinuePrompt,
implementPlanPrompt: s.implementPlanPrompt,
@@ -869,8 +879,9 @@ export class KanbanWebviewPanel {
return
}
const scope = this.context.workspaceState.get<'mine' | 'all'>(SCOPE_KEY) ?? 'mine'
try {
const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot })
const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot, scope })
// YouTrack is a best-effort second source: a failure here must never
// break the gitea board, so swallow it into a toast + log.
let youtrackList: Issue[] = []
@@ -888,7 +899,10 @@ export class KanbanWebviewPanel {
dismissOnTimer: 6000,
})
}
const issues = this.withLiveTerminalTabState([...giteaIssues, ...youtrackList])
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,再补 live terminal 态。
const issues = this.withLiveTerminalTabState(
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
)
this.issueRefs = new Map(
issues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
)
@@ -896,6 +910,7 @@ export class KanbanWebviewPanel {
this.postMessage({
type: 'issues/update',
issues,
scope,
globalAutoReview: ytSettings.autoReview,
youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '',
})
@@ -914,6 +929,7 @@ export class KanbanWebviewPanel {
errorMessage: 'Token 无效或已过期,请重新填写',
tokenSaved: false,
webhookPort: s.webhookPort,
webhookSecret: s.webhookSecret,
brainstormPrompt: s.brainstormPrompt,
brainstormContinuePrompt: s.brainstormContinuePrompt,
implementPlanPrompt: s.implementPlanPrompt,
@@ -970,8 +986,10 @@ export class KanbanWebviewPanel {
return mergeIssueState(this.context, this.refFor(issueNumber), extra)
}
/** Resolve/close the issue in its tracker. Returns false when a youtrack
* close command can't be determined. */
/**
* Resolve/close the issue in its tracker. Returns false when a youtrack
* close command can't be determined.
*/
// internal: handler 模块访问
closeIssueByNumber(issueNumber: number): Promise<boolean> {
return closeIssueByRef(this.context, this.refFor(issueNumber))
+13 -25
View File
@@ -6,15 +6,16 @@ import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
import { commands, env, ThemeColor, Uri, window, workspace } from 'vscode'
import { getToken } from '../../auth/secrets'
import { buildCcCommand } from '../../cc/ccCommand'
import { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../../cc/profiles'
import { getBrainstormPrompt } from '../../cc/prompts'
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
import { spawnClaude } from '../../cc/spawnClaude'
import { gitFetch, resolveFeatureBranch } from '../../git/branchSync'
import { detectRepo } from '../../git/remote'
import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
import {
addDependency,
closeIssue,
@@ -31,7 +32,6 @@ import { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator'
import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor'
import { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../../cc/profiles'
import { makeNonce } from '../KanbanPanel'
import { cleanupFeatureBranch } from './branchCleanup'
import * as sessions from './sessions'
@@ -345,22 +345,16 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
}
}
// 4. PR is merged — persist column='done' + prMerged=true + 清空 worktreePath
// 到 state JSON。state JSON 用空字符串清空(loader 把 length===0 视为 unset)。
// 4. PR is merged — persist column='done' + prMerged=true + 清空 worktreePath
// 走 mergeIssueState 漏斗:worktreePath 是本机字段落 workspaceState,
// 其余共享字段进 state JSON(空字符串清空,loader 把 length===0 视为 unset)。
try {
await mergeStateJsonComment({
host: remote.host,
owner: remote.owner,
repo: remote.repo,
token,
issueNumber,
extra: {
column: 'done',
worktreePath: '',
branch: '',
prMerged: true,
prMergedAt: pullRequest.merged_at ?? new Date().toISOString(),
},
await panel.mergeIssueState(issueNumber, {
column: 'done',
worktreePath: '',
branch: '',
prMerged: true,
prMergedAt: pullRequest.merged_at ?? new Date().toISOString(),
})
logger.add({
level: 'info',
@@ -2035,14 +2029,8 @@ export async function handleGeneratePrDiffSummary(panel: KanbanWebviewPanel, iss
fs.mkdirSync(path.dirname(outputAbsPath), { recursive: true })
fs.writeFileSync(outputAbsPath, summary, 'utf8')
await mergeStateJsonComment({
host: remote.host,
owner: remote.owner,
repo: remote.repo,
token,
issueNumber,
extra: { prDiffFile: outputRelPath },
})
// prDiffFile 是本机生成的文件路径,经漏斗落 workspaceState,不进共享评论。
await panel.mergeIssueState(issueNumber, { prDiffFile: outputRelPath })
panel.postMessage({
type: 'issue/patch',
issueNumber,
+51 -6
View File
@@ -20,6 +20,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
host: string
token: string
webhookPort: number
webhookSecret: string
brainstormPrompt: string
brainstormContinuePrompt: string
implementPlanPrompt: string
@@ -90,6 +91,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
errorMessage: 'Host 和 Token 都不能为空',
tokenSaved: !!oldToken,
webhookPort: payload.webhookPort,
webhookSecret: payload.webhookSecret,
brainstormPrompt: payload.brainstormPrompt || prev.brainstormPrompt,
brainstormContinuePrompt: payload.brainstormContinuePrompt || prev.brainstormContinuePrompt,
implementPlanPrompt: payload.implementPlanPrompt || prev.implementPlanPrompt,
@@ -114,6 +116,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
}
await saveSettings(panel.context, {
webhookPort: payload.webhookPort,
webhookSecret: payload.webhookSecret.trim(),
brainstormPrompt: payload.brainstormPrompt,
brainstormContinuePrompt: payload.brainstormContinuePrompt,
implementPlanPrompt: payload.implementPlanPrompt,
@@ -164,6 +167,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
message: `端口配置变更,重启监听 :${newPort}`,
})
}
webhookCoordinator.ensureSecret(getSettings(panel.context).webhookSecret)
try {
await webhookCoordinator.ensurePort(newPort)
}
@@ -216,6 +220,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
canCancel: true,
tokenSaved,
webhookPort: s.webhookPort,
webhookSecret: s.webhookSecret,
brainstormPrompt: s.brainstormPrompt,
brainstormContinuePrompt: s.brainstormContinuePrompt,
implementPlanPrompt: s.implementPlanPrompt,
@@ -310,12 +315,52 @@ export async function handleProfilesList(panel: KanbanWebviewPanel): Promise<voi
* are treated as text too — is handed to the OS default application.
*/
const TEXT_OPEN_EXTENSIONS = new Set([
'.md', '.markdown', '.txt', '.text', '.csv', '.tsv', '.json', '.jsonc',
'.yaml', '.yml', '.toml', '.ini', '.conf', '.cfg', '.log', '.xml', '.html',
'.htm', '.css', '.scss', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
'.vue', '.py', '.go', '.rs', '.java', '.kt', '.c', '.h', '.cpp', '.hpp',
'.cc', '.sh', '.bash', '.zsh', '.sql', '.env', '.properties', '.gradle',
'.dockerfile', '.gitignore',
'.md',
'.markdown',
'.txt',
'.text',
'.csv',
'.tsv',
'.json',
'.jsonc',
'.yaml',
'.yml',
'.toml',
'.ini',
'.conf',
'.cfg',
'.log',
'.xml',
'.html',
'.htm',
'.css',
'.scss',
'.js',
'.mjs',
'.cjs',
'.ts',
'.tsx',
'.jsx',
'.vue',
'.py',
'.go',
'.rs',
'.java',
'.kt',
'.c',
'.h',
'.cpp',
'.hpp',
'.cc',
'.sh',
'.bash',
'.zsh',
'.sql',
'.env',
'.properties',
'.gradle',
'.dockerfile',
'.gitignore',
])
/**
+4 -1
View File
@@ -40,7 +40,7 @@ export type ToastLevel = 'info' | 'success' | 'error'
export type ExtensionToWebview
= | { type: 'issues/loading' }
| { type: 'issues/update', issues: Issue[], globalAutoReview: boolean, youtrackConfigured: boolean }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
| { type: 'issues/error', message: string }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
@@ -53,6 +53,7 @@ export type ExtensionToWebview
canCancel?: boolean
tokenSaved: boolean
webhookPort: number
webhookSecret: string
brainstormPrompt: string
brainstormContinuePrompt: string
implementPlanPrompt: string
@@ -117,11 +118,13 @@ export type ExtensionToWebview
export type WebviewToExtension
= | { type: 'issues/refresh' }
| { type: 'issues/set-scope', scope: 'mine' | 'all' }
| {
type: 'settings/save'
host: string
token: string
webhookPort: number
webhookSecret: string
brainstormPrompt: string
brainstormContinuePrompt: string
implementPlanPrompt: string