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 } interface GitExtensionExports { getAPI: (version: 1) => GitExtensionApi } export async function handleCommitRun(panel: KanbanWebviewPanel): Promise { 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 { 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 { 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 { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath // 用底层 get 不传默认值,能区分"从未设过"(undefined) 和"用户主动选 false"。 const stored = panel.context.workspaceState.get('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 { 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('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('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) }) }