main
import {
Alert,
ErrorPanel,
Modal,
modalInputClass,
modalLabelClass,
modalTextareaClass,
PanelNote,
} from '@app/components'
import { listSpaceMembers } from '@app/domains/issues/model'
import type { MergeMethod, Pull, PullGate, PullRef, ReviewerState } from '@app/domains/pulls/model'
import {
addAssignee,
attachLabel,
closePull,
deletePull,
detachLabel,
getPull,
getPullGate,
listAssignees,
listPullLabelOptions,
listPullLabels,
listRequestedReviewers,
lockPull,
mergePull,
pullPatchUrl,
refreshPullSummary,
removeAssignee,
removeRequestedReviewer,
reopenPull,
requestReviewers,
unlockPull,
updateBranchPull,
updatePull,
} from '@app/domains/pulls/model'
import { fetchConflictDetail } from '@app/domains/repo/model'
import type { MessageKey } from '@app/i18n'
import { t as tr, useT } from '@app/i18n'
import { fmtRelative, fmtTime, repoKey, useAsync } from '@app/lib'
import { Button } from '@app/ui'
import {
IconDownload as Download,
IconFileCode2 as FileCode2,
IconGitMerge as GitMerge,
IconListCheck as ListChecks,
IconLoader2 as Loader2,
IconMessages as MessagesSquare,
IconPencil as Pencil,
IconRotate as RotateCcw,
IconShieldCheck as ShieldCheck,
IconLockOpen as Unlock,
IconX as X,
} from '@tabler/icons-react'
import { useState } from 'react'
import { MERGE_METHOD_LABEL, MERGE_METHODS, REVIEWER_STATE_LABEL, TONE, userName } from '../model/meta'
import { ChecksTab } from './checks-tab'
import { CommitsTab } from './commits-tab'
import { Conversation } from './conversation'
import { FilesTab } from './files-tab'
import { Avatar, MergeableChip, PullStateBadge, SearchSelect, SideSection } from './parts'
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>
)
}