f0a1552
ZhenYi
feat(chat): add chat module with room management, message handling, and session revocation
f0a1552
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'
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 [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('标题、源分支和目标分支都不能为空。')
return
}
if (head === base) {
setError('源分支和目标分支不能相同。')
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 : '创建失败')
setBusy(false)
}
}
return (
<Modal
title="新建合并请求"
description={`${repo} · 将源分支合入目标分支`}
open
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>取消</Button>
<Button disabled={busy} onClick={() => void submit()}>
{busy ? '创建中…' : draft ? '创建草稿' : '创建合并请求'}
</Button>
</>
}
>
<div className="grid gap-3 sm:grid-cols-2">
<label>
<span className={modalLabelClass}>源分支(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}>目标分支(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">正在比较 {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 />
可以快进合并,无冲突{status.fast_forwardable ? '' : '(将生成合并提交)'}
</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>
存在 {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}
,仍可创建 PR,但需要先解决冲突才能合并。
</span>
</p>
) : null
) : null}
<label>
<span className={modalLabelClass}>标题</span>
<input className={modalInputClass} value={title} maxLength={200} placeholder={head ? `合并 ${head} 到 ${base}` : '简短描述这次改动'} onChange={event => setTitle(event.target.value)} />
</label>
<label>
<span className={modalLabelClass}>描述(支持换行,纯文本渲染)</span>
<textarea className={modalTextareaClass} rows={6} value={body} placeholder="说明改动动机、验证方式与风险点…" 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)} />
以草稿创建(标记为 Draft,ready 后再请求评审)
</label>
{error ? <p className="text-[12.5px] text-[#b42318]">{error}</p> : null}
</Modal>
)
}