951a522
import { useEffect, useState } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { Button } from '@/shared/ui'
import { listBranches } from '@/entities/repo'
import { checkMergeStatus, createPull } from '@/entities/pull-request'
import type { MergeStatus, Pull } from '@/entities/pull-request'
import { Modal, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
import { useT } from '@/shared/i18n'
export interface PullFormModalProps {
space: string
repo: string
defaultBase: string
onClose: () => void
onCreated: (pull: Pull) => void
}
/** 新建合并请求:选分支、填标题描述,并实时探测 base…head 的可合并性。 */
export function PullFormModal({ space, repo, defaultBase, onClose, onCreated }: PullFormModalProps) {
const { t } = useT()
const [branches, setBranches] = useState<string[]>([])
const [head, setHead] = useState('')
const [base, setBase] = useState(defaultBase)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [draft, setDraft] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [probe, setProbe] = useState<{ key: string; value: MergeStatus | 'failed' } | null>(null)
useEffect(() => {
let active = true
void listBranches(space, repo, 200)
.then(page => {
if (!active) return
const names = page.items.map(item => item.name)
setBranches(names)
setHead(prev => prev || names.find(name => name !== defaultBase) || names[0] || '')
})
.catch(() => undefined)
return () => { active = false }
}, [space, repo, defaultBase])
const pairKey = head && base && head !== base ? `${space}/${repo}:${base}...${head}` : ''
// 'pending' is derived (pair selected, no result yet) so the effect only
// writes state when the probe settles.
const status: MergeStatus | 'pending' | null = !pairKey
? null
: probe?.key === pairKey
? probe.value === 'failed' ? null : probe.value
: 'pending'
useEffect(() => {
if (!pairKey) return
let active = true
const timer = setTimeout(() => {
void checkMergeStatus(space, repo, base, head)
.then(next => { if (active) setProbe({ key: pairKey, value: next }) })
.catch(() => { if (active) setProbe({ key: pairKey, value: 'failed' }) })
}, 400)
return () => {
active = false
clearTimeout(timer)
}
}, [space, repo, base, head, pairKey])
async function submit() {
if (!title.trim() || !head || !base) {
setError(t('pull.form.missingFields'))
return
}
if (head === base) {
setError(t('pull.form.sameBranch'))
return
}
setBusy(true)
setError(null)
try {
const pull = await createPull(space, repo, {
title: title.trim(),
head_branch: head,
base_branch: base,
body: body || null,
draft,
})
onCreated(pull)
} catch (failure) {
setError(failure instanceof Error ? failure.message : t('pull.form.createFailed'))
setBusy(false)
}
}
return (
<Modal
title={t('pull.form.title')}
description={t('pull.form.description', { repo })}
open
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>{t('common.action.cancel')}</Button>
<Button disabled={busy} onClick={() => void submit()}>
{busy ? t('pull.form.creating') : draft ? t('pull.form.createDraft') : t('pull.form.create')}
</Button>
</>
}
>
<div className="grid gap-3 sm:grid-cols-2">
<label>
<span className={modalLabelClass}>{t('pull.form.head')}</span>
<select className={modalInputClass} value={head} onChange={event => setHead(event.target.value)}>
{branches.map(name => <option key={name} value={name}>{name}</option>)}
</select>
</label>
<label>
<span className={modalLabelClass}>{t('pull.form.base')}</span>
<select className={modalInputClass} value={base} onChange={event => setBase(event.target.value)}>
{branches.map(name => <option key={name} value={name}>{name}</option>)}
</select>
</label>
</div>
{head && base && head !== base ? (
status === 'pending' ? (
<p className="text-[12.5px] text-shell-ink-muted">{t('pull.form.comparing', { base, head })}</p>
) : status?.mergeable ? (
<p className="flex items-center gap-1.5 text-[12.5px] text-[#1a7f37]">
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden />
{t('pull.form.fastForward')}{status.fast_forwardable ? '' : t('pull.form.willMergeCommit')}
</p>
) : status ? (
<p className="flex items-start gap-1.5 text-[12.5px] text-[#b42318]">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
<span>
{t('pull.form.conflicts', { count: status.conflicts.length })}
{status.conflicts.length ? <span className="block break-all font-mono text-[11px]">{status.conflicts.slice(0, 5).join(', ')}{status.conflicts.length > 5 ? ' …' : ''}</span> : null}
{t('pull.form.conflictsHint')}
</span>
</p>
) : null
) : null}
<label>
<span className={modalLabelClass}>{t('pull.title')}</span>
<input className={modalInputClass} value={title} maxLength={200} placeholder={head ? t('pull.form.titlePlaceholder', { head, base }) : t('pull.form.titlePlaceholderFallback')} onChange={event => setTitle(event.target.value)} />
</label>
<label>
<span className={modalLabelClass}>{t('pull.form.descriptionField')}</span>
<textarea className={modalTextareaClass} rows={6} value={body} placeholder={t('pull.form.bodyPlaceholder')} onChange={event => setBody(event.target.value)} />
</label>
<label className="flex items-center gap-2 text-[13px]">
<input type="checkbox" checked={draft} onChange={event => setDraft(event.target.checked)} />
{t('pull.form.draftCheckbox')}
</label>
{error ? <p className="text-[12.5px] text-[#b42318]">{error}</p> : null}
</Modal>
)
}