11
This commit is contained in:
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)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 「提交」tab 的扩展侧 handler:实时从 Gitea 读 PR 提交、提交内文件,并用
|
||||
* VS Code 原生 diff 比较改动前后。
|
||||
*
|
||||
* 数据全程实时拉取,不落任何 state JSON。文件内容通过一个
|
||||
* TextDocumentContentProvider(scheme `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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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', '请先在设置里配置 YouTrack(Base 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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[] }
|
||||
Reference in New Issue
Block a user