This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,272 @@
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { promises as fsp } from 'node:fs'
import { window, workspace } from 'vscode'
import { pollForNewSession, projectsDirFor } from '../../cc/sessionWatcher'
import { logger } from '../../logging/logger'
import { readManagedSessions, writeManagedSessions } from '../../sessions/managedStore'
import { DEFAULT_PROFILE_PATH } from '../KanbanPanel'
/** 默认会话名:有 prompt 取前 20 字符,否则用短 id(前 8 位)。 */
function defaultSessionName(sessionId: string, prompt?: string): string {
const trimmed = (prompt ?? '').trim()
if (trimmed)
return trimmed.slice(0, 20)
return `会话 ${sessionId.slice(0, 8)}`
}
/**
* 读取受管理会话列表,给每条附加 transient 字段 `tabOpen`
* = panel.managedTerminals 里有该 id 的存活终端),再推全量列表给 webview。
*
* `tabOpen` 只在构造 show payload 时附加,不写进 .spx/session-names.json。
*/
export async function pushManagedSessions(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
panel.postMessage({ type: 'managed-sessions/show', data: { sessions: [] } })
return
}
const data = await readManagedSessions(workspaceRoot)
panel.postMessage({
type: 'managed-sessions/show',
data: {
sessions: data.sessions.map(s => ({ ...s, tabOpen: panel.managedTerminals.has(s.id) })),
},
})
}
/**
* 从列表关闭一个受管理会话的终端 tab(不删 store 记录)。
* dispose 会触发 onDidCloseTerminal,由它清理 managedTerminals 并重推列表。
*/
export function handleManagedSessionsCloseTab(panel: KanbanWebviewPanel, sessionId: string): void {
panel.managedTerminals.get(sessionId)?.dispose()
}
/**
* 推送全量受管理会话列表给 webview。
*/
export async function handleManagedSessionsGet(panel: KanbanWebviewPanel): Promise<void> {
await pushManagedSessions(panel)
}
/**
* 从会话管理 tab 创建一个新的 cc 会话:
* - cwd 用项目根(workspaceRoot,非 worktree)。
* - 启动命令照搬头脑风暴风格(claude --dangerously-skip-permissions --settings
* '<profilePath>' --system-prompt="$(serena prompts ...)"),prompt 非空时
* 再追加 ' <prompt>'。
* - 用 pollForNewSession 捕获新 sessionId(共享 projects 目录用轮询更可靠),
* 落进 .spx/session-names.json 后推全量列表。
*/
export async function handleManagedSessionsCreate(panel: KanbanWebviewPanel, profilePath: string, name?: string, prompt?: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
return
}
const effectiveProfilePath
= profilePath && profilePath.trim() !== '' ? profilePath : DEFAULT_PROFILE_PATH
// 单引号会破坏下面的 shell 单引号包裹,防御性拒绝(与现有 handler 一致)。
if (effectiveProfilePath.includes('\'')) {
void window.showErrorMessage(
`创建会话失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
)
return
}
const trimmedName = (name ?? '').trim()
if (trimmedName.includes('\'')) {
void window.showErrorMessage('创建会话失败:会话名字含单引号,拒绝执行')
return
}
const trimmedPrompt = (prompt ?? '').trim()
if (trimmedPrompt.includes('\'')) {
void window.showErrorMessage('创建会话失败:首个提示词含单引号,拒绝执行')
return
}
// 首个提示词:填了 prompt 用 prompt;否则填了 name 用 /rename <name>;否则交互式无提示词。
let effectivePrompt = ''
if (trimmedPrompt)
effectivePrompt = trimmedPrompt
else if (trimmedName)
effectivePrompt = `/rename ${trimmedName}`
// 项目根的 claude projects 子目录;先 mkdir 让 watcher 不会错过 create 事件。
const projDir = projectsDirFor(workspaceRoot)
try {
await fsp.mkdir(projDir, { recursive: true })
}
catch (err) {
console.warn('[superpowers] failed to mkdir claude projects dir:', err)
}
// managed 会话的 projects 目录是繁忙共享目录,fs.watch 会漏 rename;改用轮询 diff。
const watchPromise = pollForNewSession({ projectsDir: projDir, timeoutMs: 120_000 })
// 终端名:用户填了会话名字就用它,否则用默认 cc-会话。
// (VS Code 终端创建后不能改名,所以只在 createTerminal 时设定。)
const terminal = window.createTerminal({
name: trimmedName || 'cc-会话',
cwd: workspaceRoot,
location: panel.resolveTerminalLocation(false),
})
terminal.show(false)
logger.add({
level: 'info',
source: 'terminal',
message: `已创建终端 "${terminal.name}"`,
})
let cmd = `claude --dangerously-skip-permissions --settings '${effectiveProfilePath}' --system-prompt="$(serena prompts print-cc-system-prompt-override)"`
if (effectivePrompt)
cmd += ` '${effectivePrompt}'`
terminal.sendText(cmd)
logger.add({
level: 'info',
source: 'panel',
message: '已从会话管理 tab 启动 cc 会话',
})
void window.showInformationMessage('已创建 cc 会话')
// Fire-and-forget:会话 jsonl 出现后写进 store、登记终端并推全量列表。
watchPromise.then(async (sid) => {
if (!sid) {
logger.add({
level: 'warn',
source: 'panel',
message: '会话管理:cc 会话监听超时 (120s)',
})
return
}
logger.add({
level: 'info',
source: 'panel',
message: `会话管理:已捕获 cc 会话 ${sid}`,
})
try {
const data = await readManagedSessions(workspaceRoot)
data.sessions.push({
id: sid,
name: trimmedName || defaultSessionName(sid, trimmedPrompt || undefined),
profilePath: effectiveProfilePath,
createdAt: Date.now(),
})
await writeManagedSessions(workspaceRoot, data)
// 捕获到 sid 后登记终端,再推 show,让列表的 tabOpen 立即为 true。
panel.managedTerminals.set(sid, terminal)
await pushManagedSessions(panel)
}
catch (err) {
console.warn('[superpowers] failed to persist managed session:', err)
}
}).catch((err) => {
console.warn('[superpowers] managed session watch failed:', err)
})
}
/**
* 重命名一个受管理会话(仅改本地记录的显示名)。
*/
export async function handleManagedSessionsRename(panel: KanbanWebviewPanel, sessionId: string, name: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return
const trimmed = name.trim()
if (!trimmed)
return
const data = await readManagedSessions(workspaceRoot)
const target = data.sessions.find(s => s.id === sessionId)
if (!target)
return
target.name = trimmed
await writeManagedSessions(workspaceRoot, data)
await pushManagedSessions(panel)
}
/**
* 恢复一个受管理会话:在新终端 tab 里跑 `claude ... --resume <id>`
* cwd = workspaceRoot。in-flight 锁防止重复点击重复 createTerminal。
*/
export async function handleManagedSessionsResume(panel: KanbanWebviewPanel, sessionId: string): Promise<void> {
const lockKey = `managed:${sessionId}`
if (panel.resumeInFlight.has(lockKey)) {
logger.add({
level: 'info',
source: 'panel',
message: `managed resume ${sessionId} 已在进行中,忽略重入`,
})
return
}
panel.resumeInFlight.add(lockKey)
try {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
return
}
// 已有该会话的存活终端,直接聚焦、不再开新的(防重复)。
const existing = panel.managedTerminals.get(sessionId)
if (existing) {
existing.show(false)
return
}
const data = await readManagedSessions(workspaceRoot)
const target = data.sessions.find(s => s.id === sessionId)
const effectiveProfilePath
= target?.profilePath && target.profilePath.trim() !== '' ? target.profilePath : DEFAULT_PROFILE_PATH
if (effectiveProfilePath.includes('\'')) {
void window.showErrorMessage(
`resume 失败:profilePath 含单引号,拒绝执行 (${effectiveProfilePath})`,
)
return
}
// 终端名用 store 里记录的会话名(缺省回落到 cc-会话)。
const terminal = window.createTerminal({
name: target?.name?.trim() || 'cc-会话',
cwd: workspaceRoot,
location: panel.resolveTerminalLocation(false),
})
terminal.show(false)
logger.add({
level: 'info',
source: 'terminal',
message: `已创建终端 "${terminal.name}" (resume ${sessionId})`,
})
const cmd = `claude --dangerously-skip-permissions --settings '${effectiveProfilePath}' --system-prompt="$(serena prompts print-cc-system-prompt-override)" --resume ${sessionId}`
terminal.sendText(cmd)
// 登记终端并推 show,让列表的 tabOpen 立即为 true。
panel.managedTerminals.set(sessionId, terminal)
await pushManagedSessions(panel)
}
finally {
panel.resumeInFlight.delete(lockKey)
}
}
/**
* 从列表移除一个受管理会话(仅删本地记录,不删 jsonl)。
*/
export async function handleManagedSessionsDelete(panel: KanbanWebviewPanel, sessionId: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return
// 删除前先关掉该会话的终端 tab。
panel.managedTerminals.get(sessionId)?.dispose()
const data = await readManagedSessions(workspaceRoot)
const next = { sessions: data.sessions.filter(s => s.id !== sessionId) }
await writeManagedSessions(workspaceRoot, next)
panel.managedTerminals.delete(sessionId)
await pushManagedSessions(panel)
}
+265
View File
@@ -0,0 +1,265 @@
/**
* 「提交」tab 的扩展侧 handler:实时从 Gitea 读 PR 提交、提交内文件,并用
* VS Code 原生 diff 比较改动前后。
*
* 数据全程实时拉取,不落任何 state JSON。文件内容通过一个
* TextDocumentContentProviderscheme `spx-gitea`)按需取 raw,使 diff 的两侧
* 各自指向一个 ref 下的文件。
*/
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { commands, Uri, window, workspace, type TextDocumentContentProvider } from 'vscode'
import { getToken } from '../../auth/secrets'
import { detectRepo } from '../../git/remote'
import { getGitCommit, getRawFile, listPullRequestCommits } from '../../gitea/api'
import { readStateJsonComment } from '../../gitea/stateJson'
import { readConfirmed, readConfirmedCommits, writeConfirmed, writeConfirmedCommits } from '../../sessions/prReviewStore'
/** diff 文件内容走的虚拟文档 scheme。 */
const SPX_GITEA_SCHEME = 'spx-gitea'
/** provider 只需注册一次;模块级守卫避免重复注册同一 scheme 抛错。 */
let providerRegistered = false
/**
* 解析当前工作区的 Gitea 仓库与 token。三者任一缺失返回 undefined
* 调用方据此走空态/错误分支。
*/
async function resolveRepoContext(panel: KanbanWebviewPanel): Promise<
{ host: string, owner: string, repo: string, token: string } | undefined
> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return undefined
const remote = await detectRepo(workspaceRoot)
if (!remote)
return undefined
const token = await getToken(panel.context, remote.host)
if (!token)
return undefined
return { host: remote.host, owner: remote.owner, repo: remote.repo, token }
}
/**
* 拉取工单对应 PR 的提交列表并推给 webview。无 PR 时回空列表(前端按空态处理,
* 不算错误);缺工作区/远程/token 同样回空列表。真出错才带 error。
*/
export async function handleGetPrCommits(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
try {
const ctx = await resolveRepoContext(panel)
if (!ctx) {
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits: [], confirmedCommits: [] })
return
}
const stateObj = await readStateJsonComment({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
issueNumber,
})
const pr = typeof stateObj?.pr === 'string' && stateObj.pr.length > 0 ? stateObj.pr : undefined
if (!pr) {
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits: [], confirmedCommits: [] })
return
}
const commits = await listPullRequestCommits({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
index: Number(pr),
})
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
const confirmedCommits = workspaceRoot
? await readConfirmedCommits(workspaceRoot, issueNumber)
: []
panel.postMessage({ type: 'pr-commits/show', issueNumber, commits, confirmedCommits })
}
catch (err) {
panel.postMessage({
type: 'pr-commits/show',
issueNumber,
commits: [],
confirmedCommits: [],
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 取某提交的文件清单并推给 webview。parentSha 一并回传,供前端发起 diff 时
* 作为左侧 ref。
*/
export async function handleGetPrCommitFiles(
panel: KanbanWebviewPanel,
issueNumber: number,
sha: string,
): Promise<void> {
try {
const ctx = await resolveRepoContext(panel)
if (!ctx) {
panel.postMessage({ type: 'pr-commit-files/show', issueNumber, sha, files: [], confirmed: [] })
return
}
const detail = await getGitCommit({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
token: ctx.token,
sha,
})
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
const confirmed = workspaceRoot
? await readConfirmed(workspaceRoot, issueNumber, sha)
: []
panel.postMessage({
type: 'pr-commit-files/show',
issueNumber,
sha,
parentSha: detail.parentSha,
files: detail.files,
confirmed,
})
}
catch (err) {
panel.postMessage({
type: 'pr-commit-files/show',
issueNumber,
sha,
files: [],
confirmed: [],
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 持久化某提交里已确认(已审阅)的文件路径集合到 `.spx/pr-review-confirmed.json`。
* 拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失不该中断 UI。
*/
export async function handleSetPrReviewConfirmed(
panel: KanbanWebviewPanel,
args: { issueNumber: number, sha: string, confirmed: string[] },
): Promise<void> {
try {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return
await writeConfirmed(workspaceRoot, args.issueNumber, args.sha, args.confirmed)
}
catch (err) {
console.warn('handleSetPrReviewConfirmed 写盘失败', err)
}
}
/**
* 持久化某工单已确认(已审阅)的提交 sha 集合到 `.spx/pr-review-confirmed.json`。
* 拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失不该中断 UI。
*/
export async function handleSetPrReviewConfirmedCommits(
panel: KanbanWebviewPanel,
args: { issueNumber: number, confirmed: string[] },
): Promise<void> {
try {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return
await writeConfirmedCommits(workspaceRoot, args.issueNumber, args.confirmed)
}
catch (err) {
console.warn('handleSetPrReviewConfirmedCommits 写盘失败', err)
}
}
/**
* 懒注册 raw 内容 provider(只一次)。闭包捕获 panel 以便取 token——diff 两侧的
* 文档内容都经此 provider 按 uri 里的 ref 拉取。
*/
function ensureContentProvider(panel: KanbanWebviewPanel): void {
if (providerRegistered)
return
providerRegistered = true
const provider: TextDocumentContentProvider = {
async provideTextDocumentContent(uri: Uri): Promise<string> {
const params = new URLSearchParams(uri.query)
const host = params.get('host') ?? ''
const owner = params.get('owner') ?? ''
const repo = params.get('repo') ?? ''
const ref = params.get('ref') ?? ''
// uri.path 形如 `/src/foo.ts`:去掉前导斜杠并 decode 还原原始路径。
const filepath = decodeURIComponent(uri.path.replace(/^\//, ''))
if (!host || !owner || !repo)
return ''
const token = await getToken(panel.context, host)
if (!token)
return ''
return getRawFile({ host, owner, repo, token, filepath, ref })
},
}
const disposable = workspace.registerTextDocumentContentProvider(SPX_GITEA_SCHEME, provider)
panel.context.subscriptions.push(disposable)
}
/** 把改动文件的一侧(某 ref 下内容)编码成 spx-gitea 虚拟文档 uri。 */
function buildSideUri(opts: {
host: string
owner: string
repo: string
ref: string
path: string
}): Uri {
const query = new URLSearchParams({
host: opts.host,
owner: opts.owner,
repo: opts.repo,
ref: opts.ref,
}).toString()
return Uri.from({ scheme: SPX_GITEA_SCHEME, path: `/${opts.path}`, query })
}
/**
* 用 VS Code 原生 diff 比较一个文件在 parentSha → sha 之间的改动。
*
* 左侧 = parentSha 下内容,右侧 = sha 下内容。根提交无 parentSha 时左侧 ref 给
* 空串,raw 取不到(404)→ 空内容,自然呈现全新增。删除文件右侧在 sha 下 404
* → 空,呈现删除。
*/
export async function handleOpenPrCommitDiff(
panel: KanbanWebviewPanel,
args: { issueNumber: number, sha: string, parentSha?: string, path: string, status: string },
): Promise<void> {
try {
const ctx = await resolveRepoContext(panel)
if (!ctx) {
void window.showWarningMessage('当前工作区没有 Gitea 仓库或未配置 token')
return
}
ensureContentProvider(panel)
const leftUri = buildSideUri({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
ref: args.parentSha ?? '',
path: args.path,
})
const rightUri = buildSideUri({
host: ctx.host,
owner: ctx.owner,
repo: ctx.repo,
ref: args.sha,
path: args.path,
})
const title = `${args.path} (${(args.parentSha ?? '∅').slice(0, 7)}${args.sha.slice(0, 7)})`
await commands.executeCommand('vscode.diff', leftUri, rightUri, title)
}
catch (err) {
void window.showErrorMessage(`打开 diff 失败:${err instanceof Error ? err.message : String(err)}`)
}
}
File diff suppressed because it is too large Load Diff
+441
View File
@@ -0,0 +1,441 @@
import type { ProfilesData } from '../../profiles/store'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { commands, env, Uri, workspace } from 'vscode'
import { getToken, getYouTrackToken, setToken, setYouTrackToken } from '../../auth/secrets'
import { listClaudeProfiles } from '../../cc/profiles'
import { detectRepo } from '../../git/remote'
import { logger } from '../../logging/logger'
import { readProfiles, writeProfiles } from '../../profiles/store'
import { getSettings, saveSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator'
import { youtrackHost } from '../../youtrack/issueLoader'
import { makeNonce } from '../KanbanPanel'
export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
host: string
token: string
webhookPort: number
brainstormPrompt: string
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
devBranch: string
autoBuildBranch: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
implTabPostCloseScript: string
youtrackBaseUrl: string
youtrackProjectShortName: string
youtrackCloseCommand: string
/** Empty string = keep the existing stored YouTrack token (placeholder). */
youtrackToken: string
}): Promise<void> {
const trimmedHost = payload.host.trim()
const trimmedToken = payload.token.trim()
const trimmedYtBase = payload.youtrackBaseUrl.trim()
const trimmedYtProject = payload.youtrackProjectShortName.trim()
const trimmedYtClose = payload.youtrackCloseCommand.trim()
const trimmedYtToken = payload.youtrackToken.trim()
const trimmedDevBranch = payload.devBranch.trim()
const trimmedAutoBuildBranch = payload.autoBuildBranch.trim()
// Hook script paths: keep '' meaningful (= "use default
// .spx/*.sh"). Just strip whitespace.
const trimmedPostCreate = payload.worktreePostCreateScript.trim()
const trimmedPreRemove = payload.worktreePreRemoveScript.trim()
const trimmedImplPre = payload.implTabPreCreateScript.trim()
const trimmedImplPost = payload.implTabPostCloseScript.trim()
const prev = getSettings(panel.context)
// Capture the previous token *for this host* before overwriting it, so
// we can decide below whether the kanban needs a re-fetch. (Only host
// and token affect the issue list — port/url-prefix/prompts don't.)
const oldToken = trimmedHost ? await getToken(panel.context, trimmedHost) : undefined
// Empty token + existing saved token = user wants to keep the existing
// one (placeholder semantics in the modal). Skip rewriting and skip the
// kanban refresh since auth didn't change.
const keepExisting = trimmedToken === '' && !!oldToken
if (!trimmedHost || (!trimmedToken && !keepExisting)) {
panel.postMessage({
type: 'settings/show',
host: trimmedHost,
errorMessage: 'Host 和 Token 都不能为空',
tokenSaved: !!oldToken,
webhookPort: payload.webhookPort,
brainstormPrompt: payload.brainstormPrompt || prev.brainstormPrompt,
implementPlanPrompt: payload.implementPlanPrompt || prev.implementPlanPrompt,
autoReview: payload.autoReview,
reviewPrompt: payload.reviewPrompt || prev.reviewPrompt,
devBranch: trimmedDevBranch || prev.devBranch,
autoBuildBranch: trimmedAutoBuildBranch,
worktreePostCreateScript: trimmedPostCreate,
worktreePreRemoveScript: trimmedPreRemove,
implTabPreCreateScript: trimmedImplPre,
implTabPostCloseScript: trimmedImplPost,
})
return
}
await saveSettings(panel.context, {
webhookPort: payload.webhookPort,
brainstormPrompt: payload.brainstormPrompt,
implementPlanPrompt: payload.implementPlanPrompt,
autoReview: payload.autoReview,
reviewPrompt: payload.reviewPrompt,
// Persist trimmed values; '' is meaningful for autoBuildBranch
// ("follow devBranch"), so don't coerce — getSettings handles the
// fallback at read time.
devBranch: trimmedDevBranch,
autoBuildBranch: trimmedAutoBuildBranch,
worktreePostCreateScript: trimmedPostCreate,
worktreePreRemoveScript: trimmedPreRemove,
implTabPreCreateScript: trimmedImplPre,
implTabPostCloseScript: trimmedImplPost,
youtrackBaseUrl: trimmedYtBase,
youtrackProjectShortName: trimmedYtProject,
youtrackCloseCommand: trimmedYtClose,
})
if (!keepExisting)
await setToken(panel.context, trimmedHost, trimmedToken)
// YouTrack token: stored under the base URL's host. Empty input keeps the
// existing token (placeholder semantics), matching the gitea flow above.
if (trimmedYtBase && trimmedYtToken)
await setYouTrackToken(panel.context, youtrackHost(trimmedYtBase), trimmedYtToken)
// Honor a port change without requiring a window reload. Restart the
// listener and emit a log entry when the port actually changed so the
// user can see it in the log modal.
const newPort = getSettings(panel.context).webhookPort
if (newPort !== prev.webhookPort) {
logger.add({
level: 'info',
source: 'webhook',
message: `端口配置变更,重启监听 :${newPort}`,
})
}
try {
await webhookCoordinator.ensurePort(newPort)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'ensurePort 失败',
details: message,
})
}
// Only re-fetch issues when the credential that gates the kanban
// actually changed. Saves a round-trip + visible loading flash when the
// user just tweaked prompts or webhook settings. `keepExisting` already
// guarantees no auth change.
// Re-fetch when the gitea credential OR any YouTrack config changed (base
// URL / project / a freshly entered token) so newly-mirrored issues appear.
const youtrackChanged = prev.youtrackBaseUrl !== trimmedYtBase
|| prev.youtrackProjectShortName !== trimmedYtProject
|| trimmedYtToken !== ''
if ((!keepExisting && oldToken !== trimmedToken) || youtrackChanged)
await panel.loadAndPush()
// Branch-sync inputs may have changed — refresh the toolbar button.
void panel.handleBranchSyncCheck()
}
export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
let host = ''
if (workspaceRoot) {
const remote = await detectRepo(workspaceRoot)
if (remote)
host = remote.host
}
const s = getSettings(panel.context)
const tok = host ? await getToken(panel.context, host) : undefined
const tokenSaved = !!tok && tok.length > 0
const ytTok = s.youtrackBaseUrl.trim()
? await getYouTrackToken(panel.context, youtrackHost(s.youtrackBaseUrl.trim()))
: undefined
// User clicked the gear themselves — let them back out without saving.
panel.postMessage({
type: 'settings/show',
host,
canCancel: true,
tokenSaved,
webhookPort: s.webhookPort,
brainstormPrompt: s.brainstormPrompt,
implementPlanPrompt: s.implementPlanPrompt,
autoReview: s.autoReview,
reviewPrompt: s.reviewPrompt,
devBranch: s.devBranch,
autoBuildBranch: s.autoBuildBranch,
worktreePostCreateScript: s.worktreePostCreateScript,
worktreePreRemoveScript: s.worktreePreRemoveScript,
implTabPreCreateScript: s.implTabPreCreateScript,
implTabPostCloseScript: s.implTabPostCloseScript,
youtrackBaseUrl: s.youtrackBaseUrl,
youtrackProjectShortName: s.youtrackProjectShortName,
youtrackCloseCommand: s.youtrackCloseCommand,
youtrackTokenSaved: !!ytTok && ytTok.length > 0,
})
}
/** Forces the open panel (if any) into the setup-auth state. */
export function requestEditAuth(panel: KanbanWebviewPanel): void {
void handleEditSettingsRequest(panel)
}
/**
* 工作区 profile 表读取入口:webview mount 时 / 切换到 Profile tab 时拉数据。
* 文件不存在直接返回默认结构 `{ profiles: ['dev', 'prod'], rows: [] }`
* 不会物理创建文件。
*/
export async function handleProfilesGet(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '请先打开一个工作区文件夹',
dismissOnTimer: 5000,
})
return
}
try {
const data = await readProfiles(workspaceRoot)
panel.postMessage({ type: 'profiles/show', data })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `读取 profiles.json 失败: ${message}`,
dismissOnTimer: 6000,
})
}
}
/**
* Read profiles from the hardcoded directory and push the list to the
* webview. Failures are swallowed and surfaced as an empty list so the
* modal simply hides its profile selector.
*/
export async function handleProfilesList(panel: KanbanWebviewPanel): Promise<void> {
try {
const profiles = await listClaudeProfiles()
panel.postMessage({ type: 'profiles/update', profiles })
}
catch {
panel.postMessage({ type: 'profiles/update', profiles: [] })
}
}
/**
* 智能打开 profile 单元格里的 value。
*
* - http(s):// / git@host:owner/repo → 外部浏览器(git@ 先转 https
* - 其他 → 当作路径处理,`~` 展开为 home,相对路径以 workspaceRoot 为基
* - 是目录 → revealFileInOS
* - 是文件 → vscode.open
* - 不存在 → toast 报错
*/
/**
* Lower-cased extensions (with leading dot) that should open inside the
* VS Code editor. Anything not in this set — and files with no extension
* 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',
])
/**
* Open a file with the OS default application (fire-and-forget).
*
* Dispatches per platform without a shell so the path cannot be
* interpreted as a command. On Windows the first empty `start` argument is
* the window-title placeholder required by `cmd`'s `start` builtin.
*/
function openWithSystemDefault(absPath: string): void {
let command: string
let args: string[]
switch (process.platform) {
case 'darwin':
command = 'open'
args = [absPath]
break
case 'win32':
command = 'cmd'
args = ['/c', 'start', '', absPath]
break
default:
command = 'xdg-open'
args = [absPath]
break
}
execFile(command, args, (err) => {
if (err) {
logger.add({
level: 'error',
source: 'profiles',
message: `系统默认程序打开失败: ${absPath}`,
details: err.message,
})
}
})
}
export async function handleProfilesOpen(panel: KanbanWebviewPanel, value: string): Promise<void> {
const trimmed = value.trim()
if (!trimmed) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '无法打开:值为空',
dismissOnTimer: 5000,
})
return
}
// URL 分支:http(s) 直开;git@ 先转 https
if (/^(?:https?:\/\/|git@)/i.test(trimmed)) {
let url = trimmed
const gitSshMatch = /^git@([^:]+):(.+?)(?:\.git)?$/i.exec(trimmed)
if (gitSshMatch) {
const host = gitSshMatch[1]
const repoPath = gitSshMatch[2].replace(/\.git$/i, '')
url = `https://${host}/${repoPath}`
}
else if (/^https?:\/\//i.test(trimmed)) {
url = trimmed.replace(/\.git$/i, '')
}
try {
await env.openExternal(Uri.parse(url))
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `打开链接失败: ${message}`,
dismissOnTimer: 6000,
})
}
return
}
// 路径分支:~ 展开、相对路径补 workspaceRoot
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
let abs: string
if (trimmed.startsWith('~')) {
abs = path.join(os.homedir(), trimmed.slice(1))
}
else if (path.isAbsolute(trimmed)) {
abs = trimmed
}
else {
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '无法解析相对路径:未打开工作区',
dismissOnTimer: 5000,
})
return
}
abs = path.join(workspaceRoot, trimmed)
}
let stat: fs.Stats
try {
stat = fs.statSync(abs)
}
catch {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `无法打开:路径不存在 / 不是 URL(${abs}`,
dismissOnTimer: 6000,
})
return
}
try {
if (stat.isDirectory()) {
await commands.executeCommand('revealFileInOS', Uri.file(abs))
}
else if (stat.isFile()) {
const ext = path.extname(abs).toLowerCase()
// No extension counts as text. Text/code extensions open in the
// editor; everything else (archives, office docs, media, …) is
// handed to the OS default application so binary content is not
// garbled by VS Code's text viewer.
if (ext === '' || TEXT_OPEN_EXTENSIONS.has(ext)) {
await commands.executeCommand('vscode.open', Uri.file(abs))
}
else {
openWithSystemDefault(abs)
}
}
else {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `无法打开:既不是文件也不是目录(${abs}`,
dismissOnTimer: 6000,
})
}
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `打开失败: ${message}`,
dismissOnTimer: 6000,
})
}
}
/**
* 工作区 profile 表写入入口。webview 已经 optimistic 更新,
* 成功不回消息;失败弹 toast。
*/
export async function handleProfilesSave(panel: KanbanWebviewPanel, data: ProfilesData): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '请先打开一个工作区文件夹',
dismissOnTimer: 5000,
})
return
}
try {
await writeProfiles(workspaceRoot, data)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `写入 profiles.json 失败: ${message}`,
dismissOnTimer: 6000,
})
}
}
+383
View File
@@ -0,0 +1,383 @@
import type {
Terminal,
TerminalEditorLocationOptions,
} from 'vscode'
import type { Issue } from '../../gitea/types'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { ViewColumn, window } from 'vscode'
import { logger } from '../../logging/logger'
export function resolveTerminalLocation(panel: KanbanWebviewPanel, preserveFocus: boolean): TerminalEditorLocationOptions {
void panel
// Pin all plugin-managed terminals to editor group 2 (right side of the
// kanban panel in column 1). VS Code creates the group on demand if it
// doesn't exist yet, and stacks new terminals as tabs in that group when
// it does — exactly what we want, no manual scan of existing tabs needed.
return { viewColumn: ViewColumn.Two, preserveFocus }
}
/**
* Inject review feedback into the implementation terminal for `issueNumber`.
* Called from the webhook coordinator's auto-review path. Returns `true`
* when a terminal was found and `sendText` was called; `false` otherwise
* (so the caller can log a warning).
*
* Falls back to looking up by terminal name when the in-memory map miss
* happens — e.g. after a window reload, the impl terminal might still be
* present in `window.terminals` but absent from `this.implTerminals`.
*/
export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: number, text: string, isFirstReview: boolean): boolean {
// `isFirstReview` 参数保留只为兼容签名(调用方仍按 first/再审 区分),
// 方法体本身不再消费它——合并改由用户拖工单到"完成"列时插件 API 触发,
// cc 不允许自行合并。
void isFirstReview
let terminal = panel.implTerminals.get(issueNumber)
if (!terminal) {
// Match by prefix — shell OSC title escapes can append a git branch
// suffix to terminal.name (e.g. "issue-48-实施 5f56026c").
const wantedPrefix = `issue-${issueNumber}-实施`
for (const t of window.terminals) {
if (t.name.startsWith(wantedPrefix)) {
terminal = t
panel.implTerminals.set(issueNumber, t)
trackSessionTerminal(panel, t, issueNumber, 'implement')
break
}
}
}
if (!terminal) {
// 实施会话未开但测试会话开着时,审查反馈交给测试会话。
// 临时使用,不写进 implTerminals(那是实施终端专属的 map)。
const testPrefix = `issue-${issueNumber}-测试`
for (const t of window.terminals) {
if (t.exitStatus === undefined && t.name.startsWith(testPrefix)) {
terminal = t
break
}
}
}
if (!terminal)
return false
// cc 的 TUI 在 raw 模式下,LF (\n) 只算输入框内的换行,CR (\r)
// 才会被识别为 Enter(提交消息)。实测把多行内容 + 末尾 \r 在同一次
// sendText 里发出去时,cc 进入多行输入模式后并不会把紧跟的 \r 当成
// 提交键,结果就是反馈只粘贴到输入框、没提交。
// 拆两次发:先把内容完整推进输入框,250ms 后再独立发一个 \r 作为 Enter。
const body = `\n[审查反馈]\n${text}`
terminal.sendText(body, false)
setTimeout(() => {
terminal!.sendText('\r', false)
}, 250)
return true
}
/**
* Scan `vscode.window.terminals` for an existing terminal whose name
* matches `expectedName`. Matches exact, or `startsWith(expectedName + ' ')`
* so a shell that appended a git branch suffix (e.g. "issue-48-实施
* 5f56026c") still counts. Skips terminals whose process already exited
* (`exitStatus !== undefined`) — those are zombie tabs and shouldn't block
* a fresh spawn.
*
* Why this exists: every session entry (`handleResumeSession`,
* `handleResumeReviewSession`, `handleImplement`) used to dedupe via its
* own in-memory Map (sessionId → Terminal). Panel reload / webview rebuild
* wipes those Maps, but the terminal stays alive in `window.terminals`,
* so re-clicking the link spawned a duplicate tab. Scanning the live
* terminal list survives reloads and prevents cross-map blind spots.
*/
export function findExistingTerminal(panel: KanbanWebviewPanel, expectedName: string): Terminal | undefined {
void panel
return window.terminals.find(
t =>
t.exitStatus === undefined
&& (t.name === expectedName || t.name.startsWith(`${expectedName} `)),
)
}
/**
* Focus an already-open terminal for `sessionId` without stealing focus
* from the kanban. Called when the webview's selection changes via arrow
* keys / clicks — if there's no terminal for this session yet, this is a
* no-op (user has to press Enter to spawn one).
*/
export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: number): void {
// 如果这次 session/focus 是 onDidChangeActiveTerminal 反选触发的回路
// (而不是用户主动点卡片切换工单),跳过优先级跳转,否则会把用户刚刚
// 点的"审查 tab"弹回到优先级更高的"实施 tab"。
const REVERSE_LOOP_WINDOW_MS = 200
if (
panel.lastReverseSelectIssueNumber === issueNumber
&& Date.now() - panel.lastReverseSelectAt < REVERSE_LOOP_WINDOW_MS
) {
return
}
// Priority 0: new-issue flow terminal whose name is `issue-new-{nonce}-规划`,
// not `issue-${N}-规划`. The webhook coordinator stitches issueNumber →
// terminal into `newIssueTerminals` via `linkPendingTerminalToIssue`.
// We can't rename the terminal tab (VS Code API limitation), so this
// side-map is the only way to find it by issueNumber.
const newIssueTerm = panel.newIssueTerminals.get(issueNumber)
if (newIssueTerm && newIssueTerm.exitStatus === undefined) {
if (newIssueTerm !== window.activeTerminal) {
panel.programmaticTabReveal = newIssueTerm
newIssueTerm.show(true)
}
return
}
// Priority 1-4: 实施 > 规划 > 审查 > 测试. Match by terminal.name since we know
// the convention (issue-${N}-实施 / issue-${N}-规划 / issue-${N}-审查 / issue-${N}-测试).
const namePriority = [
`issue-${issueNumber}-实施`,
`issue-${issueNumber}-规划`,
`issue-${issueNumber}-审查`,
`issue-${issueNumber}-测试`,
]
for (const name of namePriority) {
const term = findExistingTerminal(panel, name)
if (term) {
// 已经是 active terminal 就不重复 show(避免无谓的回声事件);
// 否则记下程序化 reveal,让随后的 active 变更被吞掉,不回环。
if (term !== window.activeTerminal) {
panel.programmaticTabReveal = term
term.show(true)
}
return
}
}
// 找不到不报错,静默 return(用户没开过终端,正常)。
}
/**
* 反向选中:用户在 column 2 切换 terminal tab 时,从 terminal.name 解析
* issueNumber 并通知 webview 选中对应工单。
*
* 终端名按 `issue-${N}-(规划|实施|审查)` 命名;`issue-new-${nonce}-...`
* 是新建工单流程的占位 tab,没有 issue number,跳过。
*/
export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal: Terminal): void {
// Dedupe by reference: OSC title rewrites / shell prompt updates fire
// onDidChangeTabs repeatedly for the same terminal. Skip if it's the
// same reference we just handled.
// 吞掉「我们自己 show() 引发的」active 变更回声:handleSessionFocus 在程序化
// show 前把目标终端记到 programmaticTabReveal,这里命中就 return,不反向选中,
// 从根上打断 选中→聚焦→选中 的死循环(基于引用,不依赖脆弱的时间窗)。
// 每次调用都清空,因此只压制紧随其后的那一次回声,用户真实点 tab 不受影响。
const reveal = panel.programmaticTabReveal
panel.programmaticTabReveal = undefined
if (terminal === reveal)
return
if (terminal === panel.lastActiveTerminalRef)
return
panel.lastActiveTerminalRef = terminal
// Primary: `issue-${N}-(规划|实施|审查|测试)` — the steady-state naming.
const m = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)/)
if (m) {
const issueNumber = Number.parseInt(m[1], 10)
if (Number.isFinite(issueNumber)) {
panel.lastReverseSelectAt = Date.now()
panel.lastReverseSelectIssueNumber = issueNumber
panel.postMessage({ type: 'issue/select-by-number', issueNumber })
}
return
}
// Fallback: `issue-new-${shortNonce}-规划` — created via the new-issue flow
// before we knew the issueNumber. Reverse-scan `newIssueTerminals` (which
// was populated by the webhook coordinator via `linkPendingTerminalToIssue`).
if (terminal.name.startsWith('issue-new-')) {
for (const [num, term] of panel.newIssueTerminals) {
if (term === terminal) {
panel.lastReverseSelectAt = Date.now()
panel.lastReverseSelectIssueNumber = num
panel.postMessage({ type: 'issue/select-by-number', issueNumber: num })
return
}
}
}
}
/**
* 将当前 VS Code 里仍存活的会话终端同步回 issue 列表。
*
* `issues/update` 会用 Gitea 里持久化的 state JSON 整体替换 webview
* 状态;tabOpen 是本地运行态,不在 state JSON 中,因此发送列表前要按
* live terminal 重新补齐,同时登记 terminalOrigin,确保详情面板的关闭
* 按钮能找到对应 terminal。
*/
export function withLiveTerminalTabState(panel: KanbanWebviewPanel, issues: Issue[]): Issue[] {
const liveByIssue = new Map<number, Partial<Pick<Issue, 'brainstormTabOpen' | 'implementTabOpen' | 'reviewTabOpen' | 'testTabOpen'>>>()
const mark = (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test'): void => {
const patch = liveByIssue.get(issueNumber) ?? {}
if (kind === 'brainstorm')
patch.brainstormTabOpen = true
else if (kind === 'implement')
patch.implementTabOpen = true
else if (kind === 'review')
patch.reviewTabOpen = true
else
patch.testTabOpen = true
liveByIssue.set(issueNumber, patch)
}
for (const [terminal, origin] of panel.terminalOrigin) {
if (terminal.exitStatus === undefined)
mark(origin.issueNumber, origin.kind)
}
for (const terminal of window.terminals) {
if (terminal.exitStatus !== undefined)
continue
const match = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)(?:\s|$)/)
if (!match)
continue
const issueNumber = Number.parseInt(match[1], 10)
if (!Number.isFinite(issueNumber))
continue
const kind = match[2] === '规划'
? 'brainstorm'
: match[2] === '实施'
? 'implement'
: match[2] === '审查'
? 'review'
: 'test'
panel.terminalOrigin.set(terminal, { issueNumber, kind })
mark(issueNumber, kind)
}
if (liveByIssue.size === 0)
return issues
return issues.map((issue) => {
const patch = liveByIssue.get(issue.number)
return patch ? { ...issue, ...patch } : issue
})
}
/**
* 登记一个新创建/复用的会话终端,并主动推一条 issue/patch 把对应 tab 打开
* 标志置 true。详情面板侧据此在三行会话 id 右侧渲染关闭按钮。
*
* 复用场景(map 命中、existingByName 命中)也会再调一次,是幂等的:
* 重复设置 terminalOrigin 不影响,重复推 true 在 webview 端的
* `mergeIssuePatch` 也是 no-op。
*/
export function trackSessionTerminal(
panel: KanbanWebviewPanel,
terminal: Terminal,
issueNumber: number,
kind: 'brainstorm' | 'implement' | 'review' | 'test',
): void {
panel.terminalOrigin.set(terminal, { issueNumber, kind })
panel.postMessage({
type: 'issue/patch',
issueNumber,
patch:
kind === 'brainstorm'
? { brainstormTabOpen: true }
: kind === 'implement'
? { implementTabOpen: true }
: kind === 'review'
? { reviewTabOpen: true }
: { testTabOpen: true },
})
}
/**
* onDidCloseTerminal 触发后调用:反查 `terminalOrigin` 找到该 terminal
* 对应的工单 + 会话类型,并推 false 让详情面板隐藏关闭按钮。
*
* 同时从 `terminalOrigin` 删除条目;四个 issue-aware Map 的清理仍由
* 现有 onDidCloseTerminal 循环负责,本方法只关心 webview 通知。
*/
export function untrackClosedTerminal(panel: KanbanWebviewPanel, closed: Terminal): void {
const origin = panel.terminalOrigin.get(closed)
if (!origin)
return
panel.terminalOrigin.delete(closed)
panel.postMessage({
type: 'issue/patch',
issueNumber: origin.issueNumber,
patch:
origin.kind === 'brainstorm'
? { brainstormTabOpen: false }
: origin.kind === 'implement'
? { implementTabOpen: false }
: origin.kind === 'review'
? { reviewTabOpen: false }
: { testTabOpen: false },
})
}
/**
* webview 端"关闭 tab"按钮入口:直接扫 `terminalOrigin` 找匹配
* (issueNumber, kind) 的 terminal 并 `dispose()`。VS Code 随后会发
* onDidCloseTerminal,由统一回调清理四个 issue-aware Map 和推 flag=false
* ——本方法不直接改任何 Map,避免双重清理。
*
* 用 terminalOrigin 而不是分别查四个 Map:审查 tab 在
* `triggerAutoReviewTab` 路径里不会进 `reviewTerminals`(那个 Map 按
* thread_id 索引,此路径还没 thread_id),但会被登记到 terminalOrigin
* 所以用它做单一真相源最稳。
*/
export function handleCloseSessionTab(panel: KanbanWebviewPanel, issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test'): void {
let terminal: Terminal | undefined
for (const [t, origin] of panel.terminalOrigin) {
if (origin.issueNumber === issueNumber && origin.kind === kind) {
terminal = t
break
}
}
if (!terminal) {
logger.add({
level: 'warn',
source: 'panel',
message: `关闭 ${kind} tab 失败 #${issueNumber}:未找到对应终端`,
})
return
}
terminal.dispose()
}
/**
* Removes and returns the {@link pendingIssueCreations} entry for the
* given nonce, if any. Called by the webhook coordinator when a matching
* `issues opened` payload arrives. Returning `undefined` (and leaving the
* map untouched) signals "no match — treat as external issue creation".
*/
export function takePendingIssueCreation(panel: KanbanWebviewPanel, nonce: string): {
sessionId?: string
profilePath?: string
color: string
workspaceRoot: string
inboxDir: string
terminalName: string
terminal: Terminal
createdAt: number
} | undefined {
const entry = panel.pendingIssueCreations.get(nonce)
if (entry)
panel.pendingIssueCreations.delete(nonce)
return entry
}
/**
* Called by the webhook coordinator after it parses a `spx:nonce=...` token
* out of `issue.body` and matches it to an entry in `pendingIssueCreations`.
* Promotes the in-flight brainstorm terminal into the issueNumber-keyed
* `newIssueTerminals` map so subsequent `handleSessionFocus(issueNumber)`
* and `handleActiveTerminalChanged` calls can find it.
*
* Also drops the terminal into `terminals` keyed by sessionId (when known)
* so `handleResumeSession` reuses the same tab instead of spawning a new
* one. Does NOT remove the pending entry — `takePendingIssueCreation` is
* still responsible for cleanup on its own path.
*/
export function linkPendingTerminalToIssue(panel: KanbanWebviewPanel, nonce: string, issueNumber: number): void {
const pending = panel.pendingIssueCreations.get(nonce)
if (!pending)
return
panel.newIssueTerminals.set(issueNumber, pending.terminal)
if (pending.sessionId && pending.sessionId.length > 0)
panel.terminals.set(pending.sessionId, pending.terminal)
trackSessionTerminal(panel, pending.terminal, issueNumber, 'brainstorm')
}
+373
View File
@@ -0,0 +1,373 @@
import type { Uri } from 'vscode'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { extensions, workspace } from 'vscode'
import { listClaudeProfiles } from '../../cc/profiles'
import { spawnClaude } from '../../cc/spawnClaude'
import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock'
import { checkBranchSync, runBranchSync } from '../../git/branchSync'
import { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel'
/**
* Minimal structural typing for the bits of the built-in `vscode.git`
* extension API we consume to observe working-tree state.
* Reference: microsoft/vscode → extensions/git/src/api/git.d.ts
*/
interface GitExtensionApiRepositoryStateChange {
(listener: () => unknown): { dispose: () => void }
}
interface GitExtensionApiRepositoryState {
readonly workingTreeChanges: ReadonlyArray<{ uri: Uri }>
readonly indexChanges: ReadonlyArray<{ uri: Uri }>
readonly untrackedChanges?: ReadonlyArray<{ uri: Uri }>
readonly onDidChange: GitExtensionApiRepositoryStateChange
}
interface GitExtensionApiRepository {
readonly rootUri: Uri
readonly state: GitExtensionApiRepositoryState
}
interface GitExtensionApi {
readonly repositories: ReadonlyArray<GitExtensionApiRepository>
}
interface GitExtensionExports {
getAPI: (version: 1) => GitExtensionApi
}
export async function handleCommitRun(panel: KanbanWebviewPanel): Promise<void> {
if (panel.commitRunning)
return
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '请先打开一个工作区文件夹',
dismissOnTimer: 5000,
})
return
}
const profiles = await listClaudeProfiles()
const deepseek = profiles.find(p => p.name === 'deepseek-v4-pro')
if (!deepseek) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '未找到 deepseek profile,请在 /home/cruldra/Sources/cruldra-profile/claude-config/profiles/ 下创建 deepseek.json',
dismissOnTimer: 8000,
})
return
}
panel.commitRunning = true
panel.postMessage({ type: 'commit/state', running: true })
try {
// claude -p 提交流程可能跑得比较久(要 git add / 编 commit message / git
// commit),所以给一个比较宽松的超时(30 min)。
await spawnClaude({
prompt: '提交下代码',
cwd: workspaceRoot,
profilePath: deepseek.path,
timeoutMs: 30 * 60 * 1000,
bare: true,
})
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'success',
message: '提交完成',
dismissOnTimer: 5000,
})
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `提交失败: ${msg}`,
dismissOnTimer: 8000,
})
}
finally {
panel.commitRunning = false
panel.postMessage({ type: 'commit/state', running: false })
}
}
export async function handleBranchSyncCheck(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
const s = getSettings(panel.context)
const devBranch = s.devBranch
// Empty string in storage means "follow devBranch" — resolve to
// devBranch so the check naturally hits the "same branch" disabled
// branch.
const autoBuildBranch = s.autoBuildBranch.length > 0 ? s.autoBuildBranch : devBranch
if (!workspaceRoot) {
panel.postMessage({
type: 'branch-sync/status',
behind: 0,
devBranch,
autoBuildBranch,
unavailable: true,
reason: '请先打开一个工作区文件夹',
})
return
}
const status = await checkBranchSync({ workspaceRoot, devBranch, autoBuildBranch })
panel.postMessage({ type: 'branch-sync/status', ...status })
}
export async function handleBranchSyncRun(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
const s = getSettings(panel.context)
const devBranch = s.devBranch
const autoBuildBranch = s.autoBuildBranch.length > 0 ? s.autoBuildBranch : devBranch
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '请先打开一个工作区文件夹',
dismissOnTimer: 5000,
})
void handleBranchSyncCheck(panel)
return
}
if (devBranch === autoBuildBranch) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'info',
message: '开发分支与自动化构建分支相同,无需同步',
dismissOnTimer: 5000,
})
void handleBranchSyncCheck(panel)
return
}
try {
await runBranchSync({ workspaceRoot, devBranch, autoBuildBranch })
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'success',
message: `已同步 ${devBranch}${autoBuildBranch}`,
dismissOnTimer: 5000,
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `分支同步失败:${message}`,
dismissOnTimer: 8000,
})
}
finally {
// Re-emit status regardless of success/failure so the button reflects
// the new behind count (0 on success, unchanged on failure).
void handleBranchSyncCheck(panel)
}
}
export async function handleEnvLockCheck(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
// 用底层 get 不传默认值,能区分"从未设过"(undefined) 和"用户主动选 false"。
const stored = panel.context.workspaceState.get<boolean>('envLocked')
if (!workspaceRoot) {
// 无工作区时无文件可锁,UI 默认显示锁定状态但 fileCount=0 等价于无操作。
panel.postMessage({ type: 'env-lock/status', locked: stored ?? true, fileCount: 0 })
return
}
if (stored === undefined) {
// 首次启动:默认锁定 + 真正 chmod 0o444 + 持久化,避免 UI 与 fs 不一致。
const result = await lockEnvFiles(workspaceRoot)
await panel.context.workspaceState.update('envLocked', true)
if (result.total === 0) {
logger.add({
level: 'info',
source: 'panel',
message: '首次启动:工作区无 .env 文件,跳过自动锁定',
})
}
else if (result.failed.length === 0) {
logger.add({
level: 'info',
source: 'panel',
message: `首次启动自动锁定 ${result.ok.length} 个 .env 文件`,
})
}
else {
logger.add({
level: 'warn',
source: 'panel',
message: `首次启动自动锁定:成功 ${result.ok.length} 个,失败 ${result.failed.length}`,
})
}
panel.postMessage({
type: 'env-lock/status',
locked: true,
fileCount: result.total,
failedCount: result.failed.length > 0 ? result.failed.length : undefined,
})
return
}
const files = await findEnvFiles(workspaceRoot)
panel.postMessage({ type: 'env-lock/status', locked: stored, fileCount: files.length })
}
export async function handleEnvLockToggle(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '请先打开一个工作区文件夹',
dismissOnTimer: 5000,
})
void handleEnvLockCheck(panel)
return
}
const prevLocked = panel.context.workspaceState.get<boolean>('envLocked', false)
const nextLocked = !prevLocked
const result = nextLocked
? await lockEnvFiles(workspaceRoot)
: await unlockEnvFiles(workspaceRoot)
await panel.context.workspaceState.update('envLocked', nextLocked)
if (result.total === 0) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'info',
message: '工作区无 .env 文件',
dismissOnTimer: 5000,
})
}
else if (result.failed.length === 0) {
const verb = nextLocked ? '锁定' : '解锁'
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'success',
message: `${verb} ${result.ok.length} 个 .env 文件`,
dismissOnTimer: 4000,
})
}
else {
const verb = nextLocked ? '锁定' : '解锁'
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `${verb}完成:成功 ${result.ok.length} 个,失败 ${result.failed.length}`,
dismissOnTimer: 8000,
})
}
panel.postMessage({
type: 'env-lock/status',
locked: nextLocked,
fileCount: result.total,
failedCount: result.failed.length > 0 ? result.failed.length : undefined,
})
}
export function updateHasChanges(panel: KanbanWebviewPanel, value: boolean): void {
if (panel.hasGitChanges === value)
return
panel.hasGitChanges = value
panel.postMessage({ type: 'commit/has-changes', value })
}
export function setupGitWatcher(panel: KanbanWebviewPanel): void {
const wsRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!wsRoot)
return
const gitExt = extensions.getExtension<GitExtensionExports>('vscode.git')
if (!gitExt) {
logger.add({
level: 'warn',
source: 'panel',
message: 'vscode.git 扩展未找到,提交按钮将持续显示',
})
// Fallback: keep the button visible so users can at least trigger
// commits manually even though we can't observe state.
updateHasChanges(panel, true)
return
}
const activation = gitExt.isActive
? Promise.resolve(gitExt.exports)
: Promise.resolve(gitExt.activate())
void activation.then((exports) => {
const api = exports.getAPI(1)
const findRepo = (): GitExtensionApiRepository | undefined =>
api.repositories.find(r => r.rootUri.fsPath === wsRoot)
const updateState = (): void => {
const repo = findRepo()
if (!repo) {
updateHasChanges(panel, false)
return
}
const total
= repo.state.workingTreeChanges.length
+ repo.state.indexChanges.length
+ (repo.state.untrackedChanges?.length ?? 0)
updateHasChanges(panel, total > 0)
}
const subscribe = (repo: GitExtensionApiRepository): void => {
panel.gitStateDisposable = repo.state.onDidChange(updateState)
}
updateState()
const repo = findRepo()
if (repo) {
subscribe(repo)
return
}
// vscode.git activation can resolve before its `repositories` array
// has populated for the freshly opened workspace. Retry once after a
// short delay; if still missing we just leave the button hidden.
setTimeout(() => {
const retried = findRepo()
if (retried)
subscribe(retried)
updateState()
}, 1000)
}).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'panel',
message: '激活 vscode.git 扩展失败,提交按钮将持续显示',
details: msg,
})
updateHasChanges(panel, true)
})
}
+287
View File
@@ -0,0 +1,287 @@
import type { HookContext } from '../../git/worktreeHooks'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process'
import * as path from 'node:path'
import { commands, Uri, window, workspace } from 'vscode'
import { getToken } from '../../auth/secrets'
import { detectRepo } from '../../git/remote'
import {
runImplTabPostCloseHook,
runImplTabPreCreateHook,
runPostCreateHook,
runPreRemoveHook,
} from '../../git/worktreeHooks'
import { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel'
/**
* Open the worktree directory in a **new** VS Code window. The Boolean
* third arg to `vscode.openFolder` is "forceNewWindow"; we always force
* a new window so the user keeps the kanban window open in parallel.
*/
export async function handleOpenWorktree(panel: KanbanWebviewPanel, relPath: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
return
}
const abs = path.isAbsolute(relPath) ? relPath : path.join(workspaceRoot, relPath)
try {
await commands.executeCommand('vscode.openFolder', Uri.file(abs), true)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
void window.showErrorMessage(`打开 worktree 失败: ${message}`)
}
}
/**
* Confirm + run `git worktree remove <abs>` (no --force), then clear
* `worktreePath`/`branch` from the issue's state JSON and refresh the
* board. If `git` rejects due to uncommitted changes we surface stderr
* verbatim — the user can resolve manually and re-try.
*/
export async function handleDeleteWorktree(panel: KanbanWebviewPanel, issueNumber: number, relPath: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
return
}
const choice = await window.showWarningMessage(
`确认删除 worktree ${relPath}?`,
{ modal: true },
'删除',
)
if (choice !== '删除')
return
const abs = path.isAbsolute(relPath) ? relPath : path.join(workspaceRoot, relPath)
// Run the pre-remove lifecycle hook so the user can tear down resources
// (close IDE windows, etc.) before the worktree dir vanishes. Best-effort
// — never blocks the removal.
const settingsForHook = getSettings(panel.context)
await dispatchWorktreeHook(panel, 'pre-remove', {
workspaceRoot,
worktreePath: abs,
branch: '',
issueNumber,
mainBranch: settingsForHook.devBranch || 'main',
customScriptPath: settingsForHook.worktreePreRemoveScript,
})
try {
await new Promise<void>((resolve, reject) => {
execFile(
'git',
['-C', workspaceRoot, 'worktree', 'remove', abs],
{ timeout: 30_000 },
(err, _stdout, stderr) => {
if (err) {
const detail = (stderr ?? '').trim() || err.message
reject(new Error(detail))
return
}
resolve()
},
)
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
void window.showErrorMessage(`git worktree remove 失败: ${message}`)
return
}
// Best-effort: clear worktreePath + branch from the state JSON so the
// detail panel reflects reality on the next refresh. Failures here are
// non-fatal (worktree is already gone on disk).
try {
await panel.mergeIssueState(issueNumber, { worktreePath: '', branch: '' })
}
catch (err) {
console.warn('[superpowers] failed to clear worktree state JSON:', err)
}
panel.postMessage({
type: 'issue/patch',
issueNumber,
patch: {
worktreePath: undefined,
branch: undefined,
worktreeExists: false,
},
})
void window.showInformationMessage(`已删除 worktree #${issueNumber}`)
}
/**
* Pre-merge a feature branch into the **main worktree** (workspace root) so the
* user can inspect/test the merge result locally before committing. Runs
* `git merge --no-commit --no-ff <branch>`, which never auto-commits and always
* forces a merge commit structure. Feedback is surfaced via notifications.
*/
export async function handleMergePreview(panel: KanbanWebviewPanel, issueNumber: number, branch: string): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showErrorMessage('请先打开一个工作区文件夹')
return
}
if (!branch) {
void window.showWarningMessage('该工单没有分支')
return
}
// Best-effort: figure out the branch currently checked out in the main
// worktree so the success message can warn about merging into the wrong
// place. Never blocks the merge.
const currentBranch = await new Promise<string>((resolve) => {
execFile(
'git',
['-C', workspaceRoot, 'rev-parse', '--abbrev-ref', 'HEAD'],
{ timeout: 10_000 },
(err, stdout) => {
resolve(err ? '?' : (stdout ?? '').trim() || '?')
},
)
})
// `git merge` reports conflicts on stdout ("CONFLICT ...") and the
// "Automatic merge failed" line on stderr with a non-zero exit code, so we
// judge success solely by `err == null`.
const result = await new Promise<{ err: Error | null, output: string }>((resolve) => {
execFile(
'git',
['-C', workspaceRoot, 'merge', '--no-commit', '--no-ff', branch],
{ timeout: 60_000 },
(err, stdout, stderr) => {
const output = `${stdout ?? ''}${stderr ?? ''}`.trim()
resolve({ err: err ?? null, output })
},
)
})
if (result.err == null) {
void window.showInformationMessage(`已把 ${branch} 预合并到当前分支 ${currentBranch}(未提交)。检查无误后自行 commit,或执行 git merge --abort 撤销。`)
return
}
const detail = result.output || result.err.message
logger.add({
level: 'warn',
source: 'panel',
message: `git merge --no-commit --no-ff ${branch} 未完成(issue #${issueNumber})`,
details: detail,
})
void window.showWarningMessage(`git merge 未完成(冲突或出错): ${detail}。如需撤销执行 git merge --abort`)
}
/**
* Run a user-provided shell script (post-create / pre-remove) and surface
* the outcome via logger + a toast. Always returns — failures of any
* kind never abort the calling flow, per the lifecycle-hook contract:
* worktree creation / removal is the source of truth, user scripts are
* best-effort sidecars.
*/
export async function dispatchWorktreeHook(
panel: KanbanWebviewPanel,
phase: 'post-create' | 'pre-remove' | 'impl-tab-pre-create' | 'impl-tab-post-close',
ctx: HookContext,
): Promise<void> {
let result
switch (phase) {
case 'post-create': result = await runPostCreateHook(ctx); break
case 'pre-remove': result = await runPreRemoveHook(ctx); break
case 'impl-tab-pre-create': result = await runImplTabPreCreateHook(ctx); break
case 'impl-tab-post-close': result = await runImplTabPostCloseHook(ctx); break
}
// 'skipped' = the script simply isn't on disk. That's the intended
// default state — stay silent, don't pester the user.
if (result.status === 'skipped')
return
if (result.status === 'ok') {
logger.add({
level: 'info',
source: 'panel',
message: `worktree ${phase} 钩子完成 #${ctx.issueNumber}`,
details: `path=${result.scriptPath}\nstdout=${result.stdout ?? ''}\nstderr=${result.stderr ?? ''}`,
})
return
}
// Anything else (failed / timeout / enoent) — warn-level log + a
// toast. ToastLevel only has 'info' | 'success' | 'error'; we use
// 'info' to keep it non-blocking, matching the "doesn't affect main
// flow" semantics. Detailed stdout/stderr stays in the log to avoid
// spamming the toast surface.
const label = {
'post-create': '创建后',
'pre-remove': '删除前',
'impl-tab-pre-create': '实施 tab 创建前',
'impl-tab-post-close': '实施 tab 关闭后',
}[phase]
logger.add({
level: 'warn',
source: 'panel',
message: `worktree ${phase} 钩子失败 #${ctx.issueNumber}: ${result.status}`,
details: [
`path=${result.scriptPath ?? '(unresolved)'}`,
result.exitCode !== undefined ? `exitCode=${result.exitCode}` : null,
result.errorMessage ? `err=${result.errorMessage}` : null,
result.stdout ? `stdout=${result.stdout}` : null,
result.stderr ? `stderr=${result.stderr}` : null,
].filter((line): line is string => line !== null).join('\n'),
})
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'info',
message: `worktree ${label}钩子失败 #${ctx.issueNumber}(不影响后续流程,详情见日志)`,
dismissOnTimer: 5000,
})
}
/**
* 实施 cc tab 被关闭后异步触发 impl-tab-post-close 钩子。
* 由 onDidCloseTerminal 同步回调里 fire-and-forget 调用,所以这里把整个
* 错误 catch 进 log,不向上抛。需要从 state JSON 读 worktreePath / branch。
*/
export async function dispatchImplTabPostCloseAsync(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
try {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return
const remote = await detectRepo(workspaceRoot)
if (!remote)
return
const token = await getToken(panel.context, remote.host)
if (!token)
return
const stateObj = await panel.readIssueState(issueNumber)
const branch = typeof stateObj.branch === 'string' ? stateObj.branch : ''
const wt = typeof stateObj.worktreePath === 'string' ? stateObj.worktreePath : ''
if (!wt)
return
const absWorktree = path.isAbsolute(wt) ? wt : path.join(workspaceRoot, wt)
const settings = getSettings(panel.context)
await dispatchWorktreeHook(panel, 'impl-tab-post-close', {
workspaceRoot,
worktreePath: absWorktree,
branch,
issueNumber,
mainBranch: settings.devBranch || 'main',
customScriptPath: settings.implTabPostCloseScript,
})
}
catch (err) {
logger.add({
level: 'warn',
source: 'panel',
message: `impl-tab-post-close 钩子调度失败 #${issueNumber}`,
details: err instanceof Error ? err.message : String(err),
})
}
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Panel handlers for YouTrack-sourced cards.
*
* Kept separate from the gitea `issues.ts` handlers: these route workflow-state
* writes to the issue's YouTrack comment (see `youtrack/stateComment.ts`) and
* resolve the issue in YouTrack when its card lands in the 完成 column — the
* only write-back the user asked for.
*/
import type { IssueColumn } from '../../gitea/types'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import type { YouTrackAuth } from '../../youtrack/api'
import { ThemeIcon, window, workspace } from 'vscode'
import { getYouTrackToken } from '../../auth/secrets'
import { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store'
import { applyCommand, listIssues, listProjects, resolvedStateCommand } from '../../youtrack/api'
import { readImportedIds, writeImportedIds } from '../../youtrack/importStore'
import { youtrackHost } from '../../youtrack/issueLoader'
import { mergeStateComment } from '../../youtrack/stateComment'
import { makeNonce } from '../KanbanPanel'
function toast(panel: KanbanWebviewPanel, level: 'info' | 'success' | 'error', message: string): void {
panel.postMessage({ type: 'toast/show', id: makeNonce(), level, message, dismissOnTimer: 6000 })
}
/** Resolve {baseUrl, token} from settings + SecretStorage, or null if YouTrack
* isn't fully configured. */
async function resolveAuth(panel: KanbanWebviewPanel): Promise<YouTrackAuth | null> {
const baseUrl = getSettings(panel.context).youtrackBaseUrl.trim()
if (!baseUrl)
return null
const token = await getYouTrackToken(panel.context, youtrackHost(baseUrl))
if (!token)
return null
return { baseUrl, token }
}
/**
* Persist a column move for a YouTrack card, and resolve the issue in YouTrack
* when it moves to 完成. Re-pulls the board afterwards rather than threading a
* source-aware optimistic patch — correctness over precision for v1.
*/
export async function handleYouTrackColumnChange(panel: KanbanWebviewPanel, externalId: string, toColumn: IssueColumn): Promise<void> {
const auth = await resolveAuth(panel)
if (!auth) {
toast(panel, 'error', '请先在设置里配置 YouTrackBase URL + Token')
await panel.loadAndPush()
return
}
try {
await mergeStateComment(auth, externalId, { column: toColumn })
if (toColumn === 'done')
await closeYouTrackIssue(panel, auth, externalId)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'youtrack', message: `${externalId} 列变更失败`, details: message })
toast(panel, 'error', `YouTrack 更新失败:${message}`)
}
await panel.loadAndPush()
}
/** Resolve the issue in YouTrack. Uses the configured close command, else
* auto-detects the project's `isResolved` state value. */
async function closeYouTrackIssue(panel: KanbanWebviewPanel, auth: YouTrackAuth, externalId: string): Promise<void> {
const configured = getSettings(panel.context).youtrackCloseCommand.trim()
const command = configured || (await resolvedStateCommand(auth, externalId))
if (!command) {
logger.add({ level: 'warn', source: 'youtrack', message: `${externalId} 无法确定关闭命令(未配置 youtrackCloseCommand 且未找到已解决状态值)` })
toast(panel, 'info', `${externalId} 已移到完成,但未能自动关闭 YouTrack(请在设置里填「关闭命令」)`)
return
}
await applyCommand(auth, externalId, command)
}
/** Populate the settings project dropdown from the in-form base URL + token
* (lets the user pick a project before saving). */
export async function handleListProjects(panel: KanbanWebviewPanel, baseUrl: string, token: string): Promise<void> {
const trimmedBase = baseUrl.trim()
const trimmedToken = token.trim()
if (!trimmedBase || !trimmedToken) {
panel.postMessage({ type: 'youtrack/projects', projects: [], error: '请先填写 Base URL 和 Token' })
return
}
try {
const projects = await listProjects({ baseUrl: trimmedBase, token: trimmedToken })
panel.postMessage({ type: 'youtrack/projects', projects })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({ type: 'youtrack/projects', projects: [], error: message })
}
}
/**
* 用原生 QuickPick 多选框让用户挑选要导入哪些 YouTrack 工单。
*
* 候选默认只显示待办(未解决)工单,框内有过滤按钮可切到「全部」。已导入的项
* 默认勾选,因此取消勾选 = 移除导入。被待办过滤隐藏掉的已导入项始终保留,避免
* 误删。确认后写回 importStore 并刷新看板。
*/
export async function handleYouTrackImport(panel: KanbanWebviewPanel): Promise<void> {
const settings = getSettings(panel.context)
const baseUrl = settings.youtrackBaseUrl.trim()
const project = settings.youtrackProjectShortName.trim()
if (!baseUrl || !project) {
void window.showWarningMessage('请先在设置里配置 YouTrack Base URL / 项目 / Token')
return
}
const token = await getYouTrackToken(panel.context, youtrackHost(baseUrl))
if (!token) {
void window.showWarningMessage('请先在设置里配置 YouTrack Base URL / 项目 / Token')
return
}
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
void window.showWarningMessage('请先打开一个工作区文件夹')
return
}
let all
try {
all = await listIssues({ baseUrl, token }, project)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'youtrack', message: '拉取 YouTrack 工单失败', details: message })
void window.showErrorMessage(`拉取 YouTrack 工单失败:${message}`)
return
}
const imported = new Set(await readImportedIds(workspaceRoot, project))
const qp = window.createQuickPick()
qp.canSelectMany = true
qp.title = 'YouTrack 导入'
qp.placeholder = '空格勾选要导入的工单,回车确认'
// label 直接用 idReadable(项目内唯一),后续增删按 label 比对。
let todoOnly = true
const buildItems = (): Array<{ label: string, description: string }> =>
(todoOnly ? all.filter(i => i.resolved == null) : all)
.map(i => ({ label: i.idReadable, description: i.summary }))
const filterButton = (): { iconPath: ThemeIcon, tooltip: string } => ({
iconPath: new ThemeIcon(todoOnly ? 'filter' : 'filter-filled'),
tooltip: todoOnly ? '当前:仅待办 / 点击显示全部' : '当前:全部 / 点击仅待办',
})
qp.items = buildItems()
qp.selectedItems = qp.items.filter(it => imported.has(it.label))
qp.buttons = [filterButton()]
qp.onDidTriggerButton(() => {
todoOnly = !todoOnly
const keep = new Set(qp.selectedItems.map(s => s.label))
qp.items = buildItems()
// 切换时保住已勾选项,外加把已导入项继续勾上。
qp.selectedItems = qp.items.filter(it => keep.has(it.label) || imported.has(it.label))
qp.buttons = [filterButton()]
})
qp.onDidAccept(async () => {
const visibleIds = new Set(qp.items.map(i => i.label))
const picked = new Set(qp.selectedItems.map(i => i.label))
// 只在「当前可见」范围内增删;被待办过滤隐藏的已导入项原样保留。
const next = [...imported].filter(id => !visibleIds.has(id))
for (const id of visibleIds) {
if (picked.has(id))
next.push(id)
}
qp.hide()
await writeImportedIds(workspaceRoot, project, [...new Set(next)])
await panel.loadAndPush()
void window.showInformationMessage(`已导入 ${picked.size} 个 YouTrack 工单`)
})
qp.onDidHide(() => qp.dispose())
qp.show()
}
+83
View File
@@ -0,0 +1,83 @@
import { ThemeColor, Uri } from 'vscode'
// 12 saturated ansi colors, chosen for tab-icon contrast.
export const PALETTE = [
'terminal.ansiRed',
'terminal.ansiGreen',
'terminal.ansiYellow',
'terminal.ansiBlue',
'terminal.ansiMagenta',
'terminal.ansiCyan',
'terminal.ansiBrightRed',
'terminal.ansiBrightGreen',
'terminal.ansiBrightYellow',
'terminal.ansiBrightBlue',
'terminal.ansiBrightMagenta',
'terminal.ansiBrightCyan',
] as const
/** Pick a deterministic ThemeColor for an issue number. */
export function issueTerminalColor(issueNumber: number): ThemeColor {
// Non-negative modulus.
const idx = ((issueNumber % PALETTE.length) + PALETTE.length) % PALETTE.length
return new ThemeColor(PALETTE[idx])
}
/** Pick a random palette entry id (string, not ThemeColor). */
export function pickRandomIssueColor(): string {
return PALETTE[Math.floor(Math.random() * PALETTE.length)]
}
/**
* Resolve which color to use for an issue based on the previously stored
* value (if any). Returns the chosen palette id plus an `isNew` flag — when
* `isNew` is true the caller is expected to persist the picked color back to
* the issue's state JSON so subsequent sessions reuse it.
*/
export function resolveIssueColor(stored: string | undefined): { id: string; isNew: boolean } {
if (stored && (PALETTE as readonly string[]).includes(stored))
return { id: stored, isNew: false }
return { id: pickRandomIssueColor(), isNew: true }
}
/**
* VS Code's default terminal-tab icon does NOT honor the `color` option on
* `createTerminal`, and `ThemeIcon`'s color param is "currently only used in
* TreeItem" (per the API docs). See https://github.com/microsoft/vscode/issues/171082.
*
* Workaround: build an inline SVG data URI with the fill color hardcoded, and
* pass it as `iconPath`. This bypasses the entire ThemeColor pipeline.
*
* Mapping uses VS Code's built-in default terminal ANSI palette so the icon
* matches what the user sees inside the terminal.
*/
const ANSI_HEX: Record<string, string> = {
'terminal.ansiRed': '#cd3131',
'terminal.ansiGreen': '#0dbc79',
'terminal.ansiYellow': '#e5e510',
'terminal.ansiBlue': '#2472c8',
'terminal.ansiMagenta': '#bc3fbc',
'terminal.ansiCyan': '#11a8cd',
'terminal.ansiBrightRed': '#f14c4c',
'terminal.ansiBrightGreen': '#23d18b',
'terminal.ansiBrightYellow': '#f5f543',
'terminal.ansiBrightBlue': '#3b8eea',
'terminal.ansiBrightMagenta': '#d670d6',
'terminal.ansiBrightCyan': '#29b8db',
}
/** Build an SVG data URI for a solid filled circle of the given hex color. */
function filledCircleUri(hex: string): Uri {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="${hex}"/></svg>`
return Uri.parse(`data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`)
}
/**
* Resolve a `terminal.ansi*` palette id to an SVG data URI suitable as
* `createTerminal({ iconPath })`. Unknown ids fall back to a neutral grey
* dot so we still render something visible.
*/
export function themeColorIdToIconUri(id: string): Uri {
const hex = ANSI_HEX[id] ?? '#888888'
return filledCircleUri(hex)
}
+175
View File
@@ -0,0 +1,175 @@
import type { Issue, IssueColumn } from '../gitea/types'
import type { LogEntry } from '../logging/logger'
import type { ProfilesData } from '../profiles/store'
import type { ManagedSession } from '../sessions/managedStore'
export type { LogEntry } from '../logging/logger'
export type { ProfileRow, ProfilesData } from '../profiles/store'
/**
* `managed-sessions/show` 的 payload 类型:在持久化的 ManagedSession 基础上
* 附加 transient 字段 `tabOpen`(该会话的终端是否正开着)。
* `tabOpen` 只在构造 show payload 时附加,不写进 .spx/session-names.json。
*/
export interface ManagedSessionShowItem extends ManagedSession {
tabOpen?: boolean
}
export interface ManagedSessionsShowData {
sessions: ManagedSessionShowItem[]
}
/** PR 提交的精简视图(webview 列表渲染用)。 */
export interface PrCommit {
sha: string
message: string
authorName: string
date: string
}
/** 提交内单个文件改动:status 取 added/modified/deleted/renamed/copied 等。 */
export interface PrCommitFile {
path: string
status: string
}
export interface ToastLink {
label: string
url: string
}
export type ToastLevel = 'info' | 'success' | 'error'
export type ExtensionToWebview
= | { type: 'issues/loading' }
| { type: 'issues/update', issues: Issue[], 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, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string, prMerged?: boolean, prMergedAt?: string, branch?: string, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, testProfilePath?: string } }
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
| { type: 'issue/append', issue: Issue, select?: boolean }
| { type: 'issue/select-by-number', issueNumber: number }
| {
type: 'settings/show'
host: string
errorMessage?: string
canCancel?: boolean
tokenSaved: boolean
webhookPort: number
brainstormPrompt: string
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
devBranch: string
autoBuildBranch: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
implTabPostCloseScript: string
youtrackBaseUrl?: string
youtrackProjectShortName?: string
youtrackCloseCommand?: string
youtrackTokenSaved?: boolean
}
| { type: 'youtrack/projects', projects: Array<{ id: string, name: string, shortName: string }>, error?: string }
| {
type: 'toast/show'
id: string
level: ToastLevel
message: string
spinner?: boolean
link?: ToastLink
dismissOnTimer?: number
}
| { type: 'toast/dismiss', id: string }
| { type: 'profiles/update', profiles: Array<{ name: string, path: string }> }
| { type: 'logs/snapshot', entries: LogEntry[] }
| { type: 'logs/append', entry: LogEntry }
| { type: 'logs/cleared' }
| { type: 'commit/state', running: boolean }
| { type: 'commit/has-changes', value: boolean }
| {
type: 'branch-sync/status'
behind: number
devBranch: string
autoBuildBranch: string
unavailable?: boolean
reason?: string
}
| { type: 'env-lock/status', locked: boolean, fileCount: number, failedCount?: number }
| { type: 'issue/remove', issueNumber: number }
| { type: 'profiles/show', data: ProfilesData }
| { type: 'managed-sessions/show', data: ManagedSessionsShowData }
| { type: 'pr-commits/show', issueNumber: number, commits: PrCommit[], confirmedCommits: string[], error?: string }
| { type: 'pr-commit-files/show', issueNumber: number, sha: string, parentSha?: string, files: PrCommitFile[], confirmed: string[], error?: string }
export type WebviewToExtension
= | { type: 'issues/refresh' }
| {
type: 'settings/save'
host: string
token: string
webhookPort: number
brainstormPrompt: string
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
devBranch: string
autoBuildBranch: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
implTabPostCloseScript: string
youtrackBaseUrl: string
youtrackProjectShortName: string
youtrackCloseCommand: string
youtrackToken: string
}
| { type: 'settings/edit-request' }
| { type: 'youtrack/list-projects', baseUrl: string, token: string }
| { type: 'youtrack/import' }
| { type: 'issue/create', userRequest: string, images?: Array<{ mediaType: string, base64: string }>, profilePath?: string }
| { type: 'toast/open-url', url: string }
| { type: 'session/resume', sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number }
| { type: 'session/focus', issueNumber: number }
| { type: 'session/resume-review', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/resume-test', sessionId: string, issueNumber: number, cwd?: string }
| { type: 'session/start-test', issueNumber: number }
| { type: 'editor/open-file', path: string }
| { type: 'profiles/list' }
| { type: 'issue/implement', issueNumber: number, planFile: string, profilePath?: string, sessionId?: string }
| { type: 'issue/generate-pr-diff-summary', issueNumber: number }
| { type: 'pr/open', pr: string }
| { type: 'worktree/open', path: string }
| { type: 'worktree/delete', issueNumber: number, path: string }
| { type: 'git/merge-preview', issueNumber: number, branch: string }
| { type: 'column/change', issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string }
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
| { type: 'issue/update-auto-review', issueNumber: number, value: boolean }
| { type: 'issue/update-profile-path', issueNumber: number, profilePath: string }
| { type: 'issue/update-test-profile-path', issueNumber: number, testProfilePath: string }
| { type: 'logs/fetch' }
| { type: 'logs/clear' }
| { type: 'commit/run' }
| { type: 'session/close-tab', issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' }
| { type: 'branch-sync/check' }
| { type: 'branch-sync/run' }
| { type: 'env-lock/check' }
| { type: 'env-lock/toggle' }
| { type: 'issue/delete', issueNumber: number }
| { type: 'issue/close', issueNumber: number }
| { type: 'brainstorm/start', issueNumber: number }
| { type: 'profiles/get' }
| { type: 'profiles/save', data: ProfilesData }
| { type: 'profiles/open', value: string }
| { type: 'managed-sessions/get' }
| { type: 'managed-sessions/create', profilePath: string, name?: string, prompt?: string }
| { type: 'managed-sessions/rename', sessionId: string, name: string }
| { type: 'managed-sessions/resume', sessionId: string }
| { type: 'managed-sessions/delete', sessionId: string }
| { type: 'managed-sessions/close-tab', sessionId: string }
| { type: 'pr-commits/get', issueNumber: number }
| { type: 'pr-commit-files/get', issueNumber: number, sha: string }
| { type: 'pr-commit-diff/open', issueNumber: number, sha: string, parentSha?: string, path: string, status: string }
| { type: 'pr-review/set', issueNumber: number, sha: string, confirmed: string[] }
| { type: 'pr-review/set-commits', issueNumber: number, confirmed: string[] }