✨ 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
This commit is contained in:
1 parent
443a8e472a
commit
73a0c46f9f
6 files changed
+322
-25
No files matched your search
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { summarizeClaudeStreamLine } from './claudeProgress'
|
||||||
|
|
||||||
|
function assistantLine(content: unknown[]): string {
|
||||||
|
return JSON.stringify({ type: 'assistant', message: { role: 'assistant', content } })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('summarizeClaudeStreamLine', () => {
|
||||||
|
it('工具 Bash 有 description 时优先用 description', () => {
|
||||||
|
const line = assistantLine([
|
||||||
|
{ type: 'tool_use', name: 'Bash', input: { command: 'pnpm test', description: '跑全量测试' } },
|
||||||
|
])
|
||||||
|
expect(summarizeClaudeStreamLine(line)).toEqual(['执行命令:跑全量测试'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('工具 Bash 没 description 时回退到 command,超长截断', () => {
|
||||||
|
const command = 'x'.repeat(80)
|
||||||
|
const line = assistantLine([{ type: 'tool_use', name: 'Bash', input: { command } }])
|
||||||
|
expect(summarizeClaudeStreamLine(line)).toEqual([`执行命令:${'x'.repeat(60)}…`])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('工具 Read 只显示文件名', () => {
|
||||||
|
const line = assistantLine([{ type: 'tool_use', name: 'Read', input: { file_path: '/abs/dir/x.py' } }])
|
||||||
|
expect(summarizeClaudeStreamLine(line)).toEqual(['读取 x.py'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('工具 Edit / Write / MultiEdit 显示为修改', () => {
|
||||||
|
for (const name of ['Edit', 'Write', 'MultiEdit']) {
|
||||||
|
const line = assistantLine([{ type: 'tool_use', name, input: { file_path: '/a/b/c.ts' } }])
|
||||||
|
expect(summarizeClaudeStreamLine(line)).toEqual(['修改 c.ts'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('工具 Grep / Glob 显示搜索模式', () => {
|
||||||
|
const line = assistantLine([{ type: 'tool_use', name: 'Grep', input: { pattern: 'foo.*bar' } }])
|
||||||
|
expect(summarizeClaudeStreamLine(line)).toEqual(['搜索 foo.*bar'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未知工具显示调用 + 工具名', () => {
|
||||||
|
const line = assistantLine([{ type: 'tool_use', name: 'TodoWrite', input: {} }])
|
||||||
|
expect(summarizeClaudeStreamLine(line)).toEqual(['调用 TodoWrite'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('text 块取第一行并截断', () => {
|
||||||
|
const line = assistantLine([{ type: 'text', text: `${'好'.repeat(70)}\n第二行` }])
|
||||||
|
expect(summarizeClaudeStreamLine(line)).toEqual([`${'好'.repeat(60)}…`])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空白 text 块忽略', () => {
|
||||||
|
const line = assistantLine([{ type: 'text', text: ' \n\n' }])
|
||||||
|
expect(summarizeClaudeStreamLine(line)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('thinking 块忽略', () => {
|
||||||
|
const line = assistantLine([{ type: 'thinking', thinking: '让我想想' }])
|
||||||
|
expect(summarizeClaudeStreamLine(line)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('system / user / result 行忽略', () => {
|
||||||
|
expect(summarizeClaudeStreamLine(JSON.stringify({ type: 'system', subtype: 'init' }))).toEqual([])
|
||||||
|
expect(summarizeClaudeStreamLine(JSON.stringify({
|
||||||
|
type: 'user',
|
||||||
|
message: { content: [{ type: 'tool_result', content: 'ok' }] },
|
||||||
|
}))).toEqual([])
|
||||||
|
expect(summarizeClaudeStreamLine(JSON.stringify({ type: 'result', result: 'done', session_id: 's' }))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('坏 JSON 返回空数组', () => {
|
||||||
|
expect(summarizeClaudeStreamLine('{not json')).toEqual([])
|
||||||
|
expect(summarizeClaudeStreamLine('')).toEqual([])
|
||||||
|
expect(summarizeClaudeStreamLine('null')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多个 block 按顺序输出', () => {
|
||||||
|
const line = assistantLine([
|
||||||
|
{ type: 'thinking', thinking: '...' },
|
||||||
|
{ type: 'text', text: '先看看代码' },
|
||||||
|
{ type: 'tool_use', name: 'Read', input: { file_path: '/p/a.ts' } },
|
||||||
|
{ type: 'tool_use', name: 'Bash', input: { command: 'git status' } },
|
||||||
|
])
|
||||||
|
expect(summarizeClaudeStreamLine(line)).toEqual(['先看看代码', '读取 a.ts', '执行命令:git status'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* 把 `claude -p --output-format stream-json --verbose` 吐出的每行 NDJSON
|
||||||
|
* 压缩成人能看懂的进度短语,供长任务(如提交代码)实时展示。
|
||||||
|
*
|
||||||
|
* 只关心 assistant 消息里的 `tool_use` 和 `text` 块,其它行(system / user /
|
||||||
|
* result / thinking 块 / 坏 JSON)一律返回空数组,调用方无需再做判断。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as path from 'node:path'
|
||||||
|
|
||||||
|
const LABEL_MAX_CHARS = 60
|
||||||
|
|
||||||
|
interface ContentBlock {
|
||||||
|
type?: unknown
|
||||||
|
text?: unknown
|
||||||
|
name?: unknown
|
||||||
|
input?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string): string {
|
||||||
|
if (text.length <= LABEL_MAX_CHARS)
|
||||||
|
return text
|
||||||
|
return `${text.slice(0, LABEL_MAX_CHARS)}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeToolUse(name: string, input: Record<string, unknown>): string {
|
||||||
|
switch (name) {
|
||||||
|
case 'Bash':
|
||||||
|
return `执行命令:${truncate(asString(input.description) || asString(input.command))}`
|
||||||
|
case 'Read':
|
||||||
|
return `读取 ${path.basename(asString(input.file_path))}`
|
||||||
|
case 'Edit':
|
||||||
|
case 'Write':
|
||||||
|
case 'MultiEdit':
|
||||||
|
return `修改 ${path.basename(asString(input.file_path))}`
|
||||||
|
case 'Grep':
|
||||||
|
case 'Glob':
|
||||||
|
return `搜索 ${truncate(asString(input.pattern))}`
|
||||||
|
default:
|
||||||
|
return `调用 ${name}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeBlock(block: ContentBlock): string | null {
|
||||||
|
if (block.type === 'tool_use' && typeof block.name === 'string') {
|
||||||
|
const input = block.input && typeof block.input === 'object'
|
||||||
|
? block.input as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
return describeToolUse(block.name, input)
|
||||||
|
}
|
||||||
|
if (block.type === 'text' && typeof block.text === 'string') {
|
||||||
|
const firstLine = block.text.split('\n').map(l => l.trim()).find(l => l.length > 0)
|
||||||
|
return firstLine ? truncate(firstLine) : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeClaudeStreamLine(line: string): string[] {
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object')
|
||||||
|
return []
|
||||||
|
const obj = parsed as { type?: unknown, message?: { content?: unknown } }
|
||||||
|
if (obj.type !== 'assistant' || !Array.isArray(obj.message?.content))
|
||||||
|
return []
|
||||||
|
|
||||||
|
const labels: string[] = []
|
||||||
|
for (const block of obj.message.content as ContentBlock[]) {
|
||||||
|
if (!block || typeof block !== 'object')
|
||||||
|
continue
|
||||||
|
const label = describeBlock(block)
|
||||||
|
if (label)
|
||||||
|
labels.push(label)
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Typed wrapper around the `claude` CLI in `-p` (one-shot) mode with
|
* Typed wrapper around the `claude` CLI in `-p` (one-shot) mode. We rely on
|
||||||
* `--output-format json`. We rely on `claude` being available on PATH;
|
* `claude` being available on PATH; callers should surface the install-hint
|
||||||
* callers should surface the install-hint error message to the user when
|
* error message to the user when ENOENT is raised.
|
||||||
* ENOENT is raised.
|
|
||||||
*
|
*
|
||||||
* Two execution paths share the same output-parsing logic:
|
* Two execution paths share the same output-parsing logic:
|
||||||
*
|
*
|
||||||
* - **Text-only** (no images): plain `claude -p "<prompt>" --output-format json`
|
* - **Text-only** (no images): plain `claude -p "<prompt>" --output-format json`
|
||||||
* via `spawn` with stdin ignored so the CLI does not wait for piped input.
|
* via `spawn` with stdin ignored so the CLI does not wait for piped input.
|
||||||
|
* When the caller passes `onProgress`, this path switches to
|
||||||
|
* `--output-format stream-json --verbose` so tool calls can be reported
|
||||||
|
* live instead of only when the process exits.
|
||||||
*
|
*
|
||||||
* - **With images**: switch to `claude -p --input-format stream-json
|
* - **With images**: switch to `claude -p --input-format stream-json
|
||||||
* --output-format json` via `spawn`, then feed a single NDJSON line on
|
* --output-format stream-json` via `spawn`, then feed a single NDJSON line on
|
||||||
* stdin containing the user message with text + image content blocks
|
* stdin containing the user message with text + image content blocks
|
||||||
* (Anthropic Messages API shape). The official docs state single-message
|
* (Anthropic Messages API shape). The official docs state single-message
|
||||||
* input does NOT support image attachments — stream-json is the only
|
* input does NOT support image attachments — stream-json is the only
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { resolveClaudeBin } from './claudeBin'
|
import { resolveClaudeBin } from './claudeBin'
|
||||||
|
import { summarizeClaudeStreamLine } from './claudeProgress'
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 300_000
|
const DEFAULT_TIMEOUT_MS = 300_000
|
||||||
const MAX_BUFFER_BYTES = 10 * 1024 * 1024
|
const MAX_BUFFER_BYTES = 10 * 1024 * 1024
|
||||||
@@ -177,6 +180,27 @@ function buildClaudeExitDiagnostic(stderr: string, stdout: string): string {
|
|||||||
return extractStdoutDiagnostic(stdout) ?? '(无 stderr/stdout)'
|
return extractStdoutDiagnostic(stdout) ?? '(无 stderr/stdout)'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ClaudeProgressListener = (label: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按行切 stdout:stream-json 每行一个对象,但 data 事件的 chunk 边界和行边界
|
||||||
|
* 无关,一行常被拆进两个 chunk,所以末尾那段不完整的残行要留到下次拼上。
|
||||||
|
*/
|
||||||
|
function createProgressFeeder(onProgress?: ClaudeProgressListener): (chunk: string) => void {
|
||||||
|
if (!onProgress)
|
||||||
|
return () => {}
|
||||||
|
let pending = ''
|
||||||
|
return (chunk) => {
|
||||||
|
pending += chunk
|
||||||
|
const lines = pending.split('\n')
|
||||||
|
pending = lines.pop() ?? ''
|
||||||
|
for (const line of lines) {
|
||||||
|
for (const label of summarizeClaudeStreamLine(line))
|
||||||
|
onProgress(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string {
|
function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string {
|
||||||
const content: Array<Record<string, unknown>> = [
|
const content: Array<Record<string, unknown>> = [
|
||||||
{ type: 'text', text: prompt },
|
{ type: 'text', text: prompt },
|
||||||
@@ -246,14 +270,16 @@ export async function spawnClaude(opts: {
|
|||||||
bare?: boolean
|
bare?: boolean
|
||||||
/** 续会话:传入则 `--resume <id>`,让本次 `-p` 接在该会话之后(复用上下文,省 token)。 */
|
/** 续会话:传入则 `--resume <id>`,让本次 `-p` 接在该会话之后(复用上下文,省 token)。 */
|
||||||
resumeSessionId?: string
|
resumeSessionId?: string
|
||||||
|
/** 实时进度:每识别出一个工具调用 / 一句回复就回调一次可读标签。 */
|
||||||
|
onProgress?: ClaudeProgressListener
|
||||||
}): Promise<ClaudeResult> {
|
}): Promise<ClaudeResult> {
|
||||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||||
const hasImages = !!opts.images && opts.images.length > 0
|
const hasImages = !!opts.images && opts.images.length > 0
|
||||||
|
|
||||||
if (!hasImages) {
|
if (!hasImages) {
|
||||||
return spawnClaudeText(opts.prompt, opts.cwd, timeoutMs, opts.profilePath, opts.bare, opts.resumeSessionId)
|
return spawnClaudeText(opts.prompt, opts.cwd, timeoutMs, opts.profilePath, opts.bare, opts.resumeSessionId, opts.onProgress)
|
||||||
}
|
}
|
||||||
return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare, opts.resumeSessionId)
|
return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare, opts.resumeSessionId, opts.onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnClaudeText(
|
function spawnClaudeText(
|
||||||
@@ -263,7 +289,9 @@ function spawnClaudeText(
|
|||||||
profilePath?: string,
|
profilePath?: string,
|
||||||
bare?: boolean,
|
bare?: boolean,
|
||||||
resumeSessionId?: string,
|
resumeSessionId?: string,
|
||||||
|
onProgress?: ClaudeProgressListener,
|
||||||
): Promise<ClaudeResult> {
|
): Promise<ClaudeResult> {
|
||||||
|
// `--output-format json` 要到进程结束才有输出;要报进度就得换成逐行吐的 stream-json
|
||||||
const args = [
|
const args = [
|
||||||
...(bare ? ['--bare'] : []),
|
...(bare ? ['--bare'] : []),
|
||||||
'--dangerously-skip-permissions',
|
'--dangerously-skip-permissions',
|
||||||
@@ -272,7 +300,7 @@ function spawnClaudeText(
|
|||||||
'-p',
|
'-p',
|
||||||
prompt,
|
prompt,
|
||||||
'--output-format',
|
'--output-format',
|
||||||
'json',
|
...(onProgress ? ['stream-json', '--verbose'] : ['json']),
|
||||||
]
|
]
|
||||||
|
|
||||||
const bin = resolveClaudeBin()
|
const bin = resolveClaudeBin()
|
||||||
@@ -284,6 +312,7 @@ function spawnClaudeText(
|
|||||||
let stderr = ''
|
let stderr = ''
|
||||||
let stdoutBytes = 0
|
let stdoutBytes = 0
|
||||||
let settled = false
|
let settled = false
|
||||||
|
const feedProgress = createProgressFeeder(onProgress)
|
||||||
|
|
||||||
const child = spawn(bin, args, {
|
const child = spawn(bin, args, {
|
||||||
cwd,
|
cwd,
|
||||||
@@ -332,7 +361,9 @@ function spawnClaudeText(
|
|||||||
))
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stdout += chunk.toString('utf8')
|
const text = chunk.toString('utf8')
|
||||||
|
stdout += text
|
||||||
|
feedProgress(text)
|
||||||
})
|
})
|
||||||
|
|
||||||
child.stderr.on('data', (chunk: Buffer) => {
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
@@ -377,6 +408,7 @@ function spawnClaudeStreamed(
|
|||||||
profilePath?: string,
|
profilePath?: string,
|
||||||
bare?: boolean,
|
bare?: boolean,
|
||||||
resumeSessionId?: string,
|
resumeSessionId?: string,
|
||||||
|
onProgress?: ClaudeProgressListener,
|
||||||
): Promise<ClaudeResult> {
|
): Promise<ClaudeResult> {
|
||||||
// Local Claude Code v2.1.143 enforces that --input-format=stream-json must
|
// Local Claude Code v2.1.143 enforces that --input-format=stream-json must
|
||||||
// be paired with --output-format=stream-json (the friendlier `--output-format
|
// be paired with --output-format=stream-json (the friendlier `--output-format
|
||||||
@@ -405,6 +437,7 @@ function spawnClaudeStreamed(
|
|||||||
let stderr = ''
|
let stderr = ''
|
||||||
let stdoutBytes = 0
|
let stdoutBytes = 0
|
||||||
let settled = false
|
let settled = false
|
||||||
|
const feedProgress = createProgressFeeder(onProgress)
|
||||||
|
|
||||||
const child = spawn(bin, args, {
|
const child = spawn(bin, args, {
|
||||||
cwd,
|
cwd,
|
||||||
@@ -453,7 +486,9 @@ function spawnClaudeStreamed(
|
|||||||
))
|
))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stdout += chunk.toString('utf8')
|
const text = chunk.toString('utf8')
|
||||||
|
stdout += text
|
||||||
|
feedProgress(text)
|
||||||
})
|
})
|
||||||
|
|
||||||
child.stderr.on('data', (chunk: Buffer) => {
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { missingGitIdentityKeys } from './identity'
|
||||||
|
|
||||||
|
describe('missingGitIdentityKeys', () => {
|
||||||
|
it('两项都有时返回空', () => {
|
||||||
|
expect(missingGitIdentityKeys({ name: 'cruldra', email: 'a@b.c' })).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('缺 name', () => {
|
||||||
|
expect(missingGitIdentityKeys({ name: '', email: 'a@b.c' })).toEqual(['user.name'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('缺 email', () => {
|
||||||
|
expect(missingGitIdentityKeys({ name: 'cruldra', email: '' })).toEqual(['user.email'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('两项都缺,顺序固定', () => {
|
||||||
|
expect(missingGitIdentityKeys({ name: '', email: '' })).toEqual(['user.name', 'user.email'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只有空白也算缺失', () => {
|
||||||
|
expect(missingGitIdentityKeys({ name: ' \n', email: '\t' })).toEqual(['user.name', 'user.email'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 读取当前仓库生效的 git 提交者身份(user.name / user.email)。
|
||||||
|
*
|
||||||
|
* 提交代码前先查一遍:身份缺失时 git commit 会直接失败,与其让后台 claude
|
||||||
|
* 跑十几分钟后才报错,不如一开始就把话说清楚。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
|
||||||
|
export interface GitIdentity {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GitIdentityKey = 'user.name' | 'user.email'
|
||||||
|
|
||||||
|
export function missingGitIdentityKeys(identity: GitIdentity): GitIdentityKey[] {
|
||||||
|
const missing: GitIdentityKey[] = []
|
||||||
|
if (!identity.name.trim())
|
||||||
|
missing.push('user.name')
|
||||||
|
if (!identity.email.trim())
|
||||||
|
missing.push('user.email')
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
function readGitConfig(cwd: string, key: GitIdentityKey): Promise<string> {
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
execFile(
|
||||||
|
'git',
|
||||||
|
['-C', cwd, 'config', '--get', key],
|
||||||
|
{ timeout: 10_000, encoding: 'utf8' },
|
||||||
|
(err, stdout) => {
|
||||||
|
// 未设置该项时 git 以退出码 1 结束且无输出,视同空串即可
|
||||||
|
resolve(err ? '' : (stdout ?? '').trim())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readGitIdentity(cwd: string): Promise<GitIdentity> {
|
||||||
|
const [name, email] = await Promise.all([
|
||||||
|
readGitConfig(cwd, 'user.name'),
|
||||||
|
readGitConfig(cwd, 'user.email'),
|
||||||
|
])
|
||||||
|
return { name, email }
|
||||||
|
}
|
||||||
@@ -8,9 +8,11 @@ import { resolveProfilePath } from '../../cc/profiles'
|
|||||||
import { spawnClaude } from '../../cc/spawnClaude'
|
import { spawnClaude } from '../../cc/spawnClaude'
|
||||||
import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock'
|
import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock'
|
||||||
import { checkBranchSync, runBranchSync } from '../../git/branchSync'
|
import { checkBranchSync, runBranchSync } from '../../git/branchSync'
|
||||||
|
import { missingGitIdentityKeys, readGitIdentity } from '../../git/identity'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
import { getEffectiveCommitProfilePath, getSettings } from '../../settings/store'
|
import { getEffectiveCommitProfilePath, getSettings } from '../../settings/store'
|
||||||
import { makeNonce } from '../KanbanPanel'
|
import { makeNonce } from '../KanbanPanel'
|
||||||
|
import { dismiss, toast } from './handoffFlow'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal structural typing for the bits of the built-in `vscode.git`
|
* Minimal structural typing for the bits of the built-in `vscode.git`
|
||||||
@@ -86,43 +88,65 @@ export async function handleCommitRun(panel: KanbanWebviewPanel): Promise<void>
|
|||||||
return
|
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.commitRunning = true
|
||||||
panel.postMessage({ type: 'commit/state', running: true })
|
panel.postMessage({ type: 'commit/state', running: true })
|
||||||
|
|
||||||
|
// 同一个 id 反复发 toast 会原地替换,正好用来滚动显示实时进度
|
||||||
|
const progressToastId = toast(panel, 'info', '提交中:正在启动 claude…', {
|
||||||
|
spinner: true,
|
||||||
|
dismissOnTimer: 31 * 60 * 1000,
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// claude -p 提交流程可能跑得比较久(要 git add / 编 commit message / git
|
// claude -p 提交流程可能跑得比较久(要 git add / 编 commit message / git
|
||||||
// commit),所以给一个比较宽松的超时(30 min)。
|
// commit),所以给一个比较宽松的超时(30 min)。
|
||||||
await spawnClaude({
|
const { resultText } = await spawnClaude({
|
||||||
prompt: '提交下代码',
|
prompt: '提交下代码',
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
profilePath: resolution.profilePath,
|
profilePath: resolution.profilePath,
|
||||||
timeoutMs: 30 * 60 * 1000,
|
timeoutMs: 30 * 60 * 1000,
|
||||||
bare: true,
|
bare: true,
|
||||||
|
onProgress: (label) => {
|
||||||
|
toast(panel, 'info', `提交中:${label}`, {
|
||||||
|
id: progressToastId,
|
||||||
|
spinner: true,
|
||||||
|
dismissOnTimer: 31 * 60 * 1000,
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
panel.postMessage({
|
toast(panel, 'success', formatCommitDone(resultText), { dismissOnTimer: 5000 })
|
||||||
type: 'toast/show',
|
|
||||||
id: makeNonce(),
|
|
||||||
level: 'success',
|
|
||||||
message: '提交完成',
|
|
||||||
dismissOnTimer: 5000,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
panel.postMessage({
|
toast(panel, 'error', `提交失败: ${msg}`, { dismissOnTimer: 8000 })
|
||||||
type: 'toast/show',
|
|
||||||
id: makeNonce(),
|
|
||||||
level: 'error',
|
|
||||||
message: `提交失败: ${msg}`,
|
|
||||||
dismissOnTimer: 8000,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
dismiss(panel, progressToastId)
|
||||||
panel.commitRunning = false
|
panel.commitRunning = false
|
||||||
panel.postMessage({ type: 'commit/state', running: 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> {
|
export async function handleBranchSyncCheck(panel: KanbanWebviewPanel): Promise<void> {
|
||||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
const s = getSettings(panel.context)
|
const s = getSettings(panel.context)
|
||||||
|
|||||||
Reference in new issue
Block a user