Skip to content
Clone
main
316 lines 12.3 KB
import { ErrorPanel, modalTextareaClass, PanelNote } from '@app/components'
import type { ReviewIntent } from '@app/domains/pulls/model'
import {
createComment,
type InlineDraft,
listCommentTree,
listPullFiles,
type PullRef,
submitReview,
} from '@app/domains/pulls/model'
import type { MessageKey } from '@app/i18n'
import { t as tr, useT } from '@app/i18n'
import { useAsync } from '@app/lib'
import { Button } from '@app/ui'
import { useEffect, useMemo, useState } from 'react'
import { type AnchoredComment, anchorKey, changedFileToDiff, type InlineAnchor } from '../model/meta'
import { Copyable, InlineDiffCard } from './parts'
export interface FilesTabProps {
refObj: PullRef
headSha: string
/** File path to expand/scroll to when jumping from an inline comment. */
focusPath: string
/** One-based line to scroll to inside `focusPath`; 0 disables scrolling. */
focusLine?: number
onChanged: () => void
}
/** 变更文件页签:逐文件 diff + 行内评论 + 待提交评审草稿。 */
export function FilesTab({ refObj, headSha, focusPath, focusLine = 0, onChanged }: FilesTabProps) {
const { t, tc } = useT()
const [tick, setTick] = useState(0)
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [draftAnchor, setDraftAnchor] = useState<InlineAnchor | null>(null)
const [draftBody, setDraftBody] = useState('')
const [pending, setPending] = useState<InlineDraft[]>([])
const [reviewBody, setReviewBody] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const files = useAsync(() => listPullFiles(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
const threads = useAsync(() => listCommentTree(refObj), [refObj.space, refObj.repo, refObj.pull, tick])
const commentsByLine = useMemo(() => {
const map = new Map<string, AnchoredComment[]>()
for (const thread of threads.data ?? []) {
const root = thread.root
if (!root.path || root.line == null) continue
// 评论自带 side(left/right),必须按实际值归位,否则删除行上的评论会落到新增侧。
const key = anchorKey(root.path, root.line, root.side === 'left' ? 'left' : 'right')
const list = map.get(key) ?? []
list.push({ id: root.id, body: root.body }, ...thread.replies.map((r) => ({ id: r.id, body: r.body })))
map.set(key, list)
}
return map
}, [threads.data])
const rows = useMemo(() => (files.data ?? []).map((file) => changedFileToDiff(file)), [files.data])
// 从行内评论跳转过来时,滚动到目标行(两侧都试,命中即止)。
useEffect(() => {
if (!focusLine || !focusPath) return
const id = `diff-right-${encodeURIComponent(focusPath)}-${focusLine}`
const alt = `diff-left-${encodeURIComponent(focusPath)}-${focusLine}`
const target = document.getElementById(id) ?? document.getElementById(alt)
if (target) {
target.scrollIntoView({ block: 'center' })
target.classList.add('bg-[rgba(240,178,50,0.16)]')
const timer = window.setTimeout(() => target.classList.remove('bg-[rgba(240,178,50,0.16)]'), 2000)
return () => window.clearTimeout(timer)
}
}, [focusLine, focusPath, rows.length])
const totalAdd = rows.reduce((sum, row) => sum + row.additions, 0)
const totalDel = rows.reduce((sum, row) => sum + row.deletions, 0)
const failure = files.error as { code?: number; message?: string } | undefined
async function postLineComment() {
if (!draftAnchor || !draftBody.trim()) return
setBusy(true)
setError(null)
try {
await createComment(refObj, {
body: draftBody.trim(),
path: draftAnchor.path,
line: draftAnchor.line,
side: draftAnchor.side === 'left' ? 'left' : 'right',
commit_sha: headSha || null,
})
setDraftAnchor(null)
setDraftBody('')
setTick((t) => t + 1)
onChanged()
} catch (f) {
setError(f instanceof Error ? f.message : t('pull.files.commentFailed'))
} finally {
setBusy(false)
}
}
function queueAsReviewDraft() {
if (!draftAnchor || !draftBody.trim()) return
setPending((prev) => [
...prev,
{ path: draftAnchor.path, line: draftAnchor.line, side: draftAnchor.side, body: draftBody.trim() },
])
setDraftAnchor(null)
setDraftBody('')
}
async function submitPendingReview(state: ReviewIntent) {
setBusy(true)
setError(null)
try {
await submitReview(refObj, { state, body: reviewBody || null, comments: pending })
setPending([])
setReviewBody('')
setTick((t) => t + 1)
onChanged()
} catch (f) {
setError(f instanceof Error ? f.message : t('pull.files.reviewFailed'))
} finally {
setBusy(false)
}
}
if (files.loading && !files.data) return <PanelNote text={t('pull.files.loading')} />
if (failure)
return (
<ErrorPanel
status={failure.code ?? 0}
what={t('pull.files.errorFiles')}
note={failure.message}
onRetry={() => setTick((tick) => tick + 1)}
/>
)
if (!rows.length) return <PanelNote text={t('pull.files.empty')} />
return (
<div className="space-y-3">
<p className="text-[12.5px] text-shell-ink-muted">
{tc('pull.files.summary', rows.length)} · <span className="text-[#1a7f37]">+{totalAdd}</span>{' '}
<span className="text-[#b42318]">−{totalDel}</span>
{headSha ? (
<>
{' '}
· {t('pull.files.compareVersion')} <Copyable text={headSha} />
</>
) : null}
</p>
{rows.map((diff) => {
const open = !(collapsed[diff.path] ?? false) || diff.path === focusPath
return (
<section
key={diff.path}
id={`file-${encodeURIComponent(diff.path)}`}
className="group/diff overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface"
>
<button
type="button"
aria-expanded={open}
onClick={() => setCollapsed((prev) => ({ ...prev, [diff.path]: open }))}
className="flex w-full items-center gap-2 border-b border-shell-hairline bg-shell-field px-3 py-2 text-left transition-colors hover:brightness-95"
>
<span className={`shrink-0 font-mono text-[11px] font-semibold ${statusTone(diff.status)}`}>
{statusLabel(diff.status)}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[12.5px]" title={diff.path}>
{diff.oldPath && diff.oldPath !== diff.path ? `${diff.oldPath} → ${diff.path}` : diff.path}
</span>
<span className="shrink-0 font-mono text-[11.5px]">
<span className="text-[#1a7f37]">+{diff.additions}</span>{' '}
<span className="text-[#b42318]">−{diff.deletions}</span>
</span>
</button>
{open ? (
<InlineDiffCard
file={diff}
commentsByLine={commentsByLine}
onRequestComment={(anchor) => {
setDraftAnchor(anchor)
setDraftBody('')
}}
renderDraft={(anchor) =>
draftAnchor &&
draftAnchor.path === anchor.path &&
draftAnchor.line === anchor.line &&
draftAnchor.side === anchor.side ? (
<div className="space-y-1.5">
<textarea
autoFocus
value={draftBody}
onChange={(event) => setDraftBody(event.target.value)}
rows={3}
placeholder={t('pull.files.linePlaceholder', {
path: anchor.path,
line: anchor.line,
side: anchor.side === 'left' ? t('pull.files.sideOld') : t('pull.files.sideNew'),
})}
className={modalTextareaClass}
/>
{error ? <p className="text-[12px] text-[#b42318]">{error}</p> : null}
<div className="flex items-center gap-2">
<Button className="h-7 text-[12px]" disabled={busy} onClick={() => void postLineComment()}>
{t('pull.files.postLine')}
</Button>
<Button
variant="secondary"
className="h-7 text-[12px]"
disabled={busy}
onClick={queueAsReviewDraft}
>
{t('pull.files.queueReview')}
</Button>
<Button variant="ghost" className="h-7 text-[12px]" onClick={() => setDraftAnchor(null)}>
{t('common.action.cancel')}
</Button>
</div>
</div>
) : null
}
/>
) : null}
</section>
)
})}
{pending.length > 0 ? (
<div className="sticky bottom-3 rounded-xl border-2 border-shell-tile bg-shell-surface p-4 shadow-lg">
<h4 className="mb-2 text-[13px] font-semibold">{t('pull.files.pendingTitle', { count: pending.length })}</h4>
<ul className="mb-2 max-h-32 space-y-1 overflow-y-auto text-[12px] text-shell-ink-muted">
{pending.map((draft, index) => (
<li key={index} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono">
{draft.path}:{draft.line}
</span>
<span className="min-w-0 max-w-[50%] truncate">{draft.body}</span>
<button
type="button"
aria-label={t('pull.files.removeDraft')}
onClick={() => setPending((prev) => prev.filter((_, i) => i !== index))}
className="text-shell-ink-muted hover:text-[#b42318]"
>
×
</button>
</li>
))}
</ul>
<textarea
value={reviewBody}
onChange={(event) => setReviewBody(event.target.value)}
rows={2}
placeholder={t('pull.files.reviewSummaryPlaceholder')}
className={modalTextareaClass}
/>
<div className="mt-2 flex items-center gap-2">
<Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('approve')}>
{t('pull.files.approve')}
</Button>
<Button
variant="secondary"
className="h-8 text-[12.5px]"
disabled={busy}
onClick={() => void submitPendingReview('request_changes')}
>
{t('pull.files.requestChanges')}
</Button>
<Button
variant="secondary"
className="h-8 text-[12.5px]"
disabled={busy}
onClick={() => void submitPendingReview('comment')}
>
{t('pull.files.commentOnly')}
</Button>
<Button
variant="secondary"
className="h-8 text-[12.5px]"
disabled={busy}
onClick={() => void submitPendingReview('pending')}
>
{t('pull.files.saveAsPending')}
</Button>
<Button variant="ghost" className="h-8 text-[12px]" onClick={() => setPending([])}>
{t('pull.files.discard')}
</Button>
</div>
</div>
) : null}
</div>
)
}
function statusLabel(status: string): string {
const key = STATUS_KEYS[status]
return key ? tr(key) : tr('pull.files.status.modified')
}
const STATUS_KEYS: Record<string, MessageKey> = {
added: 'pull.files.status.added',
deleted: 'pull.files.status.deleted',
renamed: 'pull.files.status.renamed',
copied: 'pull.files.status.copied',
binary: 'pull.files.status.binary',
modified: 'pull.files.status.modified',
}
const statusTone = (status: string) =>
status === 'added'
? 'text-[#1a7f37]'
: status === 'deleted'
? 'text-[#b42318]'
: status === 'binary'
? 'text-[#8a6100]'
: 'text-shell-ink-muted'