feat(vscode): 提交代码按钮可配置 Claude profile

This commit is contained in:
2026-07-25 07:29:05 +08:00
parent e9543d37ce
commit 84a5cda8d2
11 changed files with 269 additions and 9 deletions
+113
View File
@@ -0,0 +1,113 @@
import { accessSync, constants, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
DEFAULT_COMMIT_PROFILE_NAME,
resolveCommitProfileForRun,
seedCommitProfilePath,
} from './commitProfile'
const tempDirs: string[] = []
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop()
if (dir)
rmSync(dir, { recursive: true, force: true })
}
})
function tempDir(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), 'commit-profile-'))
tempDirs.push(dir)
return dir
}
describe('seedCommitProfilePath', () => {
it('key 缺失时预填 deepseek-v4-pro 的 path', () => {
const profiles = [
{ name: 'grok-4.5', path: '/p/grok-4.5.json' },
{ name: DEFAULT_COMMIT_PROFILE_NAME, path: '/p/deepseek-v4-pro.json' },
]
expect(seedCommitProfilePath(undefined, profiles)).toBe('/p/deepseek-v4-pro.json')
})
it('key 缺失且无 deepseek 时返回空串', () => {
expect(seedCommitProfilePath(undefined, [{ name: 'grok-4.5', path: '/p/g.json' }])).toBe('')
})
it('key 已存在为空串时尊重用户清空', () => {
expect(seedCommitProfilePath('', [{ name: DEFAULT_COMMIT_PROFILE_NAME, path: '/p/d.json' }])).toBe('')
})
it('key 已存在为 path 时原样返回', () => {
expect(seedCommitProfilePath('/custom/x.json', [{ name: DEFAULT_COMMIT_PROFILE_NAME, path: '/p/d.json' }])).toBe('/custom/x.json')
})
})
describe('resolveCommitProfileForRun', () => {
it('空串 → empty', () => {
expect(resolveCommitProfileForRun(' ', p => p, () => true)).toEqual({ ok: false, reason: 'empty' })
})
it('文件不可读 → unreadable', () => {
const dir = tempDir()
const missing = path.join(dir, 'gone.json')
const result = resolveCommitProfileForRun(
missing,
p => p,
p => {
try {
accessSync(p, constants.R_OK)
return true
}
catch {
return false
}
},
)
expect(result).toEqual({ ok: false, reason: 'unreadable', profilePath: missing })
})
it('可读 path → ok', () => {
const dir = tempDir()
const file = path.join(dir, 'deepseek-v4-pro.json')
writeFileSync(file, '{}')
const result = resolveCommitProfileForRun(
file,
p => p,
p => {
try {
accessSync(p, constants.R_OK)
return true
}
catch {
return false
}
},
)
expect(result).toEqual({ ok: true, profilePath: file })
})
it('经 resolvePath remap 后再检查可读', () => {
const dir = tempDir()
const local = path.join(dir, 'deepseek-v4-pro.json')
writeFileSync(local, '{}')
const foreign = '/Users/other/.claude/profiles/deepseek-v4-pro.json'
const result = resolveCommitProfileForRun(
foreign,
() => local,
p => {
try {
accessSync(p, constants.R_OK)
return true
}
catch {
return false
}
},
)
expect(result).toEqual({ ok: true, profilePath: local })
})
})
+49
View File
@@ -0,0 +1,49 @@
/**
* 工具栏「提交代码」所用 profile 的解析与默认 seed。
* 纯逻辑:不碰 VS Code API,便于单测。
*/
export const DEFAULT_COMMIT_PROFILE_NAME = 'deepseek-v4-pro'
export interface ProfileRef {
name: string
path: string
}
/**
* 升级迁移:stored 里从未出现 `commitProfilePath` key 时,
* 若本机 profiles 有 deepseek-v4-pro 则预填其 path,否则 ''。
* 已出现 key(含用户主动清空的 '')一律原样返回。
*/
export function seedCommitProfilePath(
stored: string | undefined,
profiles: ProfileRef[],
): string {
if (typeof stored === 'string')
return stored
const hit = profiles.find(p => p.name === DEFAULT_COMMIT_PROFILE_NAME)
return hit?.path ?? ''
}
export type CommitProfileResolution
= | { ok: true, profilePath: string }
| { ok: false, reason: 'empty' }
| { ok: false, reason: 'unreadable', profilePath: string }
/**
* 提交前解析:trim → 空失败 → resolvePath → 可读性检查。
* resolvePath / isReadable 由调用方注入(跨机 remap / fs)。
*/
export function resolveCommitProfileForRun(
raw: string,
resolvePath: (stored: string) => string,
isReadable: (profilePath: string) => boolean,
): CommitProfileResolution {
const trimmed = raw.trim()
if (!trimmed)
return { ok: false, reason: 'empty' }
const profilePath = resolvePath(trimmed)
if (!isReadable(profilePath))
return { ok: false, reason: 'unreadable', profilePath }
return { ok: true, profilePath }
}
+3 -1
View File
@@ -21,7 +21,7 @@ import { loadIssues } from '../gitea/issueLoader'
import type { IssueRef } from '../issues/stateRouter'
import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter'
import { logger } from '../logging/logger'
import { getSettings } from '../settings/store'
import { getEffectiveCommitProfilePath, getSettings } from '../settings/store'
import { webhookCoordinator } from '../webhook/coordinator'
import { loadYouTrackIssues } from '../youtrack/issueLoader'
import * as issues from './handlers/issues'
@@ -864,6 +864,7 @@ export class KanbanWebviewPanel {
implTabPreCreateScript: s.implTabPreCreateScript,
implTabPostCloseScript: s.implTabPostCloseScript,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath: await getEffectiveCommitProfilePath(this.context),
codexModel: s.codexModel,
codexReasoningEffort: s.codexReasoningEffort,
profilesDirectory: s.profilesDirectory,
@@ -930,6 +931,7 @@ export class KanbanWebviewPanel {
implTabPreCreateScript: s.implTabPreCreateScript,
implTabPostCloseScript: s.implTabPostCloseScript,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath: await getEffectiveCommitProfilePath(this.context),
codexModel: s.codexModel,
codexReasoningEffort: s.codexReasoningEffort,
profilesDirectory: s.profilesDirectory,
+9 -1
View File
@@ -10,7 +10,7 @@ import { listClaudeProfiles, setProfilesDirOverride } 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 { getEffectiveCommitProfilePath, getSettings, saveSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator'
import { youtrackHost } from '../../youtrack/issueLoader'
import { makeNonce } from '../KanbanPanel'
@@ -33,6 +33,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
implTabPreCreateScript: string
implTabPostCloseScript: string
conflictResolutionProfilePath: string
commitProfilePath: string
codexModel: string
codexReasoningEffort: string
profilesDirectory: string
@@ -64,6 +65,8 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
const trimmedImplPost = payload.implTabPostCloseScript.trim()
// conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH).
const trimmedConflictProfile = payload.conflictResolutionProfilePath.trim()
// 提交代码 profile'' 有意义(= 未配置 → 提交严格失败),只 trim。
const trimmedCommitProfile = payload.commitProfilePath.trim()
// codex 模型 / 思考级别:空串有意义(= 用 codex 默认),只 trim。
const trimmedCodexModel = payload.codexModel.trim()
const trimmedCodexReasoningEffort = payload.codexReasoningEffort.trim()
@@ -99,6 +102,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
implTabPreCreateScript: trimmedImplPre,
implTabPostCloseScript: trimmedImplPost,
conflictResolutionProfilePath: trimmedConflictProfile,
commitProfilePath: trimmedCommitProfile,
codexModel: trimmedCodexModel,
codexReasoningEffort: trimmedCodexReasoningEffort,
profilesDirectory: trimmedProfilesDirectory,
@@ -124,6 +128,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
implTabPreCreateScript: trimmedImplPre,
implTabPostCloseScript: trimmedImplPost,
conflictResolutionProfilePath: trimmedConflictProfile,
commitProfilePath: trimmedCommitProfile,
codexModel: trimmedCodexModel,
codexReasoningEffort: trimmedCodexReasoningEffort,
profilesDirectory: trimmedProfilesDirectory,
@@ -194,6 +199,8 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
const ytTok = s.youtrackBaseUrl.trim()
? await getYouTrackToken(panel.context, youtrackHost(s.youtrackBaseUrl.trim()))
: undefined
// 升级 seedkey 缺失时预填 deepseek path(若有),仅展示不写回。
const commitProfilePath = await getEffectiveCommitProfilePath(panel.context)
// User clicked the gear themselves — let them back out without saving.
panel.postMessage({
type: 'settings/show',
@@ -215,6 +222,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
implTabPreCreateScript: s.implTabPreCreateScript,
implTabPostCloseScript: s.implTabPostCloseScript,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath,
codexModel: s.codexModel,
codexReasoningEffort: s.codexReasoningEffort,
profilesDirectory: s.profilesDirectory,
+26 -7
View File
@@ -1,12 +1,15 @@
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 { listClaudeProfiles } from '../../cc/profiles'
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 { logger } from '../../logging/logger'
import { getSettings } from '../../settings/store'
import { getEffectiveCommitProfilePath, getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel'
/**
@@ -54,14 +57,30 @@ export async function handleCommitRun(panel: KanbanWebviewPanel): Promise<void>
return
}
const profiles = await listClaudeProfiles()
const deepseek = profiles.find(p => p.name === 'deepseek-v4-pro')
if (!deepseek) {
// ① 读有效 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: '未找到 deepseek profiledeepseek-v4-pro / deepseek),请在 Claude profiles 目录下创建对应 .json',
message,
dismissOnTimer: 8000,
})
return
@@ -76,7 +95,7 @@ export async function handleCommitRun(panel: KanbanWebviewPanel): Promise<void>
await spawnClaude({
prompt: '提交下代码',
cwd: workspaceRoot,
profilePath: deepseek.path,
profilePath: resolution.profilePath,
timeoutMs: 30 * 60 * 1000,
bare: true,
})
+2
View File
@@ -67,6 +67,7 @@ export type ExtensionToWebview
implTabPreCreateScript: string
implTabPostCloseScript: string
conflictResolutionProfilePath: string
commitProfilePath: string
codexModel: string
codexReasoningEffort: string
profilesDirectory: string
@@ -133,6 +134,7 @@ export type WebviewToExtension
implTabPreCreateScript: string
implTabPostCloseScript: string
conflictResolutionProfilePath: string
commitProfilePath: string
codexModel: string
codexReasoningEffort: string
profilesDirectory: string
+27
View File
@@ -13,6 +13,8 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import type { ExtensionContext } from 'vscode'
import { listClaudeProfiles } from '../cc/profiles'
import { seedCommitProfilePath } from '../cc/commitProfile'
/**
* Fallback prompt strings used when the on-disk markdown can't be read.
@@ -169,6 +171,11 @@ export interface Settings {
* (drag to 完成 when PR merge conflicts). Empty = DEFAULT_PROFILE_PATH.
*/
conflictResolutionProfilePath: string
/**
* 工具栏「提交代码」使用的 Claude profile 绝对路径。
* 空串 = 未配置 → 提交严格失败。升级时 key 缺失会 seed deepseek-v4-pro(若存在)。
*/
commitProfilePath: string
/**
* codex 审查会话使用的模型(`-c model=`)。空串 = 不传,用 codex
* `config.toml` 默认。
@@ -209,6 +216,7 @@ function defaults(ctx: ExtensionContext): Settings {
youtrackProjectShortName: '',
youtrackCloseCommand: '',
conflictResolutionProfilePath: '',
commitProfilePath: '',
codexModel: '',
codexReasoningEffort: '',
profilesDirectory: '',
@@ -296,6 +304,11 @@ export function getSettings(ctx: ExtensionContext): Settings {
const conflictResolutionProfilePath = typeof stored.conflictResolutionProfilePath === 'string'
? stored.conflictResolutionProfilePath
: base.conflictResolutionProfilePath
// 提交 profile'' 有意义(= 未配置 → 提交失败)。缺 key 时先给 ''
// deepseek seed 在 getEffectiveCommitProfilePath 里做(需异步 list profiles)。
const commitProfilePath = typeof stored.commitProfilePath === 'string'
? stored.commitProfilePath
: base.commitProfilePath
// codex 模型 / 思考级别:'' 有意义(= 用 codex 默认),不强制回退。
const codexModel = typeof stored.codexModel === 'string'
? stored.codexModel
@@ -328,12 +341,26 @@ export function getSettings(ctx: ExtensionContext): Settings {
youtrackProjectShortName,
youtrackCloseCommand,
conflictResolutionProfilePath,
commitProfilePath,
codexModel,
codexReasoningEffort,
profilesDirectory,
}
}
/**
* 读取工具栏「提交代码」有效的 profile path。
* ① raw stored 已有 key(含用户清空的 '')→ 原样返回
* ② key 从未出现 → list profiles 后 seed deepseek-v4-pro(不写回磁盘)
*/
export async function getEffectiveCommitProfilePath(ctx: ExtensionContext): Promise<string> {
const stored = ctx.globalState.get<Partial<Settings>>(SETTINGS_KEY) ?? {}
if (typeof stored.commitProfilePath === 'string')
return stored.commitProfilePath
const profiles = await listClaudeProfiles()
return seedCommitProfilePath(undefined, profiles)
}
export async function saveSettings(ctx: ExtensionContext, next: Partial<Settings>): Promise<void> {
const current = ctx.globalState.get<Partial<Settings>>(SETTINGS_KEY) ?? {}
const merged: Partial<Settings> = { ...current, ...next }