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>({}) const [draftAnchor, setDraftAnchor] = useState(null) const [draftBody, setDraftBody] = useState('') const [pending, setPending] = useState([]) const [reviewBody, setReviewBody] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState(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() 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 if (failure) return ( setTick((tick) => tick + 1)} /> ) if (!rows.length) return return (

{tc('pull.files.summary', rows.length)} · +{totalAdd}{' '} −{totalDel} {headSha ? ( <> {' '} · {t('pull.files.compareVersion')} ) : null}

{rows.map((diff) => { const open = !(collapsed[diff.path] ?? false) || diff.path === focusPath return (
{open ? ( { setDraftAnchor(anchor) setDraftBody('') }} renderDraft={(anchor) => draftAnchor && draftAnchor.path === anchor.path && draftAnchor.line === anchor.line && draftAnchor.side === anchor.side ? (