Files
superwork/vscode/src/panel/handlers/settings.ts
T
cruldra 958c59e090 ✨ feat(vscode): 设置面板可配头脑风暴/实施/测试会话的默认 profile
三类会话的 profile 此前只有工单级字段,留空就硬编码落到 official,
想整体换模型只能一个工单一个工单改。

设置面板新增三个下拉,与冲突解决/提交/改动说明的 profile 选择同区。
解析链路变成:工单级字段 > 对应的全局默认设置 > getDefaultProfilePath(),
三类之间仍互不回退。新建工单的头脑风暴会话也走同一条链路。

Claude-Session: https://claude.ai/code/session_01REdhpbaNyqWASF2HWsnTFv
2026-09-13 08:59:56 +08:00

585 lines
21 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 { ProfilesData } from '../../profiles/store'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { execFile } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import process from 'node:process'
import { commands, env, Uri, workspace } from 'vscode'
import { clearIdentityCache, initIdentity } from '../../auth/identity'
import { getToken, getYouTrackToken, setToken, setYouTrackToken } from '../../auth/secrets'
import { listClaudeProfiles, setProfilesDirOverride } from '../../cc/profiles'
import { detectRepo } from '../../git/remote'
import { logger } from '../../logging/logger'
import { readProfiles, writeProfiles } from '../../profiles/store'
import { getEffectiveCommitProfilePath, getEffectivePrDiffSummaryProfilePath, getSettings, saveSettings } from '../../settings/store'
import { webhookCoordinator } from '../../webhook/coordinator'
import { youtrackHost } from '../../youtrack/issueLoader'
import { makeNonce } from '../KanbanPanel'
export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
host: string
token: string
webhookPort: number
webhookSecret: string
brainstormPrompt: string
brainstormContinuePrompt: string
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
devBranch: string
releaseBranchPrefix: string
jenkinsJob: string
jenkinsProdHost: string
worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
pycharmCommand: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
implTabPostCloseScript: string
splitNewTerminals: boolean
defaultBrainstormProfilePath: string
defaultImplementProfilePath: string
defaultTestProfilePath: string
conflictResolutionProfilePath: string
commitProfilePath: string
prDiffSummaryProfilePath: string
codexModel: string
codexReasoningEffort: string
profilesDirectory: string
youtrackBaseUrl: string
youtrackProjectShortName: string
youtrackCloseCommand: string
/** Empty string = keep the existing stored YouTrack token (placeholder). */
youtrackToken: string
}): Promise<void> {
const trimmedHost = payload.host.trim()
const trimmedToken = payload.token.trim()
const trimmedYtBase = payload.youtrackBaseUrl.trim()
const trimmedYtProject = payload.youtrackProjectShortName.trim()
const trimmedYtClose = payload.youtrackCloseCommand.trim()
const trimmedYtToken = payload.youtrackToken.trim()
const trimmedDevBranch = payload.devBranch.trim()
// 发布前缀 / Jenkins 任务 / 正式服 host:只 trim,空值回退在 getSettings 读时做。
const trimmedReleasePrefix = payload.releaseBranchPrefix.trim()
const trimmedJenkinsJob = payload.jenkinsJob.trim()
const trimmedJenkinsProdHost = payload.jenkinsProdHost.trim()
// worktree dir template: '' = "use default", so don't preserve blanks
// specially — getSettings falls back to the default at read time.
const trimmedWorktreeDir = payload.worktreeDirectory.trim()
// pycharm launcher command: '' = "use default `pycharm`", getSettings falls
// back at read time, so just strip whitespace here.
const trimmedPycharmCmd = payload.pycharmCommand.trim()
// Hook script paths: keep '' meaningful (= "use default
// .spx/*.sh"). Just strip whitespace.
const trimmedPostCreate = payload.worktreePostCreateScript.trim()
const trimmedPreRemove = payload.worktreePreRemoveScript.trim()
const trimmedImplPre = payload.implTabPreCreateScript.trim()
const trimmedImplPost = payload.implTabPostCloseScript.trim()
// 三个会话默认 profile:'' 有意义(= 落到 DEFAULT_PROFILE_PATH),只 trim。
const trimmedDefaultBrainstormProfile = payload.defaultBrainstormProfilePath.trim()
const trimmedDefaultImplementProfile = payload.defaultImplementProfilePath.trim()
const trimmedDefaultTestProfile = payload.defaultTestProfilePath.trim()
// conflict-resolution profile: '' is meaningful (= DEFAULT_PROFILE_PATH).
const trimmedConflictProfile = payload.conflictResolutionProfilePath.trim()
// 提交代码 profile:'' 有意义(= 未配置 → 提交严格失败),只 trim。
const trimmedCommitProfile = payload.commitProfilePath.trim()
const trimmedPrDiffSummaryProfile = payload.prDiffSummaryProfilePath.trim()
// codex 模型 / 思考级别:空串有意义(= 用 codex 默认),只 trim。
const trimmedCodexModel = payload.codexModel.trim()
const trimmedCodexReasoningEffort = payload.codexReasoningEffort.trim()
// profiles 目录:空串有意义(= 自动探测),只 trim。
const trimmedProfilesDirectory = payload.profilesDirectory.trim()
const prev = getSettings(panel.context)
// Capture the previous token *for this host* before overwriting it, so
// we can decide below whether the kanban needs a re-fetch. (Only host
// and token affect the issue list — port/url-prefix/prompts don't.)
const oldToken = trimmedHost ? await getToken(panel.context, trimmedHost) : undefined
// Empty token + existing saved token = user wants to keep the existing
// one (placeholder semantics in the modal). Skip rewriting and skip the
// kanban refresh since auth didn't change.
const keepExisting = trimmedToken === '' && !!oldToken
if (!trimmedHost || (!trimmedToken && !keepExisting)) {
panel.postMessage({
type: 'settings/show',
host: trimmedHost,
errorMessage: 'Host 和 Token 都不能为空',
tokenSaved: !!oldToken,
webhookPort: payload.webhookPort,
webhookSecret: payload.webhookSecret,
brainstormPrompt: payload.brainstormPrompt || prev.brainstormPrompt,
brainstormContinuePrompt: payload.brainstormContinuePrompt || prev.brainstormContinuePrompt,
implementPlanPrompt: payload.implementPlanPrompt || prev.implementPlanPrompt,
autoReview: payload.autoReview,
reviewPrompt: payload.reviewPrompt || prev.reviewPrompt,
devBranch: trimmedDevBranch || prev.devBranch,
releaseBranchPrefix: trimmedReleasePrefix || prev.releaseBranchPrefix,
jenkinsJob: trimmedJenkinsJob,
jenkinsProdHost: trimmedJenkinsProdHost || prev.jenkinsProdHost,
worktreeDirectory: trimmedWorktreeDir || prev.worktreeDirectory,
worktreeOpenWith: payload.worktreeOpenWith || prev.worktreeOpenWith,
pycharmCommand: trimmedPycharmCmd || prev.pycharmCommand,
worktreePostCreateScript: trimmedPostCreate,
worktreePreRemoveScript: trimmedPreRemove,
implTabPreCreateScript: trimmedImplPre,
implTabPostCloseScript: trimmedImplPost,
splitNewTerminals: payload.splitNewTerminals,
defaultBrainstormProfilePath: trimmedDefaultBrainstormProfile,
defaultImplementProfilePath: trimmedDefaultImplementProfile,
defaultTestProfilePath: trimmedDefaultTestProfile,
conflictResolutionProfilePath: trimmedConflictProfile,
commitProfilePath: trimmedCommitProfile,
prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile,
codexModel: trimmedCodexModel,
codexReasoningEffort: trimmedCodexReasoningEffort,
profilesDirectory: trimmedProfilesDirectory,
})
return
}
await saveSettings(panel.context, {
webhookPort: payload.webhookPort,
webhookSecret: payload.webhookSecret.trim(),
brainstormPrompt: payload.brainstormPrompt,
brainstormContinuePrompt: payload.brainstormContinuePrompt,
implementPlanPrompt: payload.implementPlanPrompt,
autoReview: payload.autoReview,
reviewPrompt: payload.reviewPrompt,
devBranch: trimmedDevBranch,
releaseBranchPrefix: trimmedReleasePrefix,
jenkinsJob: trimmedJenkinsJob,
jenkinsProdHost: trimmedJenkinsProdHost,
worktreeDirectory: trimmedWorktreeDir,
worktreeOpenWith: payload.worktreeOpenWith,
pycharmCommand: trimmedPycharmCmd,
worktreePostCreateScript: trimmedPostCreate,
worktreePreRemoveScript: trimmedPreRemove,
implTabPreCreateScript: trimmedImplPre,
implTabPostCloseScript: trimmedImplPost,
splitNewTerminals: payload.splitNewTerminals,
defaultBrainstormProfilePath: trimmedDefaultBrainstormProfile,
defaultImplementProfilePath: trimmedDefaultImplementProfile,
defaultTestProfilePath: trimmedDefaultTestProfile,
conflictResolutionProfilePath: trimmedConflictProfile,
commitProfilePath: trimmedCommitProfile,
prDiffSummaryProfilePath: trimmedPrDiffSummaryProfile,
codexModel: trimmedCodexModel,
codexReasoningEffort: trimmedCodexReasoningEffort,
profilesDirectory: trimmedProfilesDirectory,
youtrackBaseUrl: trimmedYtBase,
youtrackProjectShortName: trimmedYtProject,
youtrackCloseCommand: trimmedYtClose,
})
// 保存后立刻让 listClaudeProfiles / getDefaultProfilePath 吃到新目录。
setProfilesDirOverride(trimmedProfilesDirectory || undefined)
void handleProfilesList(panel)
if (!keepExisting) {
await setToken(panel.context, trimmedHost, trimmedToken)
// 新 token 可能换了人,身份缓存和状态栏都要跟着重解析。
clearIdentityCache(trimmedHost)
void initIdentity(panel.context)
}
// YouTrack token: stored under the base URL's host. Empty input keeps the
// existing token (placeholder semantics), matching the gitea flow above.
if (trimmedYtBase && trimmedYtToken)
await setYouTrackToken(panel.context, youtrackHost(trimmedYtBase), trimmedYtToken)
// Honor a port change without requiring a window reload. Restart the
// listener and emit a log entry when the port actually changed so the
// user can see it in the log modal.
const newPort = getSettings(panel.context).webhookPort
if (newPort !== prev.webhookPort) {
logger.add({
level: 'info',
source: 'webhook',
message: `端口配置变更,重启监听 :${newPort}`,
})
}
webhookCoordinator.ensureSecret(getSettings(panel.context).webhookSecret)
try {
await webhookCoordinator.ensurePort(newPort)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: 'ensurePort 失败',
details: message,
})
}
// Only re-fetch issues when the credential that gates the kanban
// actually changed. Saves a round-trip + visible loading flash when the
// user just tweaked prompts or webhook settings. `keepExisting` already
// guarantees no auth change.
// Re-fetch when the gitea credential OR any YouTrack config changed (base
// URL / project / a freshly entered token) so newly-mirrored issues appear.
const youtrackChanged = prev.youtrackBaseUrl !== trimmedYtBase
|| prev.youtrackProjectShortName !== trimmedYtProject
|| trimmedYtToken !== ''
if ((!keepExisting && oldToken !== trimmedToken) || youtrackChanged)
await panel.loadAndPush()
// 开发分支 / 发布前缀可能变了,刷新工具栏发布按钮。
void panel.handleReleaseStatus()
}
export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
let host = ''
if (workspaceRoot) {
const remote = await detectRepo(workspaceRoot)
if (remote)
host = remote.host
}
const s = getSettings(panel.context)
// 打开设置时同步 override,避免未 activate 路径或旧值漂移。
setProfilesDirOverride(s.profilesDirectory || undefined)
const tok = host ? await getToken(panel.context, host) : undefined
const tokenSaved = !!tok && tok.length > 0
const ytTok = s.youtrackBaseUrl.trim()
? await getYouTrackToken(panel.context, youtrackHost(s.youtrackBaseUrl.trim()))
: undefined
// 升级 seed:key 缺失时预填 deepseek path(若有),仅展示不写回。
const commitProfilePath = await getEffectiveCommitProfilePath(panel.context)
const prDiffSummaryProfilePath = await getEffectivePrDiffSummaryProfilePath(panel.context)
// User clicked the gear themselves — let them back out without saving.
panel.postMessage({
type: 'settings/show',
host,
canCancel: true,
tokenSaved,
webhookPort: s.webhookPort,
webhookSecret: s.webhookSecret,
brainstormPrompt: s.brainstormPrompt,
brainstormContinuePrompt: s.brainstormContinuePrompt,
implementPlanPrompt: s.implementPlanPrompt,
autoReview: s.autoReview,
reviewPrompt: s.reviewPrompt,
devBranch: s.devBranch,
releaseBranchPrefix: s.releaseBranchPrefix,
jenkinsJob: s.jenkinsJob,
jenkinsProdHost: s.jenkinsProdHost,
worktreeDirectory: s.worktreeDirectory,
worktreeOpenWith: s.worktreeOpenWith,
pycharmCommand: s.pycharmCommand,
worktreePostCreateScript: s.worktreePostCreateScript,
worktreePreRemoveScript: s.worktreePreRemoveScript,
implTabPreCreateScript: s.implTabPreCreateScript,
implTabPostCloseScript: s.implTabPostCloseScript,
splitNewTerminals: s.splitNewTerminals,
defaultBrainstormProfilePath: s.defaultBrainstormProfilePath,
defaultImplementProfilePath: s.defaultImplementProfilePath,
defaultTestProfilePath: s.defaultTestProfilePath,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath,
prDiffSummaryProfilePath,
codexModel: s.codexModel,
codexReasoningEffort: s.codexReasoningEffort,
profilesDirectory: s.profilesDirectory,
youtrackBaseUrl: s.youtrackBaseUrl,
youtrackProjectShortName: s.youtrackProjectShortName,
youtrackCloseCommand: s.youtrackCloseCommand,
youtrackTokenSaved: !!ytTok && ytTok.length > 0,
})
}
/** Forces the open panel (if any) into the setup-auth state. */
export function requestEditAuth(panel: KanbanWebviewPanel): void {
void handleEditSettingsRequest(panel)
}
/**
* 工作区 profile 表读取入口:webview mount 时 / 切换到 Profile tab 时拉数据。
* 文件不存在直接返回默认结构 `{ profiles: ['dev', 'prod'], rows: [] }`,
* 不会物理创建文件。
*/
export async function handleProfilesGet(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,
})
return
}
try {
const data = await readProfiles(workspaceRoot)
panel.postMessage({ type: 'profiles/show', data })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `读取 profiles.json 失败: ${message}`,
dismissOnTimer: 6000,
})
}
}
/**
* Read profiles from the hardcoded directory and push the list to the
* webview. Failures are swallowed and surfaced as an empty list so the
* modal simply hides its profile selector.
*/
export async function handleProfilesList(panel: KanbanWebviewPanel): Promise<void> {
try {
const profiles = await listClaudeProfiles()
panel.postMessage({ type: 'profiles/update', profiles })
}
catch {
panel.postMessage({ type: 'profiles/update', profiles: [] })
}
}
/**
* 智能打开 profile 单元格里的 value。
*
* - http(s):// / git@host:owner/repo → 外部浏览器(git@ 先转 https)
* - 其他 → 当作路径处理,`~` 展开为 home,相对路径以 workspaceRoot 为基
* - 是目录 → revealFileInOS
* - 是文件 → vscode.open
* - 不存在 → toast 报错
*/
/**
* Lower-cased extensions (with leading dot) that should open inside the
* VS Code editor. Anything not in this set — and files with no extension
* are treated as text too — is handed to the OS default application.
*/
const TEXT_OPEN_EXTENSIONS = new Set([
'.md',
'.markdown',
'.txt',
'.text',
'.csv',
'.tsv',
'.json',
'.jsonc',
'.yaml',
'.yml',
'.toml',
'.ini',
'.conf',
'.cfg',
'.log',
'.xml',
'.html',
'.htm',
'.css',
'.scss',
'.js',
'.mjs',
'.cjs',
'.ts',
'.tsx',
'.jsx',
'.vue',
'.py',
'.go',
'.rs',
'.java',
'.kt',
'.c',
'.h',
'.cpp',
'.hpp',
'.cc',
'.sh',
'.bash',
'.zsh',
'.sql',
'.env',
'.properties',
'.gradle',
'.dockerfile',
'.gitignore',
])
/**
* Open a file with the OS default application (fire-and-forget).
*
* Dispatches per platform without a shell so the path cannot be
* interpreted as a command. On Windows the first empty `start` argument is
* the window-title placeholder required by `cmd`'s `start` builtin.
*/
function openWithSystemDefault(absPath: string): void {
let command: string
let args: string[]
switch (process.platform) {
case 'darwin':
command = 'open'
args = [absPath]
break
case 'win32':
command = 'cmd'
args = ['/c', 'start', '', absPath]
break
default:
command = 'xdg-open'
args = [absPath]
break
}
execFile(command, args, (err) => {
if (err) {
logger.add({
level: 'error',
source: 'profiles',
message: `系统默认程序打开失败: ${absPath}`,
details: err.message,
})
}
})
}
export async function handleProfilesOpen(panel: KanbanWebviewPanel, value: string): Promise<void> {
const trimmed = value.trim()
if (!trimmed) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '无法打开:值为空',
dismissOnTimer: 5000,
})
return
}
// URL 分支:http(s) 直开;git@ 先转 https
if (/^(?:https?:\/\/|git@)/i.test(trimmed)) {
let url = trimmed
const gitSshMatch = /^git@([^:]+):(.+?)(?:\.git)?$/i.exec(trimmed)
if (gitSshMatch) {
const host = gitSshMatch[1]
const repoPath = gitSshMatch[2].replace(/\.git$/i, '')
url = `https://${host}/${repoPath}`
}
else if (/^https?:\/\//i.test(trimmed)) {
url = trimmed.replace(/\.git$/i, '')
}
try {
await env.openExternal(Uri.parse(url))
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `打开链接失败: ${message}`,
dismissOnTimer: 6000,
})
}
return
}
// 路径分支:~ 展开、相对路径补 workspaceRoot
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
let abs: string
if (trimmed.startsWith('~')) {
abs = path.join(os.homedir(), trimmed.slice(1))
}
else if (path.isAbsolute(trimmed)) {
abs = trimmed
}
else {
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '无法解析相对路径:未打开工作区',
dismissOnTimer: 5000,
})
return
}
abs = path.join(workspaceRoot, trimmed)
}
let stat: fs.Stats
try {
stat = fs.statSync(abs)
}
catch {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `无法打开:路径不存在 / 不是 URL(${abs})`,
dismissOnTimer: 6000,
})
return
}
try {
if (stat.isDirectory()) {
await commands.executeCommand('revealFileInOS', Uri.file(abs))
}
else if (stat.isFile()) {
const ext = path.extname(abs).toLowerCase()
// No extension counts as text. Text/code extensions open in the
// editor; everything else (archives, office docs, media, …) is
// handed to the OS default application so binary content is not
// garbled by VS Code's text viewer.
if (ext === '' || TEXT_OPEN_EXTENSIONS.has(ext)) {
await commands.executeCommand('vscode.open', Uri.file(abs))
}
else {
openWithSystemDefault(abs)
}
}
else {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `无法打开:既不是文件也不是目录(${abs})`,
dismissOnTimer: 6000,
})
}
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `打开失败: ${message}`,
dismissOnTimer: 6000,
})
}
}
/**
* 工作区 profile 表写入入口。webview 已经 optimistic 更新,
* 成功不回消息;失败弹 toast。
*/
export async function handleProfilesSave(panel: KanbanWebviewPanel, data: ProfilesData): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot) {
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: '请先打开一个工作区文件夹',
dismissOnTimer: 5000,
})
return
}
try {
await writeProfiles(workspaceRoot, data)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `写入 profiles.json 失败: ${message}`,
dismissOnTimer: 6000,
})
}
}