Files
superwork/vscode/src/panel/handlers/toolbar.ts
T
cruldra 73a0c46f9f ✨ feat(vscode): 提交代码按钮实时显示进度并预检 git 身份
- spawnClaude 新增 onProgress 回调,给了回调就改走 stream-json 逐行输出,按行解析出工具调用 / 回复文本
- 新增 claudeProgress 把每行 NDJSON 压成可读进度短语
- 提交前先读 git user.name / user.email,缺失直接提示,不再让 claude 白跑
- 运行期间用带转圈的 toast 滚动显示进度,完成后带上 claude 的结果摘要

Claude-Session: https://claude.ai/code/session_01HTMgwaE3woqM5yvsHiuSAa
2026-08-31 22:11:09 +08:00

417 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Uri } from 'vscode'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { accessSync, constants } from 'node:fs'
import * as path from 'node:path'
import { extensions, workspace } from 'vscode'
import { resolveCommitProfileForRun } from '../../cc/commitProfile'
import { resolveProfilePath } from '../../cc/profiles'
import { spawnClaude } from '../../cc/spawnClaude'
import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock'
import { checkBranchSync, runBranchSync } from '../../git/branchSync'
import { missingGitIdentityKeys, readGitIdentity } from '../../git/identity'
import { logger } from '../../logging/logger'
import { getEffectiveCommitProfilePath, getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel'
import { dismiss, toast } from './handoffFlow'
/**
* 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
}
// ① 读有效 path(含升级 seed)→ ② 严格校验 → ③ 才标记 running
const raw = await getEffectiveCommitProfilePath(panel.context)
const resolution = resolveCommitProfileForRun(
raw,
resolveProfilePath,
(p) => {
try {
accessSync(p, constants.R_OK)
return true
}
catch {
return false
}
},
)
if (!resolution.ok) {
const message = resolution.reason === 'empty'
? '未配置提交代码 profile,请在设置中选择'
: `提交 profile 不可用:${path.basename(resolution.profilePath)}(路径:${resolution.profilePath})`
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message,
dismissOnTimer: 8000,
})
return
}
// 身份缺失时 git commit 必然失败,提前拦下,别让 claude 白跑十几分钟
const missingIdentity = missingGitIdentityKeys(await readGitIdentity(workspaceRoot))
if (missingIdentity.length > 0) {
toast(
panel,
'error',
`git 未配置 ${missingIdentity.join(' 和 ')},无法提交。请先执行:git config --global user.name "你的名字" && git config --global user.email "你的邮箱"`,
{ dismissOnTimer: 15000 },
)
return
}
panel.commitRunning = true
panel.postMessage({ type: 'commit/state', running: true })
// 同一个 id 反复发 toast 会原地替换,正好用来滚动显示实时进度
const progressToastId = toast(panel, 'info', '提交中:正在启动 claude…', {
spinner: true,
dismissOnTimer: 31 * 60 * 1000,
})
try {
// claude -p 提交流程可能跑得比较久(要 git add / 编 commit message / git
// commit),所以给一个比较宽松的超时(30 min)。
const { resultText } = await spawnClaude({
prompt: '提交下代码',
cwd: workspaceRoot,
profilePath: resolution.profilePath,
timeoutMs: 30 * 60 * 1000,
bare: true,
onProgress: (label) => {
toast(panel, 'info', `提交中:${label}`, {
id: progressToastId,
spinner: true,
dismissOnTimer: 31 * 60 * 1000,
})
},
})
toast(panel, 'success', formatCommitDone(resultText), { dismissOnTimer: 5000 })
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
toast(panel, 'error', `提交失败: ${msg}`, { dismissOnTimer: 8000 })
}
finally {
dismiss(panel, progressToastId)
panel.commitRunning = false
panel.postMessage({ type: 'commit/state', running: false })
}
}
function formatCommitDone(resultText: string): string {
const firstLine = resultText.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''
if (!firstLine)
return '提交完成'
const summary = firstLine.length > 100 ? `${firstLine.slice(0, 100)}…` : firstLine
return `提交完成:${summary}`
}
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)
})
}