import { Markdown } from '@app/components' import type { Pull, UserDto } from '@app/domains/pulls/model' import { type DiffFile, type DiffLine, parseHunks } from '@app/domains/repo/model' import { InitialAvatar } from '@app/domains/shared' import type { MessageKey } from '@app/i18n' import { t as tr, useT } from '@app/i18n' import { shortSha } from '@app/lib' import { IconChevronDown as ChevronDown, IconGitMerge as GitMerge, IconMessage as MessageSquareText, IconSearch as Search, IconTag as TagIcon, } from '@tabler/icons-react' import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react' import { type AnchoredComment, anchorKey, type InlineAnchor, PULL_STATE_LABEL, TONE, userName } from '../model/meta' export function Avatar({ user, size = 26 }: { user?: UserDto | null; size?: number }) { return } export function PullStateBadge({ pull }: { pull: Pull }) { if (pull.is_draft) { return ( {tr('pull.badge.draft')} ) } const tone = pull.state === 'open' ? TONE.green : pull.state === 'merged' ? TONE.violet : TONE.gray return ( {pull.state === 'merged' ? : null} {PULL_STATE_LABEL[pull.state]} {pull.merged_at ? ' ✓' : ''} ) } const MERGEABLE_KEYS: Record = { clean: { key: 'pull.mergeable.clean', tone: 'green' }, blocked: { key: 'pull.mergeable.blocked', tone: 'amber' }, dirty: { key: 'pull.mergeable.dirty', tone: 'red' }, unstable: { key: 'pull.mergeable.unstable', tone: 'amber' }, has_hooks: { key: 'pull.mergeable.hasHooks', tone: 'gray' }, unknown: { key: 'pull.mergeable.unknown', tone: 'gray' }, } export function MergeableChip({ state }: { state?: string | null }) { const { t } = useT() if (!state) return null const entry = MERGEABLE_KEYS[state] const hit = entry ? { text: t(entry.key), tone: entry.tone } : { text: state, tone: 'gray' as const } return {hit.text} } export function LabelPill({ name, color, onRemove }: { name: string; color?: string | null; onRemove?: () => void }) { const { t } = useT() return ( {name} {onRemove ? ( ) : null} ) } export function SideSection({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) { return (

{title}

{action}
{children}
) } /* ------------------------------------------------------------------ diff */ /** * One changed file of the files tab: the commit-view look (unified hunk table) * plus per-line affordances — existing inline comments are pinned under their * line and a hover button starts a new draft anchored to it. */ export function InlineDiffCard({ file, commentsByLine, onRequestComment, renderDraft, }: { file: DiffFile commentsByLine: Map onRequestComment?: (anchor: InlineAnchor) => void renderDraft?: (anchor: InlineAnchor) => ReactNode }) { const { hunks, truncated } = parseHunks(file.raw) const canComment = Boolean(onRequestComment) return (
{file.status === 'binary' ? (

{tr('pull.parts.binaryDiff')}

) : null} {file.status !== 'binary' && hunks.length === 0 ? (

{tr('pull.parts.metaOnly')}

) : null} {hunks.map((hunk) => ( {hunk.lines.map((line, index) => { const side: 'left' | 'right' = line.kind === 'del' ? 'left' : 'right' const lineNo = side === 'left' ? line.oldNo : line.newNo const key = lineNo != null ? anchorKey(file.path, lineNo, side) : null const comments = key ? (commentsByLine.get(key) ?? []) : [] const draft = key && lineNo != null ? renderDraft?.({ path: file.path, line: lineNo, side }) : null return ( {comments.map((comment) => ( ))} {draft ? ( ) : null} ) })} ))}
{hunk.header}
{line.oldNo ?? ''} {line.newNo ?? ''} {canComment && lineNo != null ? ( ) : line.kind === 'add' ? ( '+' ) : line.kind === 'del' ? ( '−' ) : ( '' )} {line.text || ' '}
{comment.body}
{draft}
{truncated ? (

{tr('pull.parts.truncated')}

) : null}
) } const lineTone = (line: DiffLine) => line.kind === 'add' ? 'bg-[rgba(35,165,90,0.12)]' : line.kind === 'del' ? 'bg-[rgba(242,63,67,0.10)]' : '' export function Copyable({ text }: { text: string }) { return ( void navigator.clipboard?.writeText(text).catch(() => undefined)} onKeyDown={(event) => { if (event.key === 'Enter') void navigator.clipboard?.writeText(text).catch(() => undefined) }} className="cursor-copy rounded bg-shell-active/70 px-1.5 py-0.5 font-mono text-[11px] text-shell-ink-muted transition-colors hover:text-shell-ink" > {shortSha(text, 8)} ) } export interface SearchSelectOption { value: string label: string /** 右侧灰字(用户名等补充信息)。 */ hint?: string /** 左侧色点(标签颜色)。 */ color?: string | null /** 左侧自定义图形(头像等)。 */ icon?: ReactNode } /** * 可搜索下拉:触发按钮 + 弹出搜索框 + 过滤后的选项列表。 * 侧栏的评审人/受理人/标签只能从这里选,避免手输拼错或写入无效值。 */ export function SearchSelect({ placeholder, options, onSelect, disabled = false, emptyText, }: { placeholder: string options: SearchSelectOption[] onSelect: (value: string) => void disabled?: boolean emptyText: string }) { const box = useRef(null) const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const trimmed = query.trim().toLowerCase() const filtered = trimmed ? options.filter((option) => `${option.label} ${option.hint ?? ''}`.toLowerCase().includes(trimmed)) : options useEffect(() => { if (!open) return const onDown = (event: MouseEvent) => { if (box.current && !box.current.contains(event.target as Node)) setOpen(false) } const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false) } window.addEventListener('mousedown', onDown) window.addEventListener('keydown', onKey) return () => { window.removeEventListener('mousedown', onDown) window.removeEventListener('keydown', onKey) } }, [open]) return (
{open ? (
setQuery(event.target.value)} placeholder={placeholder} className="h-7 w-full rounded-t-lg border-b border-shell-hairline bg-shell-field px-2 text-[12px] outline-none" />
{filtered.length === 0 ? (

{emptyText}

) : ( filtered.map((option) => ( )) )}
) : null}
) }