f0a1552
ZhenYi
feat(chat): add chat module with room management, message handling, and session revocation
f0a1552
import { useMemo, useState } from 'react'
import { PanelNote, ErrorPanel, Button, modalTextareaClass } from '@/shared/ui'
import { useAsync } from '@/shared/lib'
import {
createComment,
listCommentTree,
listPullFiles,
submitReview,
type InlineDraft,
type PullRef,
} from '@/entities/pull-request'
import type { ReviewIntent } from '@/entities/pull-request'
import { Copyable, InlineDiffCard } from './parts'
import { anchorKey, changedFileToDiff, type AnchoredComment, type InlineAnchor } from '../model/meta'
export interface FilesTabProps {
refObj: PullRef
headSha: string
/** File path to expand/scroll to when jumping from an inline comment. */
focusPath: string
onChanged: () => void
}
/** 变更文件页签:逐文件 diff + 行内评论 + 待提交评审草稿。 */
export function FilesTab({ refObj, headSha, focusPath, onChanged }: FilesTabProps) {
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
const key = anchorKey(root.path, root.line, '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])
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 : '评论失败')
} 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 : '评审提交失败')
} finally {
setBusy(false)
}
}
if (files.loading && !files.data) return <PanelNote text="加载变更文件…" />
if (failure) return <ErrorPanel status={failure.code ?? 0} what="变更文件" note={failure.message} onRetry={() => setTick(t => t + 1)} />
if (!rows.length) return <PanelNote text="没有文件变更。" />
return (
<div className="space-y-3">
<p className="text-[12.5px] text-shell-ink-muted">
{rows.length} 个文件改动 · <span className="text-[#1a7f37]">+{totalAdd}</span>{' '}
<span className="text-[#b42318]">−{totalDel}</span>
{headSha ? <> · 对比版本 <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={`评论 ${anchor.path}:${anchor.line}(${anchor.side === 'left' ? '旧行' : '新行'})…`}
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()}>直接评论此行</Button>
<Button variant="secondary" className="h-7 text-[12px]" disabled={busy} onClick={queueAsReviewDraft}>加入待提交评审</Button>
<Button variant="ghost" className="h-7 text-[12px]" onClick={() => setDraftAnchor(null)}>取消</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">待提交的评审({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="移除该条意见" 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="评审总结(可选)…" className={modalTextareaClass} />
<div className="mt-2 flex items-center gap-2">
<Button className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('approve')}>批准</Button>
<Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('request_changes')}>请求修改</Button>
<Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('comment')}>仅评论</Button>
<Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={() => void submitPendingReview('pending')}>存为待提交评审</Button>
<Button variant="ghost" className="h-8 text-[12px]" onClick={() => setPending([])}>丢弃</Button>
</div>
</div>
) : null}
</div>
)
}
function statusLabel(status: string): string {
return status === 'added' ? '新增'
: status === 'deleted' ? '删除'
: status === 'renamed' ? '重命名'
: status === 'copied' ? '复制'
: status === 'binary' ? '二进制'
: '修改'
}
const statusTone = (status: string) =>
status === 'added' ? 'text-[#1a7f37]'
: status === 'deleted' ? 'text-[#b42318]'
: status === 'binary' ? 'text-[#8a6100]'
: 'text-shell-ink-muted'