From 84a5cda8d2336ad92109334753a3a418f036afae Mon Sep 17 00:00:00 2001 From: cruldra Date: Sat, 25 Jul 2026 07:29:05 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8C=89=E9=92=AE=E5=8F=AF=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=20Claude=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/cc/commitProfile.test.ts | 113 ++++++++++++++++++ vscode/src/cc/commitProfile.ts | 49 ++++++++ vscode/src/panel/KanbanPanel.ts | 4 +- vscode/src/panel/handlers/settings.ts | 10 +- vscode/src/panel/handlers/toolbar.ts | 33 +++-- vscode/src/panel/messages.ts | 2 + vscode/src/settings/store.ts | 27 +++++ vscode/webview-ui/src/App.tsx | 1 + .../src/components/SettingsModal.tsx | 33 +++++ vscode/webview-ui/src/hooks/useIssues.ts | 4 + vscode/webview-ui/src/lib/messages.ts | 2 + 11 files changed, 269 insertions(+), 9 deletions(-) create mode 100644 vscode/src/cc/commitProfile.test.ts create mode 100644 vscode/src/cc/commitProfile.ts diff --git a/vscode/src/cc/commitProfile.test.ts b/vscode/src/cc/commitProfile.test.ts new file mode 100644 index 0000000..06bd2b9 --- /dev/null +++ b/vscode/src/cc/commitProfile.test.ts @@ -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 }) + }) +}) diff --git a/vscode/src/cc/commitProfile.ts b/vscode/src/cc/commitProfile.ts new file mode 100644 index 0000000..0b5f695 --- /dev/null +++ b/vscode/src/cc/commitProfile.ts @@ -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 } +} diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 5b5fcd0..b339098 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -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, diff --git a/vscode/src/panel/handlers/settings.ts b/vscode/src/panel/handlers/settings.ts index 248d623..650f135 100644 --- a/vscode/src/panel/handlers/settings.ts +++ b/vscode/src/panel/handlers/settings.ts @@ -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 + // 升级 seed:key 缺失时预填 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, diff --git a/vscode/src/panel/handlers/toolbar.ts b/vscode/src/panel/handlers/toolbar.ts index a589052..9003da4 100644 --- a/vscode/src/panel/handlers/toolbar.ts +++ b/vscode/src/panel/handlers/toolbar.ts @@ -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 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 profile(deepseek-v4-pro / deepseek),请在 Claude profiles 目录下创建对应 .json', + message, dismissOnTimer: 8000, }) return @@ -76,7 +95,7 @@ export async function handleCommitRun(panel: KanbanWebviewPanel): Promise await spawnClaude({ prompt: '提交下代码', cwd: workspaceRoot, - profilePath: deepseek.path, + profilePath: resolution.profilePath, timeoutMs: 30 * 60 * 1000, bare: true, }) diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index 74e05a5..fe84de4 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -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 diff --git a/vscode/src/settings/store.ts b/vscode/src/settings/store.ts index 28fb15e..9b5d80f 100644 --- a/vscode/src/settings/store.ts +++ b/vscode/src/settings/store.ts @@ -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 { + const stored = ctx.globalState.get>(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): Promise { const current = ctx.globalState.get>(SETTINGS_KEY) ?? {} const merged: Partial = { ...current, ...next } diff --git a/vscode/webview-ui/src/App.tsx b/vscode/webview-ui/src/App.tsx index d4e129a..446b612 100644 --- a/vscode/webview-ui/src/App.tsx +++ b/vscode/webview-ui/src/App.tsx @@ -466,6 +466,7 @@ export function App() { initialImplTabPreCreateScript={settings?.implTabPreCreateScript ?? ''} initialImplTabPostCloseScript={settings?.implTabPostCloseScript ?? ''} initialConflictResolutionProfilePath={settings?.conflictResolutionProfilePath ?? ''} + initialCommitProfilePath={settings?.commitProfilePath ?? ''} initialCodexModel={settings?.codexModel ?? ''} initialCodexReasoningEffort={settings?.codexReasoningEffort ?? ''} initialProfilesDirectory={settings?.profilesDirectory ?? ''} diff --git a/vscode/webview-ui/src/components/SettingsModal.tsx b/vscode/webview-ui/src/components/SettingsModal.tsx index 8eab8d7..4b3d5a7 100644 --- a/vscode/webview-ui/src/components/SettingsModal.tsx +++ b/vscode/webview-ui/src/components/SettingsModal.tsx @@ -32,6 +32,7 @@ interface SubmitValues { implTabPreCreateScript: string implTabPostCloseScript: string conflictResolutionProfilePath: string + commitProfilePath: string codexModel: string codexReasoningEffort: string profilesDirectory: string @@ -75,6 +76,7 @@ export interface SettingsModalProps { initialImplTabPreCreateScript: string initialImplTabPostCloseScript: string initialConflictResolutionProfilePath: string + initialCommitProfilePath: string initialCodexModel: string initialCodexReasoningEffort: string initialProfilesDirectory: string @@ -152,6 +154,7 @@ export function SettingsModal({ initialImplTabPreCreateScript, initialImplTabPostCloseScript, initialConflictResolutionProfilePath, + initialCommitProfilePath, initialCodexModel, initialCodexReasoningEffort, initialProfilesDirectory, @@ -183,6 +186,7 @@ export function SettingsModal({ const [implTabPreCreateScript, setImplTabPreCreateScript] = useState(initialImplTabPreCreateScript) const [implTabPostCloseScript, setImplTabPostCloseScript] = useState(initialImplTabPostCloseScript) const [conflictResolutionProfilePath, setConflictResolutionProfilePath] = useState(initialConflictResolutionProfilePath) + const [commitProfilePath, setCommitProfilePath] = useState(initialCommitProfilePath) const [codexModel, setCodexModel] = useState(initialCodexModel) const [codexReasoningEffort, setCodexReasoningEffort] = useState(initialCodexReasoningEffort) const [profilesDirectory, setProfilesDirectory] = useState(initialProfilesDirectory) @@ -216,6 +220,7 @@ export function SettingsModal({ setImplTabPreCreateScript(initialImplTabPreCreateScript) setImplTabPostCloseScript(initialImplTabPostCloseScript) setConflictResolutionProfilePath(initialConflictResolutionProfilePath) + setCommitProfilePath(initialCommitProfilePath) setCodexModel(initialCodexModel) setCodexReasoningEffort(initialCodexReasoningEffort) setProfilesDirectory(initialProfilesDirectory) @@ -302,6 +307,8 @@ export function SettingsModal({ implTabPostCloseScript: implTabPostCloseScript.trim(), // 冲突解决 profile:空串 = 用 DEFAULT_PROFILE_PATH conflictResolutionProfilePath: conflictResolutionProfilePath.trim(), + // 提交代码 profile:空串 = 未配置 → 提交严格失败 + commitProfilePath: commitProfilePath.trim(), // codex 模型 / 思考级别:trim 后留空 = 用 codex config.toml 默认 codexModel: codexModel.trim(), codexReasoningEffort: codexReasoningEffort.trim(), @@ -484,6 +491,32 @@ export function SettingsModal({ + + 顶部工具栏「提交代码」使用此 profile。留空则提交失败,不会回退默认。 + + )} + > + + +