951a522
import { useMemo, useState } from 'react'
import { Inbox, Plus, Search } from 'lucide-react'
import { Button, ErrorPanel, PanelNote } from '@/shared/ui'
import { fmtRelative, repoKey, useAsync } from '@/shared/lib'
import { listMyPulls, listPulls } from '@/entities/pull-request'
import { useT } from '@/shared/i18n'
import { MY_PULL_SCOPES } from '@/entities/pull-request'
import type { MyPullScope } from '@/entities/pull-request'
import { PullFilter, type PullFilterValue } from '../model/filters'
import { Avatar, MergeableChip, PullStateBadge } from './parts'
import { userName } from '../model/meta'
import { PullFormModal } from './pull-form-modal'
const PAGE_SIZE = 20
export interface PullsTabProps {
space: string
repo: string
/** `RepoDetail.id` — the inbox can only deep-link PRs of this repo. */
repoId: string
defaultBranch: string
/** Repo write access (space owner / repo collaborator server-side). */
canWrite: boolean
/** 详情面板当前打开的 pull_ref;与其匹配的行再次点击即关闭详情。 */
activePull?: string | null
onOpenPull: (pullRef: string) => void
onClosePull: () => void
}
const SCOPE_KEYS: Record<MyPullScope, 'pull.list.scope.created' | 'pull.list.scope.reviewRequested' | 'pull.list.scope.reviewed'> = {
created: 'pull.list.scope.created',
'review-requested': 'pull.list.scope.reviewRequested',
reviewed: 'pull.list.scope.reviewed',
}
/** 合并请求 页签:本仓库列表 + “我的 PR” 收件箱(限定本仓库,可深链)。 */
export function PullsTab({ space, repo, repoId, defaultBranch, canWrite, activePull, onOpenPull, onClosePull }: PullsTabProps) {
const { t, tc } = useT()
const [source, setSource] = useState<'repo' | 'mine'>('repo')
const [scope, setScope] = useState<MyPullScope>('review-requested')
const [filter, setFilter] = useState<PullFilterValue>({ state: 'open', search: '' })
const [offset, setOffset] = useState(0)
const [creating, setCreating] = useState(false)
const [tick, setTick] = useState(0)
// Filter/source changes reset paging inline (no effect-state ping-pong).
const withReset = (fn: () => void) => () => {
fn()
setOffset(0)
}
const state = useAsync(
async () => {
if (source === 'repo') {
return listPulls(space, repo, {
state: filter.state,
...(filter.search ? { search: filter.search } : {}),
limit: PAGE_SIZE,
offset,
})
}
const page = await listMyPulls(scope, { state: filter.state, limit: 50, offset: 0 })
const rows = page.items.filter(pull =>
pull.repo_id === repoId &&
(filter.state !== 'draft' || pull.is_draft) &&
(!filter.search || pull.title.toLowerCase().includes(filter.search.toLowerCase())),
)
return {
items: rows.slice(offset, offset + PAGE_SIZE),
total: rows.length,
limit: PAGE_SIZE,
offset,
}
},
[space, repo, repoId, source, scope, filter.state, filter.search, offset, tick],
{ cacheKey: repoKey(space, repo, `pulls:${source}:${scope}:${filter.state}:${filter.search}:${offset}`) },
)
const page = state.data
const rows = useMemo(() => page?.items ?? [], [page])
const failure = state.error as { code?: number; message?: string } | undefined
return (
<div className="space-y-3 pb-6">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-0.5 rounded-md bg-shell-field p-0.5">
{(['repo', 'mine'] as const).map(value => (
<button
key={value}
type="button"
aria-pressed={source === value}
onClick={withReset(() => setSource(value))}
className={`flex items-center gap-1 rounded px-2.5 py-1 text-[12.5px] transition-colors ${
source === value ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
}`}
>
{value === 'repo' ? t('pull.list.repo') : <><Inbox className="h-3.5 w-3.5" aria-hidden />{t('pull.list.mine')}</>}
</button>
))}
</div>
{source === 'mine' ? (
<div className="flex items-center gap-0.5 rounded-md bg-shell-field p-0.5">
{MY_PULL_SCOPES.map(value => (
<button
key={value}
type="button"
aria-pressed={scope === value}
onClick={withReset(() => setScope(value))}
className={`rounded px-2.5 py-1 text-[12.5px] transition-colors ${
scope === value ? 'bg-shell-active font-semibold text-shell-ink' : 'text-shell-ink-muted hover:text-shell-ink'
}`}
>
{t(SCOPE_KEYS[value])}
</button>
))}
</div>
) : (
<label className="relative hidden items-center sm:flex">
<Search className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
<input
value={filter.search}
onChange={event => withReset(() => setFilter(prev => ({ ...prev, search: event.target.value })))()}
placeholder={t('pull.list.searchPlaceholder')}
className="h-8 w-52 rounded-md border border-shell-hairline bg-shell-field pl-8 pr-2 text-[12.5px] outline-none focus:border-shell-tile"
/>
</label>
)}
{canWrite ? (
<Button className="ml-auto h-8 gap-1 text-[12.5px]" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" aria-hidden /> {t('pull.list.new')}
</Button>
) : null}
</div>
<PullFilter value={filter} onChange={next => withReset(() => setFilter(next))()} />
{state.loading && !page ? <PanelNote text={t('pull.list.loading')} /> : null}
{!state.loading && failure ? (
<ErrorPanel status={failure.code ?? 0} what={t('pull.list.errorList')} note={failure.message} onRetry={() => setTick(tick => tick + 1)} />
) : null}
{!state.loading && !failure && rows.length === 0 ? (
<PanelNote text={source === 'mine' ? t('pull.list.emptyInbox') : t('pull.list.empty')} />
) : null}
{rows.length > 0 ? (
<ul className="divide-y divide-shell-hairline rounded-xl border border-shell-hairline bg-shell-surface">
{rows.map(pull => {
const ref = String(pull.number)
const active = activePull != null && (activePull === ref || activePull === pull.id)
return (
<li key={pull.id} className={`px-4 py-3 ${active ? 'bg-shell-active/50' : ''}`}>
<button
type="button"
aria-current={active ? 'true' : undefined}
onClick={() => (active ? onClosePull() : onOpenPull(ref))}
className="group flex w-full items-start gap-3 text-left"
>
<span className="mt-0.5"><PullStateBadge pull={pull} /></span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-semibold leading-6 group-hover:underline">{pull.title}</span>
<span className="mt-0.5 block text-[12px] text-shell-ink-muted">
#{pull.number} · {pull.head.branch} → {pull.base.branch}
<span className="mx-1">·</span>
{userName(pull.author)} · {fmtRelative(pull.created_at)}
{pull.mergeable_state ? <><span className="mx-1">·</span><MergeableChip state={pull.mergeable_state} /></> : null}
</span>
</span>
<Avatar user={pull.author} size={22} />
</button>
</li>
)
})}
</ul>
) : null}
{page && page.total > PAGE_SIZE ? (
<div className="flex items-center gap-3 text-[12.5px] text-shell-ink-muted">
<Button variant="secondary" className="h-7 text-[12px]" disabled={offset <= 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
{t('issue.board.prev')}
</Button>
<span className="rounded bg-shell-active px-2 py-0.5">
{Math.floor(offset / PAGE_SIZE) + 1} / {Math.max(1, Math.ceil(page.total / PAGE_SIZE))}
</span>
<Button variant="secondary" className="h-7 text-[12px]" disabled={offset + PAGE_SIZE >= page.total} onClick={() => setOffset(offset + PAGE_SIZE)}>
{t('issue.board.next')}
</Button>
<span>{tc('pull.list.total', page.total)}</span>
</div>
) : null}
{creating ? (
<PullFormModal
space={space}
repo={repo}
defaultBase={defaultBranch}
onClose={() => setCreating(false)}
onCreated={pull => {
setCreating(false)
setTick(t => t + 1)
onOpenPull(String(pull.number))
}}
/>
) : null}
</div>
)
}