This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
@@ -0,0 +1,733 @@
/**
* 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'
type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks'
interface SubmitValues {
host: string
token: string
webhookPort: number
brainstormPrompt: string
implementPlanPrompt: string
autoReview: boolean
reviewPrompt: string
devBranch: string
autoBuildBranch: string
worktreePostCreateScript: string
worktreePreRemoveScript: string
implTabPreCreateScript: string
implTabPostCloseScript: 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
initialImplementPlanPrompt: string
initialAutoReview: boolean
initialReviewPrompt: string
initialDevBranch: string
initialAutoBuildBranch: string
initialWorktreePostCreateScript: string
initialWorktreePreRemoveScript: string
initialImplTabPreCreateScript: string
initialImplTabPostCloseScript: string
initialYoutrackBaseUrl: string
initialYoutrackProjectShortName: string
initialYoutrackCloseCommand: string
initialYoutrackTokenSaved: boolean
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,
initialImplementPlanPrompt,
initialAutoReview,
initialReviewPrompt,
initialDevBranch,
initialAutoBuildBranch,
initialWorktreePostCreateScript,
initialWorktreePreRemoveScript,
initialImplTabPreCreateScript,
initialImplTabPostCloseScript,
initialYoutrackBaseUrl,
initialYoutrackProjectShortName,
initialYoutrackCloseCommand,
initialYoutrackTokenSaved,
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 [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 [worktreePostCreateScript, setWorktreePostCreateScript] = useState(initialWorktreePostCreateScript)
const [worktreePreRemoveScript, setWorktreePreRemoveScript] = useState(initialWorktreePreRemoveScript)
const [implTabPreCreateScript, setImplTabPreCreateScript] = useState(initialImplTabPreCreateScript)
const [implTabPostCloseScript, setImplTabPostCloseScript] = useState(initialImplTabPostCloseScript)
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)
setImplementPlanPrompt(initialImplementPlanPrompt)
setAutoReview(initialAutoReview)
setReviewPrompt(initialReviewPrompt)
setDevBranch(initialDevBranch)
setAutoBuildBranch(initialAutoBuildBranch)
setWorktreePostCreateScript(initialWorktreePostCreateScript)
setWorktreePreRemoveScript(initialWorktreePreRemoveScript)
setImplTabPreCreateScript(initialImplTabPreCreateScript)
setImplTabPostCloseScript(initialImplTabPostCloseScript)
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,
implementPlanPrompt,
autoReview,
reviewPrompt,
devBranch: devBranch.trim(),
autoBuildBranch: autoBuildBranch.trim(),
// 钩子路径保持原样 trim(前后空格无意义;空串 = 用默认 .spx/*.sh
worktreePostCreateScript: worktreePostCreateScript.trim(),
worktreePreRemoveScript: worktreePreRemoveScript.trim(),
implTabPreCreateScript: implTabPreCreateScript.trim(),
implTabPostCloseScript: implTabPostCloseScript.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>
</>
)}
{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>
</>
)}
{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>{'{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="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>
)
}