759ae5e
import { useState } from 'react'
import {
Download,
FileCode2,
GitMerge,
ListChecks,
Loader2,
MessagesSquare,
Pencil,
RotateCcw,
ShieldCheck,
Unlock,
X,
} from 'lucide-react'
import { Alert, Button, ErrorPanel, Modal, PanelNote, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui'
import { fmtRelative, fmtTime, repoKey, useAsync } from '@/shared/lib'
import { t as tr, useT } from '@/shared/i18n'
import { listSpaceMembers } from '@/entities/issue'
import { fetchConflictDetail } from '@/entities/repo'
import {
addAssignee,
attachLabel,
closePull,
deletePull,
detachLabel,
getPull,
getPullGate,
listAssignees,
listPullLabelOptions,
listPullLabels,
listRequestedReviewers,
lockPull,
mergePull,
pullPatchUrl,
refreshPullSummary,
removeAssignee,
removeRequestedReviewer,
reopenPull,
requestReviewers,
unlockPull,
updateBranchPull,
updatePull,
} from '@/entities/pull-request'
import type { MessageKey } from '@/shared/i18n'
import type { MergeMethod, Pull, PullGate, PullRef, ReviewerState } from '@/entities/pull-request'
import { MERGE_METHOD_LABEL, MERGE_METHODS, REVIEWER_STATE_LABEL, TONE, userName } from '../model/meta'
import { Avatar, MergeableChip, PullStateBadge, SearchSelect, SideSection } from './parts'
import { Conversation } from './conversation'
import { FilesTab } from './files-tab'
import { CommitsTab } from './commits-tab'
import { ChecksTab } from './checks-tab'
export type PullSubTab = 'conversation' | 'files' | 'commits' | 'checks'
export interface PullDetailProps {
space: string
repo: string
pullRef: string
/** Repo write access (space owner / repo collaborator server-side). */
canWrite: boolean
ptab: PullSubTab
onPtabChange: (next: PullSubTab) => void
onBackToList: () => void
onOpenCommit: (sha: string) => void
}
const SUB_TABS: Array<{ id: PullSubTab; icon: typeof MessagesSquare }> = [
{ id: 'conversation', icon: MessagesSquare },
{ id: 'files', icon: FileCode2 },
{ id: 'commits', icon: GitMerge },
{ id: 'checks', icon: ListChecks },
]
const SUB_TAB_KEYS: Record<PullSubTab, MessageKey> = {
conversation: 'pull.detail.tab.conversation',
files: 'pull.tabs.files',
commits: 'pull.tabs.commits',
checks: 'pull.detail.tab.checks',
}
/** PR 详情页:头部/操作 + 子页签 + 侧栏。 */
export function PullDetail({ space, repo, pullRef, canWrite, ptab, onPtabChange, onBackToList, onOpenCommit }: PullDetailProps) {
const { t } = useT()
const [tick, setTick] = useState(0)
const [focusFile, setFocusFile] = useState('')
/** 从文件变更跳转过来时要高亮的行号(0 表示不高亮)。 */
const [focusLine] = useState(0)
const [notice, setNotice] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const refObj: PullRef = { space, repo, pull: pullRef }
const pullState = useAsync(() => getPull(refObj), [space, repo, pullRef, tick], {
cacheKey: repoKey(space, repo, `pull:${pullRef}:${tick}`),
})
const gateState = useAsync(() => getPullGate(refObj), [space, repo, pullRef, tick])
const pull = pullState.data
const gate = gateState.data
const failure = pullState.error as { code?: number; message?: string } | undefined
const refresh = () => setTick(t => t + 1)
async function run(label: string, fn: () => Promise<unknown>) {
setNotice(null)
setError(null)
try {
await fn()
setNotice(t('pull.detail.complete', { label }))
refresh()
} catch (f) {
setError(t('pull.detail.actionFailed', { label, reason: f instanceof Error ? f.message : t('common.state.unknown') }))
}
}
if (pullState.loading && !pull) return <PanelNote text={t('pull.detail.loading')} />
if (!pull) {
return failure ? (
<ErrorPanel status={failure.code ?? 0} what={t('pull.detail.errorPull')} note={failure.message} onRetry={refresh} />
) : (
<PanelNote text={t('pull.detail.notFound')} />
)
}
return (
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2 text-[12.5px] text-shell-ink-muted">
<button type="button" onClick={onBackToList} className="hover:underline">{t('pull.detail.crumb')}</button>
<span aria-hidden>/</span>
<span className="font-mono">#{pull.number}</span>
</div>
<div className="mb-2 flex flex-wrap items-start gap-3">
<h2 className="min-w-0 flex-1 text-[20px] font-semibold leading-7 break-words">{pull.title}</h2>
<PullStateBadge pull={pull} />
</div>
<p className="mb-3 text-[12.5px] text-shell-ink-muted">
<span className="font-medium text-shell-ink">{userName(pull.author)}</span>{' '}
<code className="rounded bg-shell-active/70 px-1 py-0.5 font-mono text-[11.5px]">{t('pull.detail.requestsMerge', { head: pull.head.branch, base: pull.base.branch })}</code>
<span className="mx-1.5">·</span>{t('pull.detail.updatedAt', { time: fmtRelative(pull.updated_at ?? pull.created_at) }) }
<MergeableChip state={pull.mergeable_state} />
{pull.locked ? <span className={`ml-2 rounded px-1.5 py-0.5 text-[11.5px] ${TONE.amber}`}>{t('pull.detail.locked')}</span> : null}
{pull.auto_merge ? <span className={`ml-2 rounded px-1.5 py-0.5 text-[11.5px] ${TONE.green}`}>{t('pull.detail.autoMergeOn')}</span> : null}
</p>
{notice ? <Alert tone="success">{notice}</Alert> : null}
{error ? <Alert tone="error">{error}</Alert> : null}
<div className="mb-3 flex flex-wrap items-center gap-1.5 border-b border-shell-hairline pb-3">
{SUB_TABS.map(tab => {
const Icon = tab.icon
return (
<button
key={tab.id}
type="button"
aria-current={ptab === tab.id ? 'page' : undefined}
onClick={() => onPtabChange(tab.id)}
className={`inline-flex items-center gap-1 rounded-md px-2.5 py-1.5 text-[13px] transition-colors ${
ptab === tab.id ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
}`}
>
<Icon className="h-3.5 w-3.5" aria-hidden />{t(SUB_TAB_KEYS[tab.id])}
{tab.id === 'files' && pull.files_changed ? ` (${pull.files_changed})` : ''}
{tab.id === 'commits' && pull.commits_ahead ? ` (${pull.commits_ahead})` : ''}
</button>
)
})}
</div>
{ptab === 'conversation' ? (
<Conversation
refObj={refObj}
pull={pull}
onChanged={refresh}
beforeComposer={pull.state === 'open' && canWrite ? (
<MergeBox
refObj={refObj}
pull={pull}
gate={gate}
analyzing={gateState.loading && !gateState.data}
onChanged={refresh}
onError={setError}
/>
) : null}
onOpenFile={path => {
setFocusFile(path)
onPtabChange('files')
}}
/>
) : null}
{ptab === 'files' ? (
<FilesTab key={`${pull.head.sha}:${focusFile}:${focusLine}`} refObj={refObj} headSha={pull.head.sha} focusPath={focusFile} focusLine={focusLine} onChanged={refresh} />
) : null}
{ptab === 'commits' ? <CommitsTab refObj={refObj} onOpenCommit={onOpenCommit} /> : null}
{ptab === 'checks' ? <ChecksTab refObj={refObj} headSha={pull.head.sha} /> : null}
</div>
<div className="w-full shrink-0 space-y-3 lg:w-[300px]">
{canWrite ? <SideSection title={t('pull.detail.actions')}>
<div className="flex flex-wrap gap-1.5">
{canWrite && pull.state === 'open' ? (
<Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run(t('pull.detail.close'), () => closePull(refObj))}>
<X className="h-3 w-3" aria-hidden />{t('pull.detail.close')}
</Button>
) : canWrite && pull.state === 'closed' ? (
<Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run(t('pull.detail.reopen'), () => reopenPull(refObj))}>
<RotateCcw className="h-3 w-3" aria-hidden />{t('pull.detail.reopen')}
</Button>
) : null}
{canWrite ? <EditButton refObj={refObj} pull={pull} onChanged={refresh} onError={setError} /> : null}
{canWrite ? (
<Button
variant="secondary"
className="h-7 gap-1 text-[12px]"
onClick={() => void run(pull.locked ? t('pull.detail.unlock') : t('pull.detail.lock'), () => (pull.locked ? unlockPull(refObj) : lockPull(refObj)))}
>
{pull.locked ? <Unlock className="h-3 w-3" aria-hidden /> : <ShieldCheck className="h-3 w-3" aria-hidden />}
{pull.locked ? t('pull.detail.unlockDiscussion') : t('pull.detail.lockDiscussion')}
</Button>
) : null}
{canWrite ? (
<Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => void run(t('pull.detail.refreshSummary'), () => refreshPullSummary(refObj))}>
<RotateCcw className="h-3 w-3" aria-hidden />{t('pull.detail.refreshSummary')}
</Button>
) : null}
<Button
variant="secondary"
className="h-7 gap-1 text-[12px]"
onClick={() => window.open(pullPatchUrl(refObj), '_blank', 'noopener')}
>
<Download className="h-3 w-3" aria-hidden />{t('pull.detail.patch')}
</Button>
{canWrite ? (
<Button
variant="ghost"
className="h-7 text-[12px] text-[#b42318]"
onClick={() => {
if (!window.confirm(t('pull.detail.deleteConfirm', { number: pull.number }))) return
void (async () => {
try {
await deletePull(refObj)
onBackToList()
} catch (f) {
setError(t('pull.detail.deleteFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
}
})()
}}
>
{t('common.action.delete')}
</Button>
) : null}
</div>
</SideSection> : null}
<ReviewersPanel refObj={refObj} pull={pull} tick={tick} onChanged={refresh} onError={setError} />
<AssigneesPanel refObj={refObj} pull={pull} tick={tick} onChanged={refresh} onError={setError} />
<LabelsPanel refObj={refObj} tick={tick} onChanged={refresh} onError={setError} />
<SideSection title={t('pull.detail.stats')}>
<dl className="space-y-1 text-[12.5px] text-shell-ink-muted">
<div className="flex justify-between"><dt>{t('pull.detail.aheadBehind')}</dt><dd className="font-mono text-shell-ink">{pull.commits_ahead ?? '—'} / {pull.commits_behind ?? '—'}</dd></div>
<div className="flex justify-between"><dt>{t('pull.detail.filesChanged')}</dt><dd className="font-mono text-shell-ink">{pull.files_changed}</dd></div>
<div className="flex justify-between">
<dt>{t('pull.detail.lines')}</dt>
<dd className="font-mono"><span className="text-[#1a7f37]">+{pull.additions}</span> <span className="text-[#b42318]">−{pull.deletions}</span></dd>
</div>
<div className="flex justify-between"><dt>{t('pull.detail.created')}</dt><dd title={fmtTime(pull.created_at)}>{fmtRelative(pull.created_at)}</dd></div>
{pull.merged_at ? <div className="flex justify-between"><dt>{t('pull.detail.mergedAt')}</dt><dd>{fmtRelative(pull.merged_at)}</dd></div> : null}
{pull.merged_commit_sha ? <div className="flex justify-between"><dt>{t('pull.detail.mergeCommit')}</dt><dd className="font-mono">{pull.merged_commit_sha.slice(0, 10)}</dd></div> : null}
</dl>
</SideSection>
</div>
</div>
)
}
/* ------------------------------------------------------------ merge box */
function MergeBox({ refObj, pull, gate, analyzing, onChanged, onError }: {
refObj: PullRef
pull: Pull
gate: PullGate | null | undefined
/** 合并冲突分析尚未完成:先显示“分析中”,不提前给出可合并结论。 */
analyzing: boolean
onChanged: () => void
onError: (message: string) => void
}) {
const { t } = useT()
const [method, setMethod] = useState<MergeMethod>(pull.merge_method || 'merge')
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
const [deleteAfter, setDeleteAfter] = useState(false)
const [busy, setBusy] = useState(false)
const g = gate?.gate
const conflicts = gate?.conflicts ?? pull.conflicts ?? []
const blocked = !g?.allowed && g !== null && g !== undefined
const stale = (pull.commits_behind ?? 0) > 0
async function merge() {
setBusy(true)
try {
const outcome = await mergePull(refObj, {
method,
title: title.trim() || null,
message: message.trim() || null,
delete_branch_after_merge: deleteAfter,
expect_head_sha: pull.head.sha,
})
onChanged()
window.alert(t('pull.detail.mergeSucceeded', { sha: outcome.result.new_sha.slice(0, 10) }))
} catch (f) {
onError(t('pull.detail.mergeFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
} finally {
setBusy(false)
}
}
return (
<section className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
<h4 className="mb-2 text-[13px] font-semibold text-shell-ink">{t('pull.detail.merge')}</h4>
{analyzing ? (
<p className="flex items-center gap-1.5 text-[12.5px] text-shell-ink-muted">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{t('pull.detail.analyzingMerge')}
</p>
) : pull.is_draft ? (
<p className="text-[12.5px] text-[#8a6100]">{t('pull.detail.draftMergeHint')}</p>
) : conflicts.length ? (
<div className="text-[12.5px] text-[#b42318]">
{t('pull.detail.conflicts', { count: conflicts.length })}
<ul className="mt-1 max-h-32 space-y-0.5 overflow-y-auto">
{conflicts.map(path => (
<li key={path}>
<ConflictFileButton space={refObj.space} repo={refObj.repo} base={pull.base.branch} head={pull.head.branch} path={path} />
</li>
))}
</ul>
</div>
) : g && !g.allowed ? (
<p className="text-[12.5px] text-[#8a6100]">
{t('pull.detail.blockedByRules', { reasons: g.reasons.join('; ') || t('pull.detail.needsApprovals', { required: g.required_approvals, current: g.approvals }) })}
{g.bypassed ? t('pull.detail.canBypass') : ''}
</p>
) : stale ? (
<p className="text-[12.5px] text-[#8a6100]">{t('pull.detail.staleBranch', { count: pull.commits_behind ?? 0 })}</p>
) : (
<p className="text-[12.5px] text-[#1a7f37]">{t('pull.detail.readyToMerge')}{g ? t('pull.detail.approvals', { current: g.approvals, required: g.required_approvals }) : ''}</p>
)}
{g?.outdated && stale ? (
<Button
variant="secondary"
className="mt-2 h-7 w-full text-[12px]"
disabled={busy}
onClick={() => {
void (async () => {
try {
await updateBranchPull(refObj)
onChanged()
} catch (f) {
onError(t('pull.detail.syncFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
}
})()
}}
>
{t('pull.detail.syncBranch')}
</Button>
) : null}
<div className="mt-2 space-y-2">
<label className="block">
<span className={modalLabelClass}>{t('pull.mergeMethod')}</span>
<select className={modalInputClass} value={method} onChange={event => setMethod(event.target.value as MergeMethod)}>
{MERGE_METHODS.map(value => (
<option key={value} value={value}>{MERGE_METHOD_LABEL[value]}</option>
))}
</select>
</label>
<input className={modalInputClass} placeholder={t('pull.detail.mergeTitlePlaceholder')} value={title} onChange={event => setTitle(event.target.value)} />
<textarea className={modalTextareaClass} rows={2} placeholder={t('pull.detail.mergeMessagePlaceholder')} value={message} onChange={event => setMessage(event.target.value)} />
<label className="flex items-center gap-2 text-[12.5px]">
<input type="checkbox" checked={deleteAfter} onChange={event => setDeleteAfter(event.target.checked)} />
{t('pull.detail.deleteBranchAfter')}
</label>
<Button
className="h-8 w-full gap-1 text-[13px]"
disabled={busy || analyzing || pull.is_draft || conflicts.length > 0 || blocked || stale}
onClick={() => void merge()}
>
<GitMerge className="h-3.5 w-3.5" aria-hidden />{busy ? t('pull.detail.merging') : t('pull.detail.merge')}
</Button>
<label className="flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
<input
type="checkbox"
checked={pull.auto_merge}
onChange={event => {
void (async () => {
try {
await updatePull(refObj, { auto_merge: event.target.checked })
onChanged()
} catch (f) {
onError(t('pull.detail.autoMergeFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
}
})()
}}
/>
{t('pull.detail.autoMerge')}
</label>
</div>
</section>
)
}
function ConflictFileButton({ space, repo, base, head, path }: { space: string; repo: string; base: string; head: string; path: string }) {
const [detail, setDetail] = useState<string | null | 'loading'>(null)
return (
<>
<button
type="button"
className="block max-w-full truncate text-left font-mono text-[12px] underline underline-offset-2 hover:brightness-125"
title={tr('pull.detail.viewConflict')}
onClick={() => {
setDetail('loading')
void fetchConflictDetail(space, repo, base, head, path)
.then(d => setDetail(d.content ?? tr('pull.detail.noConflictText')))
.catch(() => setDetail(null))
}}
>
{path}
</button>
{detail === null ? null : (
<Modal title={tr('pull.detail.conflictContent', { path })} open width="max-w-[760px]" onClose={() => setDetail(null)}
footer={<Button variant="ghost" onClick={() => setDetail(null)}>{tr('common.action.close')}</Button>}
>
{detail === 'loading' ? (
<p className="text-[12.5px] text-shell-ink-muted">{tr('pull.detail.loadingConflict')}</p>
) : (
<pre className="max-h-[60vh] overflow-auto rounded-lg border border-shell-hairline bg-shell-field p-3 font-mono text-[11.5px] leading-5">{detail}</pre>
)}
</Modal>
)}
</>
)
}
/* ---------------------------------------------------------- edit dialog */
function EditButton({ refObj, pull, onChanged, onError }: { refObj: PullRef; pull: Pull; onChanged: () => void; onError: (m: string) => void }) {
const { t } = useT()
const [open, setOpen] = useState(false)
const [title, setTitle] = useState(pull.title)
const [body, setBody] = useState(pull.body)
const [draft, setDraft] = useState(pull.is_draft)
const [busy, setBusy] = useState(false)
async function save() {
setBusy(true)
try {
await updatePull(refObj, { title: title.trim() || undefined, body, draft })
setOpen(false)
onChanged()
} catch (f) {
onError(t('pull.detail.updateFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
} finally {
setBusy(false)
}
}
return (
<>
<Button variant="secondary" className="h-7 gap-1 text-[12px]" onClick={() => { setTitle(pull.title); setBody(pull.body); setDraft(pull.is_draft); setOpen(true) }}>
<Pencil className="h-3 w-3" aria-hidden />{t('issue.board.edit')}
</Button>
{open ? (
<Modal
title={t('pull.detail.editNumber', { number: pull.number })}
open
onClose={() => setOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setOpen(false)}>{t('common.action.cancel')}</Button>
<Button disabled={busy} onClick={() => void save()}>{t('common.action.save')}</Button>
</>
}
>
<label className="block">
<span className={modalLabelClass}>{t('issue.board.form.title')}</span>
<input className={modalInputClass} value={title} onChange={event => setTitle(event.target.value)} />
</label>
<label className="block">
<span className={modalLabelClass}>{t('pull.description')}</span>
<textarea className={modalTextareaClass} rows={7} value={body} 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.detail.draftCheckbox')}
</label>
</Modal>
) : null}
</>
)
}
/* ------------------------------------------------------------ side panels */
function ReviewersPanel({ refObj, pull, tick, onChanged, onError }: { refObj: PullRef; pull: Pull; tick: number; onChanged: () => void; onError: (m: string) => void }) {
const { t } = useT()
const [required, setRequired] = useState(true)
const reviewers = useAsync(() => listRequestedReviewers(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
const rows = reviewers.data ?? []
const members = useAsync(() => listSpaceMembers(refObj.space), [refObj.space])
const memberOptions = (members.data?.items ?? [])
.map(row => row.user)
.filter((user): user is NonNullable<typeof user> => Boolean(user))
.filter(user => !rows.some(reviewer => reviewer.user?.username === user.username))
.map(user => ({
value: user.username,
label: user.display_name || user.username,
hint: `@${user.username}`,
icon: <Avatar user={user} size={16} />,
}))
async function add(name: string) {
try {
await requestReviewers(refObj, [name], required)
onChanged()
} catch (f) {
onError(t('pull.detail.requestReviewFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
throw f
}
}
return (
<SideSection
title={`${t('pull.detail.reviewers')}${rows.length ? ` (${rows.length})` : ''}`}
action={
<label className="flex items-center gap-1 text-[11.5px] text-shell-ink-muted">
<input type="checkbox" checked={required} onChange={event => setRequired(event.target.checked)} />{t('pull.detail.required')}
</label>
}
>
{rows.length ? (
<ul className="space-y-1.5">
{rows.map(row => (
<li key={row.id} className="flex items-center gap-2 text-[13px]">
<Avatar user={row.user} size={20} />
<span className="min-w-0 flex-1 truncate">{userName(row.user)}</span>
<span className="text-[11.5px] text-shell-ink-muted">{reviewerStateLabel(row.state)}</span>
{row.state === 'requested' && pull.state === 'open' ? (
row.user ? (
<button
type="button"
aria-label={t('pull.detail.remove', { name: userName(row.user) })}
className="text-shell-ink-muted hover:text-[#b42318]"
onClick={() => void removeRequestedReviewer(refObj, row.user?.user_id ?? 0).then(onChanged).catch(() => undefined)}
>
×
</button>
) : null
) : null}
</li>
))}
</ul>
) : (
<p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noReviewers')}</p>
)}
{pull.state === 'open' ? (
<SearchSelect
placeholder={t('pull.detail.reviewRequestPlaceholder')}
options={memberOptions}
emptyText={t('pull.detail.pickNoMatch')}
onSelect={value => void add(value).catch(() => undefined)}
/>
) : null}
</SideSection>
)
}
function reviewerStateLabel(state: ReviewerState | string): string {
return REVIEWER_STATE_LABEL[state as ReviewerState] ?? state
}
function AssigneesPanel({ refObj, pull, tick, onChanged, onError }: { refObj: PullRef; pull: Pull; tick: number; onChanged: () => void; onError: (m: string) => void }) {
const { t } = useT()
const assignees = useAsync(() => listAssignees(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
const rows = assignees.data ?? []
const members = useAsync(() => listSpaceMembers(refObj.space), [refObj.space])
const memberOptions = (members.data?.items ?? [])
.map(row => row.user)
.filter((user): user is NonNullable<typeof user> => Boolean(user))
.filter(user => !rows.some(assignee => assignee.user?.username === user.username))
.map(user => ({
value: user.username,
label: user.display_name || user.username,
hint: `@${user.username}`,
icon: <Avatar user={user} size={16} />,
}))
return (
<SideSection title={`${t('pull.detail.assignees')}${rows.length ? ` (${rows.length})` : ''}`}>
{rows.length ? (
<ul className="space-y-1.5">
{rows.map(row => (
<li key={row.id} className="flex items-center gap-2 text-[13px]">
<Avatar user={row.user} size={20} />
<span className="min-w-0 flex-1 truncate">{userName(row.user)}</span>
{pull.state === 'open' ? (
<button
type="button"
aria-label={t('pull.detail.remove', { name: userName(row.user) })}
className="text-shell-ink-muted hover:text-[#b42318]"
onClick={() => void removeAssignee(refObj, row.user?.username ?? String(row.user?.user_id ?? '')).then(onChanged).catch(() => undefined)}
>
×
</button>
) : null}
</li>
))}
</ul>
) : (
<p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noAssignees')}</p>
)}
{pull.state === 'open' ? (
<SearchSelect
placeholder={t('pull.detail.assigneePlaceholder')}
options={memberOptions}
emptyText={t('pull.detail.pickNoMatch')}
onSelect={value => void (async () => {
try {
await addAssignee(refObj, value)
onChanged()
} catch (f) {
onError(t('pull.detail.addAssigneeFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
}
})()}
/>
) : null}
</SideSection>
)
}
function LabelsPanel({ refObj, tick, onChanged, onError }: { refObj: PullRef; tick: number; onChanged: () => void; onError: (m: string) => void }) {
const { t } = useT()
const labels = useAsync(() => listPullLabels(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
const rows = labels.data ?? []
const candidates = useAsync(() => listPullLabelOptions(refObj.space, refObj.repo), [refObj.space, refObj.repo, tick])
const labelOptions = (candidates.data ?? [])
.filter(label => !rows.some(bound => bound.id === label.id))
.map(label => ({ value: label.id, label: label.name, color: label.color }))
return (
<SideSection title={t('issue.board.labelsField')}>
{rows.length ? (
<div className="flex flex-wrap gap-1.5">
{rows.map(label => (
<LabelPillInline key={label.id} name={label.name} color={label.color} onRemove={() => void detachLabel(refObj, label.id).then(onChanged).catch(() => undefined)} />
))}
</div>
) : (
<p className="text-[12.5px] text-shell-ink-muted">{t('pull.detail.noLabels')}</p>
)}
<SearchSelect
placeholder={t('pull.detail.labelPlaceholder')}
options={labelOptions}
emptyText={t('pull.detail.pickNoMatch')}
onSelect={value => void (async () => {
try {
await attachLabel(refObj, value)
onChanged()
} catch (f) {
onError(t('pull.detail.attachLabelFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') }))
}
})()}
/>
</SideSection>
)
}
function LabelPillInline({ name, color, onRemove }: { name: string; color?: string | null; onRemove: () => void }) {
const { t } = useT()
return (
<span
className="inline-flex items-center gap-1 rounded-full border border-shell-hairline px-2 py-0.5 text-[11.5px]"
style={color ? { backgroundColor: `${color}22`, borderColor: color } : undefined}
>
{name}
<button type="button" aria-label={t('issue.board.removeLabel', { name })} onClick={onRemove} className="text-shell-ink-muted hover:text-shell-ink">×</button>
</span>
)
}