1349 lines
53 KiB
TypeScript
1349 lines
53 KiB
TypeScript
/**
|
||
* Overlay modal for editing Superpowers settings. Sits on top of the kanban
|
||
* without unmounting it — matches the LogModal / NewIssueModal pattern:
|
||
* - Backdrop click is a no-op (only ESC closes, and only when `canCancel`).
|
||
* - Two-column layout: vertical group rail on the left, fields on the right.
|
||
*
|
||
* Replaces the old full-screen `SetupForm.tsx`.
|
||
*/
|
||
|
||
import type { ReactElement } from 'react'
|
||
import type { ClaudeProfile } from '../hooks/useIssues'
|
||
import { X } from 'lucide-react'
|
||
import { useEffect, useState } from 'react'
|
||
import { SelectMenu } from './ui/select-menu'
|
||
|
||
type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks'
|
||
|
||
interface SubmitValues {
|
||
host: string
|
||
token: string
|
||
webhookPort: number
|
||
webhookSecret: string
|
||
brainstormPrompt: string
|
||
implementPlanPrompt: string
|
||
autoReview: boolean
|
||
reviewPrompt: string
|
||
testPrompt: string
|
||
prototypePrompt: 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
|
||
defaultPrototypeProfilePath: string
|
||
defaultAskProfilePath: string
|
||
defaultRewriteProfilePath: string
|
||
conflictResolutionProfilePath: string
|
||
commitProfilePath: string
|
||
prDiffSummaryProfilePath: string
|
||
codexModel: string
|
||
codexReasoningEffort: string
|
||
profilesDirectory: string
|
||
youtrackBaseUrl: string
|
||
youtrackProjectShortName: string
|
||
youtrackCloseCommand: string
|
||
youtrackToken: string
|
||
}
|
||
|
||
/** Project option for the YouTrack project dropdown. */
|
||
export interface YouTrackProjectOption {
|
||
id: string
|
||
name: string
|
||
shortName: string
|
||
}
|
||
|
||
export interface YouTrackProjectsState {
|
||
status: 'idle' | 'loading' | 'ready' | 'error'
|
||
projects: YouTrackProjectOption[]
|
||
error?: string
|
||
}
|
||
|
||
export interface SettingsModalProps {
|
||
open: boolean
|
||
host: string
|
||
errorMessage?: string
|
||
canCancel?: boolean
|
||
initialTokenSaved: boolean
|
||
initialWebhookPort: number
|
||
initialWebhookSecret: string
|
||
initialBrainstormPrompt: string
|
||
initialImplementPlanPrompt: string
|
||
initialAutoReview: boolean
|
||
initialReviewPrompt: string
|
||
initialTestPrompt: string
|
||
initialPrototypePrompt: string
|
||
initialDevBranch: string
|
||
initialReleaseBranchPrefix: string
|
||
initialJenkinsJob: string
|
||
initialJenkinsProdHost: string
|
||
initialWorktreeDirectory: string
|
||
initialWorktreeOpenWith: 'vscode' | 'pycharm'
|
||
initialPycharmCommand: string
|
||
initialWorktreePostCreateScript: string
|
||
initialWorktreePreRemoveScript: string
|
||
initialImplTabPreCreateScript: string
|
||
initialImplTabPostCloseScript: string
|
||
initialSplitNewTerminals: boolean
|
||
initialDefaultBrainstormProfilePath: string
|
||
initialDefaultImplementProfilePath: string
|
||
initialDefaultTestProfilePath: string
|
||
initialDefaultPrototypeProfilePath: string
|
||
initialDefaultAskProfilePath: string
|
||
initialDefaultRewriteProfilePath: string
|
||
initialConflictResolutionProfilePath: string
|
||
initialCommitProfilePath: string
|
||
initialPrDiffSummaryProfilePath: string
|
||
initialCodexModel: string
|
||
initialCodexReasoningEffort: string
|
||
initialProfilesDirectory: string
|
||
initialYoutrackBaseUrl: string
|
||
initialYoutrackProjectShortName: string
|
||
initialYoutrackCloseCommand: string
|
||
initialYoutrackTokenSaved: boolean
|
||
profiles: ClaudeProfile[]
|
||
youtrackProjects: YouTrackProjectsState
|
||
onListYouTrackProjects: (baseUrl: string, token: string) => void
|
||
onSubmit: (values: SubmitValues) => void
|
||
onCancel?: () => void
|
||
}
|
||
|
||
const inputClass = 'w-full rounded border border-[var(--vscode-input-border,transparent)] bg-[var(--vscode-input-background)] px-2 py-1 text-xs text-[var(--vscode-input-foreground)] outline-none focus:border-[var(--vscode-focusBorder)]'
|
||
const promptTextareaClass = `${inputClass} field-sizing-content min-h-16 resize-y font-mono`
|
||
|
||
interface FieldProps {
|
||
label: string
|
||
hint?: React.ReactNode
|
||
error?: string
|
||
children: React.ReactNode
|
||
}
|
||
|
||
function Field({ label, hint, error, children }: FieldProps) {
|
||
return (
|
||
<label className="mb-3 block text-xs">
|
||
<span className="mb-1 block opacity-80">{label}</span>
|
||
{children}
|
||
{hint && (
|
||
<span className="mt-1 block text-[10px] opacity-60">{hint}</span>
|
||
)}
|
||
{error && (
|
||
<span className="mt-1 block text-[10px] text-state-red">{error}</span>
|
||
)}
|
||
</label>
|
||
)
|
||
}
|
||
|
||
const GROUPS: Array<{ key: GroupKey, label: string }> = [
|
||
{ key: 'auth', label: '认证' },
|
||
{ key: 'network', label: '网络' },
|
||
{ key: 'youtrack', label: 'YouTrack' },
|
||
{ key: 'prompts', label: '提示词' },
|
||
{ key: 'hooks', label: '钩子' },
|
||
]
|
||
|
||
type ErrorKey = 'host' | 'token' | 'webhookPort'
|
||
|
||
/**
|
||
* Map a field name to the group it lives in, so we can auto-switch to the
|
||
* first invalid field's group on save.
|
||
*/
|
||
const FIELD_TO_GROUP: Record<ErrorKey, GroupKey> = {
|
||
host: 'auth',
|
||
token: 'auth',
|
||
webhookPort: 'network',
|
||
}
|
||
|
||
export function SettingsModal({
|
||
open,
|
||
host: initialHost,
|
||
errorMessage,
|
||
canCancel,
|
||
initialTokenSaved,
|
||
initialWebhookPort,
|
||
initialWebhookSecret,
|
||
initialBrainstormPrompt,
|
||
initialImplementPlanPrompt,
|
||
initialAutoReview,
|
||
initialReviewPrompt,
|
||
initialTestPrompt,
|
||
initialPrototypePrompt,
|
||
initialDevBranch,
|
||
initialReleaseBranchPrefix,
|
||
initialJenkinsJob,
|
||
initialJenkinsProdHost,
|
||
initialWorktreeDirectory,
|
||
initialWorktreeOpenWith,
|
||
initialPycharmCommand,
|
||
initialWorktreePostCreateScript,
|
||
initialWorktreePreRemoveScript,
|
||
initialImplTabPreCreateScript,
|
||
initialImplTabPostCloseScript,
|
||
initialSplitNewTerminals,
|
||
initialDefaultBrainstormProfilePath,
|
||
initialDefaultImplementProfilePath,
|
||
initialDefaultTestProfilePath,
|
||
initialDefaultPrototypeProfilePath,
|
||
initialDefaultAskProfilePath,
|
||
initialDefaultRewriteProfilePath,
|
||
initialConflictResolutionProfilePath,
|
||
initialCommitProfilePath,
|
||
initialPrDiffSummaryProfilePath,
|
||
initialCodexModel,
|
||
initialCodexReasoningEffort,
|
||
initialProfilesDirectory,
|
||
initialYoutrackBaseUrl,
|
||
initialYoutrackProjectShortName,
|
||
initialYoutrackCloseCommand,
|
||
initialYoutrackTokenSaved,
|
||
profiles,
|
||
youtrackProjects,
|
||
onListYouTrackProjects,
|
||
onSubmit,
|
||
onCancel,
|
||
}: SettingsModalProps): ReactElement | null {
|
||
const [activeGroup, setActiveGroup] = useState<GroupKey>('auth')
|
||
const [host, setHost] = useState(initialHost)
|
||
const [token, setToken] = useState('')
|
||
const [webhookPort, setWebhookPort] = useState<string>(String(initialWebhookPort))
|
||
const [webhookSecret, setWebhookSecret] = useState(initialWebhookSecret)
|
||
const [brainstormPrompt, setBrainstormPrompt] = useState(initialBrainstormPrompt)
|
||
const [implementPlanPrompt, setImplementPlanPrompt] = useState(initialImplementPlanPrompt)
|
||
const [autoReview, setAutoReview] = useState(initialAutoReview)
|
||
const [reviewPrompt, setReviewPrompt] = useState(initialReviewPrompt)
|
||
const [testPrompt, setTestPrompt] = useState(initialTestPrompt)
|
||
const [prototypePrompt, setPrototypePrompt] = useState(initialPrototypePrompt)
|
||
const [devBranch, setDevBranch] = useState(initialDevBranch)
|
||
const [releaseBranchPrefix, setReleaseBranchPrefix] = useState(initialReleaseBranchPrefix)
|
||
const [jenkinsJob, setJenkinsJob] = useState(initialJenkinsJob)
|
||
const [jenkinsProdHost, setJenkinsProdHost] = useState(initialJenkinsProdHost)
|
||
const [worktreeDirectory, setWorktreeDirectory] = useState(initialWorktreeDirectory)
|
||
const [worktreeOpenWith, setWorktreeOpenWith] = useState(initialWorktreeOpenWith)
|
||
const [pycharmCommand, setPycharmCommand] = useState(initialPycharmCommand)
|
||
const [worktreePostCreateScript, setWorktreePostCreateScript] = useState(initialWorktreePostCreateScript)
|
||
const [worktreePreRemoveScript, setWorktreePreRemoveScript] = useState(initialWorktreePreRemoveScript)
|
||
const [implTabPreCreateScript, setImplTabPreCreateScript] = useState(initialImplTabPreCreateScript)
|
||
const [implTabPostCloseScript, setImplTabPostCloseScript] = useState(initialImplTabPostCloseScript)
|
||
const [splitNewTerminals, setSplitNewTerminals] = useState(initialSplitNewTerminals)
|
||
const [defaultBrainstormProfilePath, setDefaultBrainstormProfilePath] = useState(initialDefaultBrainstormProfilePath)
|
||
const [defaultImplementProfilePath, setDefaultImplementProfilePath] = useState(initialDefaultImplementProfilePath)
|
||
const [defaultTestProfilePath, setDefaultTestProfilePath] = useState(initialDefaultTestProfilePath)
|
||
const [defaultPrototypeProfilePath, setDefaultPrototypeProfilePath] = useState(initialDefaultPrototypeProfilePath)
|
||
const [defaultAskProfilePath, setDefaultAskProfilePath] = useState(initialDefaultAskProfilePath)
|
||
const [defaultRewriteProfilePath, setDefaultRewriteProfilePath] = useState(initialDefaultRewriteProfilePath)
|
||
const [conflictResolutionProfilePath, setConflictResolutionProfilePath] = useState(initialConflictResolutionProfilePath)
|
||
const [commitProfilePath, setCommitProfilePath] = useState(initialCommitProfilePath)
|
||
const [prDiffSummaryProfilePath, setPrDiffSummaryProfilePath] = useState(initialPrDiffSummaryProfilePath)
|
||
const [codexModel, setCodexModel] = useState(initialCodexModel)
|
||
const [codexReasoningEffort, setCodexReasoningEffort] = useState(initialCodexReasoningEffort)
|
||
const [profilesDirectory, setProfilesDirectory] = useState(initialProfilesDirectory)
|
||
const [youtrackBaseUrl, setYoutrackBaseUrl] = useState(initialYoutrackBaseUrl)
|
||
const [youtrackProjectShortName, setYoutrackProjectShortName] = useState(initialYoutrackProjectShortName)
|
||
const [youtrackCloseCommand, setYoutrackCloseCommand] = useState(initialYoutrackCloseCommand)
|
||
const [youtrackToken, setYoutrackToken] = useState('')
|
||
const [errors, setErrors] = useState<Partial<Record<ErrorKey, string>>>({})
|
||
|
||
// Reset local state whenever the modal opens with fresh server values.
|
||
// We intentionally don't depend on initialBrainstormPrompt/initialImplementPlanPrompt
|
||
// to mid-edit; this effect just primes form state on each (re)open.
|
||
useEffect(() => {
|
||
if (!open)
|
||
return
|
||
setActiveGroup('auth')
|
||
setHost(initialHost)
|
||
setToken('')
|
||
setWebhookPort(String(initialWebhookPort))
|
||
setWebhookSecret(initialWebhookSecret)
|
||
setBrainstormPrompt(initialBrainstormPrompt)
|
||
setImplementPlanPrompt(initialImplementPlanPrompt)
|
||
setAutoReview(initialAutoReview)
|
||
setReviewPrompt(initialReviewPrompt)
|
||
setTestPrompt(initialTestPrompt)
|
||
setPrototypePrompt(initialPrototypePrompt)
|
||
setDevBranch(initialDevBranch)
|
||
setReleaseBranchPrefix(initialReleaseBranchPrefix)
|
||
setJenkinsJob(initialJenkinsJob)
|
||
setJenkinsProdHost(initialJenkinsProdHost)
|
||
setWorktreeDirectory(initialWorktreeDirectory)
|
||
setWorktreeOpenWith(initialWorktreeOpenWith)
|
||
setPycharmCommand(initialPycharmCommand)
|
||
setWorktreePostCreateScript(initialWorktreePostCreateScript)
|
||
setWorktreePreRemoveScript(initialWorktreePreRemoveScript)
|
||
setImplTabPreCreateScript(initialImplTabPreCreateScript)
|
||
setImplTabPostCloseScript(initialImplTabPostCloseScript)
|
||
setSplitNewTerminals(initialSplitNewTerminals)
|
||
setDefaultBrainstormProfilePath(initialDefaultBrainstormProfilePath)
|
||
setDefaultImplementProfilePath(initialDefaultImplementProfilePath)
|
||
setDefaultTestProfilePath(initialDefaultTestProfilePath)
|
||
setDefaultPrototypeProfilePath(initialDefaultPrototypeProfilePath)
|
||
setDefaultAskProfilePath(initialDefaultAskProfilePath)
|
||
setDefaultRewriteProfilePath(initialDefaultRewriteProfilePath)
|
||
setConflictResolutionProfilePath(initialConflictResolutionProfilePath)
|
||
setCommitProfilePath(initialCommitProfilePath)
|
||
setPrDiffSummaryProfilePath(initialPrDiffSummaryProfilePath)
|
||
setCodexModel(initialCodexModel)
|
||
setCodexReasoningEffort(initialCodexReasoningEffort)
|
||
setProfilesDirectory(initialProfilesDirectory)
|
||
setYoutrackBaseUrl(initialYoutrackBaseUrl)
|
||
setYoutrackProjectShortName(initialYoutrackProjectShortName)
|
||
setYoutrackCloseCommand(initialYoutrackCloseCommand)
|
||
setYoutrackToken('')
|
||
setErrors({})
|
||
}, [open])
|
||
|
||
// ESC closes when allowed.
|
||
useEffect(() => {
|
||
if (!open)
|
||
return
|
||
function onKey(e: KeyboardEvent): void {
|
||
if (e.key === 'Escape' && canCancel && onCancel) {
|
||
e.preventDefault()
|
||
onCancel()
|
||
}
|
||
}
|
||
document.addEventListener('keydown', onKey)
|
||
return () => document.removeEventListener('keydown', onKey)
|
||
}, [open, canCancel, onCancel])
|
||
|
||
if (!open)
|
||
return null
|
||
|
||
const trimmedHost = host.trim()
|
||
const tokenPageUrl = trimmedHost
|
||
? `https://${trimmedHost}/user/settings/applications`
|
||
: ''
|
||
|
||
function handleSubmit(event: React.FormEvent): void {
|
||
event.preventDefault()
|
||
const nextErrors: Partial<Record<ErrorKey, string>> = {}
|
||
const trimmedToken = token.trim()
|
||
if (!trimmedHost)
|
||
nextErrors.host = 'Host 不能为空'
|
||
// When a token is already saved server-side, leaving the field blank
|
||
// means "keep the existing token" — don't flag it as an error. Only
|
||
// require a new token when nothing is saved yet.
|
||
if (!trimmedToken && !initialTokenSaved)
|
||
nextErrors.token = 'Token 不能为空'
|
||
const portNum = Number.parseInt(webhookPort, 10)
|
||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
|
||
nextErrors.webhookPort = '端口必须是 1–65535 的整数'
|
||
|
||
if (Object.keys(nextErrors).length > 0) {
|
||
setErrors(nextErrors)
|
||
// Auto-jump to the group containing the first invalid field, so the
|
||
// inline error is visible without the user hunting for it.
|
||
const order: ErrorKey[] = ['host', 'token', 'webhookPort']
|
||
for (const key of order) {
|
||
if (nextErrors[key]) {
|
||
setActiveGroup(FIELD_TO_GROUP[key])
|
||
break
|
||
}
|
||
}
|
||
return
|
||
}
|
||
setErrors({})
|
||
onSubmit({
|
||
host: trimmedHost,
|
||
// Pass raw token (may be empty string when keeping existing). The
|
||
// extension side detects empty + existing-saved as "preserve".
|
||
token: trimmedToken,
|
||
webhookPort: portNum,
|
||
webhookSecret: webhookSecret.trim(),
|
||
brainstormPrompt,
|
||
implementPlanPrompt,
|
||
autoReview,
|
||
reviewPrompt,
|
||
testPrompt,
|
||
prototypePrompt,
|
||
devBranch: devBranch.trim(),
|
||
releaseBranchPrefix: releaseBranchPrefix.trim(),
|
||
jenkinsJob: jenkinsJob.trim(),
|
||
jenkinsProdHost: jenkinsProdHost.trim(),
|
||
// worktree 目录模板:trim 后留空 = 用默认模板(getSettings 读时回退)
|
||
worktreeDirectory: worktreeDirectory.trim(),
|
||
worktreeOpenWith,
|
||
// pycharm 启动命令:trim 后留空 = 用默认 `pycharm`(getSettings 读时回退)
|
||
pycharmCommand: pycharmCommand.trim(),
|
||
// 钩子路径保持原样 trim(前后空格无意义;空串 = 用默认 .spx/*.sh)
|
||
worktreePostCreateScript: worktreePostCreateScript.trim(),
|
||
worktreePreRemoveScript: worktreePreRemoveScript.trim(),
|
||
implTabPreCreateScript: implTabPreCreateScript.trim(),
|
||
implTabPostCloseScript: implTabPostCloseScript.trim(),
|
||
splitNewTerminals,
|
||
// 四个会话默认 profile:空串 = 落到 DEFAULT_PROFILE_PATH
|
||
defaultBrainstormProfilePath: defaultBrainstormProfilePath.trim(),
|
||
defaultImplementProfilePath: defaultImplementProfilePath.trim(),
|
||
defaultTestProfilePath: defaultTestProfilePath.trim(),
|
||
defaultPrototypeProfilePath: defaultPrototypeProfilePath.trim(),
|
||
defaultAskProfilePath: defaultAskProfilePath.trim(),
|
||
defaultRewriteProfilePath: defaultRewriteProfilePath.trim(),
|
||
// 冲突解决 profile:空串 = 用 DEFAULT_PROFILE_PATH
|
||
conflictResolutionProfilePath: conflictResolutionProfilePath.trim(),
|
||
// 提交代码 profile:空串 = 未配置 → 提交严格失败
|
||
commitProfilePath: commitProfilePath.trim(),
|
||
prDiffSummaryProfilePath: prDiffSummaryProfilePath.trim(),
|
||
// codex 模型 / 思考级别:trim 后留空 = 用 codex config.toml 默认
|
||
codexModel: codexModel.trim(),
|
||
codexReasoningEffort: codexReasoningEffort.trim(),
|
||
// profiles 目录:trim 后留空 = 自动探测
|
||
profilesDirectory: profilesDirectory.trim(),
|
||
youtrackBaseUrl: youtrackBaseUrl.trim(),
|
||
youtrackProjectShortName: youtrackProjectShortName.trim(),
|
||
youtrackCloseCommand: youtrackCloseCommand.trim(),
|
||
// Raw (may be empty = keep existing), mirroring the gitea token rule.
|
||
youtrackToken: youtrackToken.trim(),
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
className="relative flex h-[min(600px,80vh)] w-[min(900px,90vw)] flex-col rounded-md border border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)] shadow-xl"
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center gap-2 border-b border-[var(--vscode-panel-border)] px-4 py-2">
|
||
<h2 className="text-sm font-medium">设置</h2>
|
||
{canCancel && onCancel && (
|
||
<button
|
||
type="button"
|
||
onClick={onCancel}
|
||
aria-label="关闭"
|
||
className="ml-auto grid size-6 place-items-center rounded opacity-60 hover:opacity-100"
|
||
>
|
||
<X className="size-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div className="flex min-h-0 flex-1 flex-row">
|
||
{/* Left rail */}
|
||
<div className="flex w-40 shrink-0 flex-col border-r border-[var(--vscode-panel-border)] py-2">
|
||
{GROUPS.map((g) => {
|
||
const active = g.key === activeGroup
|
||
return (
|
||
<button
|
||
key={g.key}
|
||
type="button"
|
||
onClick={() => setActiveGroup(g.key)}
|
||
className={[
|
||
'px-3 py-1.5 text-left text-xs',
|
||
active
|
||
? 'bg-white/10 text-[var(--vscode-foreground)]'
|
||
: 'opacity-80 hover:bg-white/5',
|
||
].join(' ')}
|
||
>
|
||
{g.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Right pane */}
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||
{errorMessage && (
|
||
<div className="mb-4 rounded border border-state-red/60 bg-state-red/10 px-3 py-2 text-xs text-state-red">
|
||
{errorMessage}
|
||
</div>
|
||
)}
|
||
|
||
{activeGroup === 'auth' && (
|
||
<>
|
||
<Field label="Gitea Host" error={errors.host}>
|
||
<input
|
||
type="text"
|
||
value={host}
|
||
onChange={e => setHost(e.target.value)}
|
||
placeholder="gitea.example.com"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="Personal Access Token"
|
||
error={errors.token}
|
||
hint={(
|
||
<>
|
||
在
|
||
{' '}
|
||
{tokenPageUrl
|
||
? (
|
||
<a
|
||
href={tokenPageUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="text-[var(--vscode-textLink-foreground)] underline"
|
||
>
|
||
{tokenPageUrl}
|
||
</a>
|
||
)
|
||
: <span className="opacity-60">填写 Host 后这里会显示生成 token 的链接</span>}
|
||
{' '}
|
||
生成一个 token
|
||
{initialTokenSaved && (
|
||
<>
|
||
<br />
|
||
已保存,留空则保留
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="password"
|
||
value={token}
|
||
onChange={e => setToken(e.target.value)}
|
||
placeholder={initialTokenSaved ? '••••••••' : '例如 1a2b3c4d…'}
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="开发分支"
|
||
hint={(
|
||
<>
|
||
日常推送的分支,留空默认
|
||
{' '}
|
||
<code>main</code>
|
||
。发布按钮从这条分支切发布分支、统计未发布提交。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={devBranch}
|
||
onChange={e => setDevBranch(e.target.value)}
|
||
placeholder="main"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="发布分支前缀"
|
||
hint="线上跑的发布分支名前缀,默认 release/,按钮会自动取最新的一个"
|
||
>
|
||
<input
|
||
type="text"
|
||
value={releaseBranchPrefix}
|
||
onChange={e => setReleaseBranchPrefix(e.target.value)}
|
||
placeholder="release/"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="Jenkins 任务名"
|
||
hint={(
|
||
<>
|
||
留空则不显示「部署到正式服」;触发时执行
|
||
{' '}
|
||
<code>opencli jks build <任务名> -p REF=… -p REMOTE_HOST=…</code>
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={jenkinsJob}
|
||
onChange={e => setJenkinsJob(e.target.value)}
|
||
placeholder="留空 = 不提供部署"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="正式服 REMOTE_HOST 值">
|
||
<input
|
||
type="text"
|
||
value={jenkinsProdHost}
|
||
onChange={e => setJenkinsProdHost(e.target.value)}
|
||
placeholder="正式服"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="头脑风暴默认配置文件"
|
||
hint={(
|
||
<>
|
||
新建工单 / 续聊的头脑风暴会话默认用此 profile;工单详情面板里的「头脑风暴配置文件」优先级更高。留空用 official。
|
||
</>
|
||
)}
|
||
>
|
||
<SelectMenu
|
||
value={defaultBrainstormProfilePath}
|
||
onChange={setDefaultBrainstormProfilePath}
|
||
variant="input"
|
||
className={inputClass}
|
||
options={[
|
||
{ value: '', label: '(默认)' },
|
||
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||
...(defaultBrainstormProfilePath
|
||
&& !profiles.some(p => p.path === defaultBrainstormProfilePath)
|
||
? [{ value: defaultBrainstormProfilePath, label: `自定义:${defaultBrainstormProfilePath}` }]
|
||
: []),
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="实施默认配置文件"
|
||
hint={(
|
||
<>
|
||
实施会话默认用此 profile;工单详情面板里的「实施配置文件」优先级更高。留空用 official。
|
||
</>
|
||
)}
|
||
>
|
||
<SelectMenu
|
||
value={defaultImplementProfilePath}
|
||
onChange={setDefaultImplementProfilePath}
|
||
variant="input"
|
||
className={inputClass}
|
||
options={[
|
||
{ value: '', label: '(默认)' },
|
||
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||
...(defaultImplementProfilePath
|
||
&& !profiles.some(p => p.path === defaultImplementProfilePath)
|
||
? [{ value: defaultImplementProfilePath, label: `自定义:${defaultImplementProfilePath}` }]
|
||
: []),
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="测试默认配置文件"
|
||
hint={(
|
||
<>
|
||
测试会话默认用此 profile;工单详情面板里的「测试配置文件」优先级更高。留空用 official。
|
||
</>
|
||
)}
|
||
>
|
||
<SelectMenu
|
||
value={defaultTestProfilePath}
|
||
onChange={setDefaultTestProfilePath}
|
||
variant="input"
|
||
className={inputClass}
|
||
options={[
|
||
{ value: '', label: '(默认)' },
|
||
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||
...(defaultTestProfilePath
|
||
&& !profiles.some(p => p.path === defaultTestProfilePath)
|
||
? [{ value: defaultTestProfilePath, label: `自定义:${defaultTestProfilePath}` }]
|
||
: []),
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="原型默认配置文件"
|
||
hint={(
|
||
<>
|
||
原型会话默认用此 profile;工单详情面板里的「原型配置文件」优先级更高。留空用 official。
|
||
</>
|
||
)}
|
||
>
|
||
<SelectMenu
|
||
value={defaultPrototypeProfilePath}
|
||
onChange={setDefaultPrototypeProfilePath}
|
||
variant="input"
|
||
className={inputClass}
|
||
options={[
|
||
{ value: '', label: '(默认)' },
|
||
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||
...(defaultPrototypeProfilePath
|
||
&& !profiles.some(p => p.path === defaultPrototypeProfilePath)
|
||
? [{ value: defaultPrototypeProfilePath, label: `自定义:${defaultPrototypeProfilePath}` }]
|
||
: []),
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="问答默认模型"
|
||
hint="代码阅读里问 AI 默认使用此模型:代码块下面的问答、预览顶部问这个文件或文件夹、「了解一块代码」都用它;留空沿用官方默认。输入框旁可临时切换,不会修改这里的默认值;重启或修改此设置后恢复默认。"
|
||
>
|
||
<SelectMenu
|
||
value={defaultAskProfilePath}
|
||
onChange={setDefaultAskProfilePath}
|
||
variant="input"
|
||
className={inputClass}
|
||
options={[
|
||
{ value: '', label: '(默认)' },
|
||
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||
...(defaultAskProfilePath
|
||
&& !profiles.some(p => p.path === defaultAskProfilePath)
|
||
? [{ value: defaultAskProfilePath, label: `不可用:${defaultAskProfilePath}` }]
|
||
: []),
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="重写注释默认模型"
|
||
hint="代码阅读里重写注释默认使用此模型:点按钮重写、在输入框里接着说都用它;留空沿用官方默认。输入框旁可临时切换,不会修改这里的默认值;重启或修改此设置后恢复默认。"
|
||
>
|
||
<SelectMenu
|
||
value={defaultRewriteProfilePath}
|
||
onChange={setDefaultRewriteProfilePath}
|
||
variant="input"
|
||
className={inputClass}
|
||
options={[
|
||
{ value: '', label: '(默认)' },
|
||
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||
...(defaultRewriteProfilePath
|
||
&& !profiles.some(p => p.path === defaultRewriteProfilePath)
|
||
? [{ value: defaultRewriteProfilePath, label: `不可用:${defaultRewriteProfilePath}` }]
|
||
: []),
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="冲突解决配置文件"
|
||
hint={(
|
||
<>
|
||
拖到「完成」时若 PR 与 main/dev 冲突,自动开的冲突解决会话使用此 profile;留空用默认 official。
|
||
</>
|
||
)}
|
||
>
|
||
<SelectMenu
|
||
value={conflictResolutionProfilePath}
|
||
onChange={setConflictResolutionProfilePath}
|
||
variant="input"
|
||
className={inputClass}
|
||
options={[
|
||
{ value: '', label: '(默认)' },
|
||
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||
...(conflictResolutionProfilePath
|
||
&& !profiles.some(p => p.path === conflictResolutionProfilePath)
|
||
? [{ value: conflictResolutionProfilePath, label: `自定义:${conflictResolutionProfilePath}` }]
|
||
: []),
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="提交代码 profile"
|
||
hint={(
|
||
<>
|
||
顶部工具栏「提交代码」使用此 profile。留空则提交失败,不会回退默认。
|
||
</>
|
||
)}
|
||
>
|
||
<SelectMenu
|
||
value={commitProfilePath}
|
||
onChange={setCommitProfilePath}
|
||
variant="input"
|
||
className={inputClass}
|
||
options={[
|
||
{ value: '', label: '(未配置)' },
|
||
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||
...(commitProfilePath
|
||
&& !profiles.some(p => p.path === commitProfilePath)
|
||
? [{ value: commitProfilePath, label: `自定义:${commitProfilePath}` }]
|
||
: []),
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="改动说明 profile"
|
||
hint={(
|
||
<>
|
||
「改动」tab 单文件说明和工单 PR 变更摘要使用此 profile。留空则生成失败,不会回退默认。
|
||
</>
|
||
)}
|
||
>
|
||
<SelectMenu
|
||
value={prDiffSummaryProfilePath}
|
||
onChange={setPrDiffSummaryProfilePath}
|
||
variant="input"
|
||
className={inputClass}
|
||
options={[
|
||
{ value: '', label: '(未配置)' },
|
||
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||
...(prDiffSummaryProfilePath
|
||
&& !profiles.some(p => p.path === prDiffSummaryProfilePath)
|
||
? [{ value: prDiffSummaryProfilePath, label: `自定义:${prDiffSummaryProfilePath}` }]
|
||
: []),
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="codex 模型"
|
||
hint={(
|
||
<>
|
||
审查会话使用的 codex 模型(
|
||
<code>-c model=</code>
|
||
)。留空则用 codex
|
||
{' '}
|
||
<code>config.toml</code>
|
||
{' '}
|
||
默认。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={codexModel}
|
||
onChange={e => setCodexModel(e.target.value)}
|
||
placeholder="留空用 codex 默认(如 gpt-5.6-sol)"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="codex 思考级别"
|
||
hint={(
|
||
<>
|
||
审查会话的
|
||
{' '}
|
||
<code>model_reasoning_effort</code>
|
||
;留空则用 codex 默认。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={codexReasoningEffort}
|
||
onChange={e => setCodexReasoningEffort(e.target.value)}
|
||
placeholder="留空用 codex 默认(minimal/low/medium/high/xhigh)"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
</>
|
||
)}
|
||
|
||
{activeGroup === 'network' && (
|
||
<>
|
||
<Field
|
||
label="本地端口"
|
||
error={errors.webhookPort}
|
||
hint={(
|
||
<>
|
||
扩展 HTTP server 绑定的端口。在 gitea 仓库 Webhooks 配置里把目标 URL 指向
|
||
{' '}
|
||
<code>{'http://<你的本机/公网域名>:<此端口>/webhook'}</code>
|
||
,事件需勾选:
|
||
<b>工单</b>
|
||
(issue opened/edited)、
|
||
<b>合并请求</b>
|
||
(PR opened/synchronize/closed)、
|
||
<b>工单评论</b>
|
||
(codex 审查回流)。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={65535}
|
||
value={webhookPort}
|
||
onChange={e => setWebhookPort(e.target.value)}
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="Webhook Secret"
|
||
hint={(
|
||
<>
|
||
与 gitea Webhooks 配置里的
|
||
{' '}
|
||
<b>密钥文本</b>
|
||
{' '}
|
||
保持一致,用于校验
|
||
{' '}
|
||
<code>X-Gitea-Signature</code>
|
||
。webhook 走公网隧道时必填,否则任何人都能伪造事件;留空 = 不校验。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={webhookSecret}
|
||
onChange={e => setWebhookSecret(e.target.value)}
|
||
placeholder="留空表示不校验签名"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="开启自动审查"
|
||
hint="PR 创建后自动用 codex exec review 跑审查并把结果灌到实施会话终端"
|
||
>
|
||
<label className="inline-flex cursor-pointer items-center gap-2 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
checked={autoReview}
|
||
onChange={e => setAutoReview(e.target.checked)}
|
||
/>
|
||
<span className="opacity-80">启用</span>
|
||
</label>
|
||
</Field>
|
||
|
||
<Field
|
||
label="Claude profiles 目录"
|
||
hint={(
|
||
<>
|
||
claude --settings 用的 .json 所在目录。留空自动探测(优先
|
||
{' '}
|
||
<code>~/Sources/cruldra-profile/claude-config/profiles</code>
|
||
)。支持
|
||
{' '}
|
||
<code>~</code>
|
||
。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={profilesDirectory}
|
||
onChange={e => setProfilesDirectory(e.target.value)}
|
||
placeholder="~/Sources/cruldra-profile/claude-config/profiles"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
</>
|
||
)}
|
||
|
||
{activeGroup === 'youtrack' && (
|
||
<>
|
||
<Field
|
||
label="YouTrack Base URL"
|
||
hint="例如 https://ziwuxian.youtrack.cloud。留空 = 关闭 YouTrack 源。"
|
||
>
|
||
<input
|
||
type="text"
|
||
value={youtrackBaseUrl}
|
||
onChange={e => setYoutrackBaseUrl(e.target.value)}
|
||
placeholder="https://xxx.youtrack.cloud"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="Permanent Token"
|
||
hint={(
|
||
<>
|
||
在 YouTrack「个人资料 → Account Security → New Token」生成。
|
||
{initialYoutrackTokenSaved && (
|
||
<>
|
||
<br />
|
||
已保存,留空则保留
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="password"
|
||
value={youtrackToken}
|
||
onChange={e => setYoutrackToken(e.target.value)}
|
||
placeholder={initialYoutrackTokenSaved ? '••••••••' : 'perm-xxxx…'}
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="关联项目"
|
||
hint="填 shortName(如 LXF),或点「加载项目」从下拉选择。"
|
||
>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={youtrackProjectShortName}
|
||
onChange={e => setYoutrackProjectShortName(e.target.value)}
|
||
placeholder="LXF"
|
||
className={inputClass}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => onListYouTrackProjects(youtrackBaseUrl.trim(), youtrackToken.trim())}
|
||
className="shrink-0 rounded border border-[var(--vscode-button-border,transparent)] bg-[var(--vscode-button-secondaryBackground)] px-2 py-1 text-xs text-[var(--vscode-button-secondaryForeground)] hover:opacity-90"
|
||
>
|
||
{youtrackProjects.status === 'loading' ? '加载中…' : '加载项目'}
|
||
</button>
|
||
</div>
|
||
{youtrackProjects.status === 'ready' && youtrackProjects.projects.length > 0 && (
|
||
<SelectMenu
|
||
value={youtrackProjectShortName}
|
||
onChange={setYoutrackProjectShortName}
|
||
className={`${inputClass} mt-2`}
|
||
variant="input"
|
||
options={[
|
||
{ value: '', label: '— 选择项目 —' },
|
||
...youtrackProjects.projects.map(p => ({
|
||
value: p.shortName,
|
||
label: `${p.shortName} · ${p.name}`,
|
||
})),
|
||
]}
|
||
/>
|
||
)}
|
||
{youtrackProjects.status === 'error' && (
|
||
<span className="mt-1 block text-[10px] text-state-red">
|
||
加载失败:
|
||
{youtrackProjects.error}
|
||
</span>
|
||
)}
|
||
</Field>
|
||
|
||
<Field
|
||
label="关闭命令(可选)"
|
||
hint="卡片拖到「完成」时对 YouTrack 工单执行的命令,如 State Fixed。留空 = 自动探测项目里第一个「已解决」状态。"
|
||
>
|
||
<input
|
||
type="text"
|
||
value={youtrackCloseCommand}
|
||
onChange={e => setYoutrackCloseCommand(e.target.value)}
|
||
placeholder="留空 = 自动探测"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
</>
|
||
)}
|
||
|
||
{activeGroup === 'prompts' && (
|
||
<>
|
||
<Field
|
||
label="头脑风暴提示词"
|
||
hint={(
|
||
<>
|
||
Handlebars 模板。
|
||
<code>{'{{#if issueNumber}}'}</code>
|
||
分支 = 对已有工单续聊(
|
||
<code>{'{{issueNumber}}'}</code>
|
||
,里面再用
|
||
<code>{'{{#if prototypeFile}}'}</code>
|
||
嵌原型段落、
|
||
<code>{'{{#if prototypeSessionId}}'}</code>
|
||
控制原型会话 id 那行);
|
||
<code>{'{{else}}'}</code>
|
||
分支 = 新建工单(
|
||
<code>{'{{userRequest}}'}</code>
|
||
、
|
||
<code>{'{{nonce}}'}</code>
|
||
)。工单 state JSON 的字段(
|
||
<code>{'{{planFile}}'}</code>
|
||
、
|
||
<code>{'{{branch}}'}</code>
|
||
等)都能直接用。
|
||
</>
|
||
)}
|
||
>
|
||
<textarea
|
||
value={brainstormPrompt}
|
||
onChange={e => setBrainstormPrompt(e.target.value)}
|
||
className={promptTextareaClass}
|
||
placeholder="新建工单 + 续聊 + 原型段落,一份模板按条件分叉"
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="实施提示词"
|
||
hint={(
|
||
<>
|
||
可用变量(Handlebars):
|
||
<code>{'{{planFile}}'}</code>
|
||
、
|
||
<code>{'{{issueNumber}}'}</code>
|
||
</>
|
||
)}
|
||
>
|
||
<textarea
|
||
value={implementPlanPrompt}
|
||
onChange={e => setImplementPlanPrompt(e.target.value)}
|
||
className={promptTextareaClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="审查提示词"
|
||
hint={(
|
||
<>
|
||
可用变量(Handlebars):
|
||
<code>{'{{prNumber}}'}</code>
|
||
</>
|
||
)}
|
||
>
|
||
<textarea
|
||
value={reviewPrompt}
|
||
onChange={e => setReviewPrompt(e.target.value)}
|
||
className={promptTextareaClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="测试提示词"
|
||
hint={(
|
||
<>
|
||
可用变量(Handlebars):
|
||
<code>{'{{issueNumber}}'}</code>
|
||
、
|
||
<code>{'{{prRef}}'}</code>
|
||
(worktree 里是分支,主仓库里是已合并的 PR)
|
||
</>
|
||
)}
|
||
>
|
||
<textarea
|
||
value={testPrompt}
|
||
onChange={e => setTestPrompt(e.target.value)}
|
||
className={promptTextareaClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="原型提示词"
|
||
hint={(
|
||
<>
|
||
可用变量(Handlebars):
|
||
<code>{'{{issueNumber}}'}</code>
|
||
</>
|
||
)}
|
||
>
|
||
<textarea
|
||
value={prototypePrompt}
|
||
onChange={e => setPrototypePrompt(e.target.value)}
|
||
className={promptTextareaClass}
|
||
/>
|
||
</Field>
|
||
</>
|
||
)}
|
||
|
||
{activeGroup === 'hooks' && (
|
||
<>
|
||
<Field
|
||
label="新开终端分屏"
|
||
hint="关掉后,会话终端和看板在同一组里只加标签,不再劈成左右两栏。小屏建议关。"
|
||
>
|
||
<label className="inline-flex cursor-pointer items-center gap-2 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
checked={splitNewTerminals}
|
||
onChange={e => setSplitNewTerminals(e.target.checked)}
|
||
/>
|
||
<span className="opacity-80">分到右侧一栏</span>
|
||
</label>
|
||
</Field>
|
||
<Field
|
||
label="工作树目录"
|
||
hint={(
|
||
<>
|
||
实施 Plan 时创建 worktree 的目录模板。占位符
|
||
{' '}
|
||
<code>$project_root</code>
|
||
(工作区绝对路径)、
|
||
<code>$project_name</code>
|
||
(工作区目录名)、
|
||
<code>$feature_name</code>
|
||
(spec/plan 的 slug);开头
|
||
{' '}
|
||
<code>~</code>
|
||
{' '}
|
||
展开为主目录。留空 = 默认
|
||
{' '}
|
||
<code>~/Sources/worktree/$project_name/$feature_name</code>
|
||
。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={worktreeDirectory}
|
||
onChange={e => setWorktreeDirectory(e.target.value)}
|
||
placeholder="~/Sources/worktree/$project_name/$feature_name"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="工作树打开方式"
|
||
hint={(
|
||
<>
|
||
点击工单详情里的工作树时用哪个编辑器打开。PyCharm 需
|
||
{' '}
|
||
<code>pycharm</code>
|
||
{' '}
|
||
命令在 PATH 中(JetBrains Toolbox 里启用命令行启动器)。
|
||
</>
|
||
)}
|
||
>
|
||
<SelectMenu
|
||
value={worktreeOpenWith}
|
||
onChange={v => setWorktreeOpenWith(v as 'vscode' | 'pycharm')}
|
||
className={inputClass}
|
||
variant="input"
|
||
options={[
|
||
{ value: 'vscode', label: 'VS Code' },
|
||
{ value: 'pycharm', label: 'PyCharm' },
|
||
]}
|
||
/>
|
||
</Field>
|
||
|
||
{worktreeOpenWith === 'pycharm' && (
|
||
<Field
|
||
label="PyCharm 启动命令"
|
||
hint={(
|
||
<>
|
||
可填绝对路径或
|
||
{' '}
|
||
<code>~</code>
|
||
{' '}
|
||
开头路径(如
|
||
{' '}
|
||
<code>~/.local/share/JetBrains/Toolbox/scripts/pycharm</code>
|
||
);GUI 启动的 VS Code 可能找不到裸
|
||
{' '}
|
||
<code>pycharm</code>
|
||
,填全路径最稳。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={pycharmCommand}
|
||
onChange={e => setPycharmCommand(e.target.value)}
|
||
placeholder="pycharm"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
)}
|
||
|
||
<Field
|
||
label="worktree 创建后脚本"
|
||
hint={(
|
||
<>
|
||
工单进入"实施"后,
|
||
<code>git worktree add</code>
|
||
{' '}
|
||
成功立刻执行。留空 = 默认
|
||
{' '}
|
||
<code>.spx/worktree-post-create.sh</code>
|
||
;文件不存在 = 静默跳过,不报错。
|
||
<br />
|
||
环境变量:
|
||
<code>WORKTREE_PATH</code>
|
||
、
|
||
<code>WORKSPACE_ROOT</code>
|
||
、
|
||
<code>BRANCH</code>
|
||
、
|
||
<code>ISSUE_NUMBER</code>
|
||
、
|
||
<code>MAIN_BRANCH</code>
|
||
。超时 30s。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={worktreePostCreateScript}
|
||
onChange={e => setWorktreePostCreateScript(e.target.value)}
|
||
placeholder=".spx/worktree-post-create.sh"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="worktree 删除前脚本"
|
||
hint={(
|
||
<>
|
||
工单关闭 / 手动删 worktree 时,
|
||
<code>git worktree remove</code>
|
||
{' '}
|
||
之前执行,便于关闭 IDE / 归档日志。留空 = 默认
|
||
{' '}
|
||
<code>.spx/worktree-pre-remove.sh</code>
|
||
。失败不阻塞删除。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={worktreePreRemoveScript}
|
||
onChange={e => setWorktreePreRemoveScript(e.target.value)}
|
||
placeholder=".spx/worktree-pre-remove.sh"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="实施 tab 创建前脚本"
|
||
hint={(
|
||
<>
|
||
实施 cc tab 创建之前执行;常用于启动 IDE(pycharm 等)。
|
||
留空 = 默认
|
||
{' '}
|
||
<code>.spx/impl-tab-pre-create.sh</code>
|
||
。tab 复用 fast path 不触发。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={implTabPreCreateScript}
|
||
onChange={e => setImplTabPreCreateScript(e.target.value)}
|
||
placeholder=".spx/impl-tab-pre-create.sh"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
|
||
<Field
|
||
label="实施 tab 关闭后脚本"
|
||
hint={(
|
||
<>
|
||
实施 cc tab 被关闭后执行;常用于关闭 pre-create 启动的 IDE 进程。
|
||
留空 = 默认
|
||
{' '}
|
||
<code>.spx/impl-tab-post-close.sh</code>
|
||
。
|
||
</>
|
||
)}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={implTabPostCloseScript}
|
||
onChange={e => setImplTabPostCloseScript(e.target.value)}
|
||
placeholder=".spx/impl-tab-post-close.sh"
|
||
className={inputClass}
|
||
/>
|
||
</Field>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-end gap-2 border-t border-[var(--vscode-panel-border)] p-3">
|
||
{canCancel && onCancel && (
|
||
<button
|
||
type="button"
|
||
onClick={onCancel}
|
||
className="rounded border border-[var(--vscode-button-border,transparent)] bg-[var(--vscode-button-secondaryBackground)] px-3 py-1.5 text-xs text-[var(--vscode-button-secondaryForeground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)]"
|
||
>
|
||
取消
|
||
</button>
|
||
)}
|
||
<button
|
||
type="submit"
|
||
className="rounded bg-[var(--vscode-button-background)] px-3 py-1.5 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)]"
|
||
>
|
||
保存
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|