1016 lines
39 KiB
TypeScript
1016 lines
39 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 { 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 = '端口必须是 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,
|
||
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 创建之前执行;常用于启动 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>
|
||
)
|
||
}
|