Skip to content
Clone
f0a1552
188 lines 8.0 KB
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 { 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
onOpenPull: (pullRef: string) => void
}
const SCOPE_LABEL: Record<MyPullScope, string> = {
created: '我发起的',
'review-requested': '待我评审',
reviewed: '我评审过的',
}
/** 合并请求 页签:本仓库列表 + “我的 PR” 收件箱(限定本仓库,可深链)。 */
export function PullsTab({ space, repo, repoId, defaultBranch, onOpenPull }: PullsTabProps) {
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' ? '本仓库' : <><Inbox className="h-3.5 w-3.5" aria-hidden />我的 PR</>}
</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'
}`}
>
{SCOPE_LABEL[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="搜索标题/描述"
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>
)}
<Button className="ml-auto h-8 gap-1 text-[12.5px]" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" aria-hidden /> 新建合并请求
</Button>
</div>
<PullFilter value={filter} onChange={next => withReset(() => setFilter(next))()} />
{state.loading && !page ? <PanelNote text="加载合并请求…" /> : null}
{!state.loading && failure ? (
<ErrorPanel status={failure.code ?? 0} what="合并请求列表" note={failure.message} onRetry={() => setTick(t => t + 1)} />
) : null}
{!state.loading && !failure && rows.length === 0 ? (
<PanelNote text={source === 'mine' ? '这个收件箱是空的。' : '没有符合条件的合并请求。'} />
) : null}
{rows.length > 0 ? (
<ul className="divide-y divide-shell-hairline rounded-xl border border-shell-hairline bg-shell-surface">
{rows.map(pull => (
<li key={pull.id} className="px-4 py-3">
<button type="button" onClick={() => onOpenPull(String(pull.number))} 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))}>
上一页
</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)}>
下一页
</Button>
<span>共 {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>
)
}