Files
superwork/vscode/webview-ui/src/components/SettingsModal.tsx
T

1016 lines
39 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.
/**
* 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 { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import type { ClaudeProfile } from '../hooks/useIssues'
type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks'
interface SubmitValues {
host: string
token: string
webhookPort: number
brainstormPrompt: string
brainstormContinuePrompt: string
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
devBranch: string
autoBuildBranch: string
worktreeDirectory: string
worktreeOpenWith: 'vscode' | 'pycharm'
pycharmCommand: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
implTabPostCloseScript: string
conflictResolutionProfilePath: string
commitProfilePath: 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
initialBrainstormPrompt: string
initialBrainstormContinuePrompt: string
initialImplementPlanPrompt: string
initialAutoReview: boolean
initialReviewPrompt: string
initialDevBranch: string
initialAutoBuildBranch: string
initialWorktreeDirectory: string
initialWorktreeOpenWith: 'vscode' | 'pycharm'
initialPycharmCommand: string
initialWorktreePostCreateScript: string
initialWorktreePreRemoveScript: string
initialImplTabPreCreateScript: string
initialImplTabPostCloseScript: string
initialConflictResolutionProfilePath: string
initialCommitProfilePath: 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)]'
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,
initialBrainstormPrompt,
initialBrainstormContinuePrompt,
initialImplementPlanPrompt,
initialAutoReview,
initialReviewPrompt,
initialDevBranch,
initialAutoBuildBranch,
initialWorktreeDirectory,
initialWorktreeOpenWith,
initialPycharmCommand,
initialWorktreePostCreateScript,
initialWorktreePreRemoveScript,
initialImplTabPreCreateScript,
initialImplTabPostCloseScript,
initialConflictResolutionProfilePath,
initialCommitProfilePath,
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 [brainstormPrompt, setBrainstormPrompt] = useState(initialBrainstormPrompt)
const [brainstormContinuePrompt, setBrainstormContinuePrompt] = useState(initialBrainstormContinuePrompt)
const [implementPlanPrompt, setImplementPlanPrompt] = useState(initialImplementPlanPrompt)
const [autoReview, setAutoReview] = useState(initialAutoReview)
const [reviewPrompt, setReviewPrompt] = useState(initialReviewPrompt)
const [devBranch, setDevBranch] = useState(initialDevBranch)
const [autoBuildBranch, setAutoBuildBranch] = useState(initialAutoBuildBranch)
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 [conflictResolutionProfilePath, setConflictResolutionProfilePath] = useState(initialConflictResolutionProfilePath)
const [commitProfilePath, setCommitProfilePath] = useState(initialCommitProfilePath)
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))
setBrainstormPrompt(initialBrainstormPrompt)
setBrainstormContinuePrompt(initialBrainstormContinuePrompt)
setImplementPlanPrompt(initialImplementPlanPrompt)
setAutoReview(initialAutoReview)
setReviewPrompt(initialReviewPrompt)
setDevBranch(initialDevBranch)
setAutoBuildBranch(initialAutoBuildBranch)
setWorktreeDirectory(initialWorktreeDirectory)
setWorktreeOpenWith(initialWorktreeOpenWith)
setPycharmCommand(initialPycharmCommand)
setWorktreePostCreateScript(initialWorktreePostCreateScript)
setWorktreePreRemoveScript(initialWorktreePreRemoveScript)
setImplTabPreCreateScript(initialImplTabPreCreateScript)
setImplTabPostCloseScript(initialImplTabPostCloseScript)
setConflictResolutionProfilePath(initialConflictResolutionProfilePath)
setCommitProfilePath(initialCommitProfilePath)
setCodexModel(initialCodexModel)
setCodexReasoningEffort(initialCodexReasoningEffort)
setProfilesDirectory(initialProfilesDirectory)
setYoutrackBaseUrl(initialYoutrackBaseUrl)
setYoutrackProjectShortName(initialYoutrackProjectShortName)
setYoutrackCloseCommand(initialYoutrackCloseCommand)
setYoutrackToken('')
setErrors({})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 = '端口必须是 165535 的整数'
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,
brainstormPrompt,
brainstormContinuePrompt,
implementPlanPrompt,
autoReview,
reviewPrompt,
devBranch: devBranch.trim(),
autoBuildBranch: autoBuildBranch.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(),
// 冲突解决 profile:空串 = 用 DEFAULT_PROFILE_PATH
conflictResolutionProfilePath: conflictResolutionProfilePath.trim(),
// 提交代码 profile:空串 = 未配置 → 提交严格失败
commitProfilePath: commitProfilePath.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={(
<>
gitea webhook Jenkins 监听的分支。留空表示与开发分支相同,同步按钮会一直禁用。
</>
)}
>
<input
type="text"
value={autoBuildBranch}
onChange={e => setAutoBuildBranch(e.target.value)}
placeholder="留空 = 与开发分支相同"
className={inputClass}
/>
</Field>
<Field
label="冲突解决配置文件"
hint={(
<>
拖到「完成」时若 PR main/dev 冲突,自动开的冲突解决会话使用此 profile;留空用默认 official
</>
)}
>
<select
value={conflictResolutionProfilePath}
onChange={e => setConflictResolutionProfilePath(e.target.value)}
className={inputClass}
>
<option value="">(默认)</option>
{profiles.map(p => (
<option key={p.path} value={p.path}>{p.name}</option>
))}
{conflictResolutionProfilePath
&& !profiles.some(p => p.path === conflictResolutionProfilePath) && (
<option value={conflictResolutionProfilePath}>
{`自定义:${conflictResolutionProfilePath}`}
</option>
)}
</select>
</Field>
<Field
label="提交代码 profile"
hint={(
<>
顶部工具栏「提交代码」使用此 profile。留空则提交失败,不会回退默认。
</>
)}
>
<select
value={commitProfilePath}
onChange={e => setCommitProfilePath(e.target.value)}
className={inputClass}
>
<option value="">(未配置)</option>
{profiles.map(p => (
<option key={p.path} value={p.path}>{p.name}</option>
))}
{commitProfilePath
&& !profiles.some(p => p.path === commitProfilePath) && (
<option value={commitProfilePath}>
{`自定义:${commitProfilePath}`}
</option>
)}
</select>
</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="开启自动审查"
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 && (
<select
value={youtrackProjectShortName}
onChange={e => setYoutrackProjectShortName(e.target.value)}
className={`${inputClass} mt-2`}
>
<option value=""> 选择项目 </option>
{youtrackProjects.projects.map(p => (
<option key={p.id} value={p.shortName}>
{p.shortName}
{' · '}
{p.name}
</option>
))}
</select>
)}
{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={(
<>
可用占位符:
<code>{'{userRequest}'}</code>
<code>{'{nonce}'}</code>
。同时承担:建工单初始指令 + 会话内 spec/plan 路径写回 issue body 的持续约定。
</>
)}
>
<textarea
rows={12}
value={brainstormPrompt}
onChange={e => setBrainstormPrompt(e.target.value)}
className={`${inputClass} resize-y font-mono`}
placeholder="头脑风暴 + 建工单 + 持续约定提示词"
/>
</Field>
<Field
label="头脑风暴续聊提示词"
hint={(
<>
可用占位符
{' '}
<code>{'{issueNumber}'}</code>
;用于对已有工单继续讨论 / spec/plan 的会话初始指令。
</>
)}
>
<textarea
rows={8}
value={brainstormContinuePrompt}
onChange={e => setBrainstormContinuePrompt(e.target.value)}
className={`${inputClass} resize-y font-mono`}
/>
</Field>
<Field
label="实施提示词"
hint={(
<>
可用占位符:
<code>{'{planFile}'}</code>
<code>{'{issueNumber}'}</code>
</>
)}
>
<textarea
rows={8}
value={implementPlanPrompt}
onChange={e => setImplementPlanPrompt(e.target.value)}
className={`${inputClass} resize-y font-mono`}
/>
</Field>
<Field
label="审查提示词"
hint={<>可用占位符:<code>{'{prNumber}'}</code></>}
>
<textarea
rows={4}
value={reviewPrompt}
onChange={e => setReviewPrompt(e.target.value)}
className={`${inputClass} resize-y font-mono`}
/>
</Field>
</>
)}
{activeGroup === 'hooks' && (
<>
<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 里启用命令行启动器)。
</>
)}
>
<select
value={worktreeOpenWith}
onChange={e => setWorktreeOpenWith(e.target.value as 'vscode' | 'pycharm')}
className={inputClass}
>
<option value="vscode">VS Code</option>
<option value="pycharm">PyCharm</option>
</select>
</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 创建之前执行;常用于启动 IDEpycharm 等)。
留空 = 默认
{' '}
<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>
)
}