Skip to content
Clone
main
337 lines 13.2 KB
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 <InitialAvatar name={userName(user)} url={user?.avatar_url || null} size={size} />
}
export function PullStateBadge({ pull }: { pull: Pull }) {
if (pull.is_draft) {
return (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${TONE.gray}`}
>
{tr('pull.badge.draft')}
</span>
)
}
const tone = pull.state === 'open' ? TONE.green : pull.state === 'merged' ? TONE.violet : TONE.gray
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${tone}`}>
{pull.state === 'merged' ? <GitMerge className="h-3 w-3" aria-hidden /> : null}
{PULL_STATE_LABEL[pull.state]}
{pull.merged_at ? ' ✓' : ''}
</span>
)
}
const MERGEABLE_KEYS: Record<string, { key: MessageKey; tone: keyof typeof TONE }> = {
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 <span className={`rounded px-1.5 py-0.5 text-[11.5px] font-medium ${TONE[hit.tone]}`}>{hit.text}</span>
}
export function LabelPill({ name, color, onRemove }: { name: string; color?: string | null; onRemove?: () => void }) {
const { t } = useT()
return (
<span
className="inline-flex items-center gap-1 rounded-full border border-shell-hairline px-2 py-0.5 text-[11.5px] font-medium text-shell-ink"
style={color ? { backgroundColor: `${color}22`, borderColor: color } : undefined}
>
<TagIcon className="h-3 w-3" aria-hidden />
{name}
{onRemove ? (
<button
type="button"
aria-label={t('issue.board.removeLabel', { name })}
onClick={onRemove}
className="ml-0.5 text-shell-ink-muted hover:text-shell-ink"
>
×
</button>
) : null}
</span>
)
}
export function SideSection({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
return (
<section className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="font-mono text-[10.5px] font-semibold uppercase tracking-[1.2px] text-shell-ink-muted">
{title}
</h4>
{action}
</div>
{children}
</section>
)
}
/* ------------------------------------------------------------------ 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<string, AnchoredComment[]>
onRequestComment?: (anchor: InlineAnchor) => void
renderDraft?: (anchor: InlineAnchor) => ReactNode
}) {
const { hunks, truncated } = parseHunks(file.raw)
const canComment = Boolean(onRequestComment)
return (
<div className="overflow-x-auto">
{file.status === 'binary' ? (
<p className="px-3 py-2 text-[12.5px] text-shell-ink-muted">{tr('pull.parts.binaryDiff')}</p>
) : null}
{file.status !== 'binary' && hunks.length === 0 ? (
<p className="px-3 py-2 text-[12.5px] text-shell-ink-muted">{tr('pull.parts.metaOnly')}</p>
) : null}
<table className="w-full border-collapse font-mono text-[12px] leading-5">
<tbody>
{hunks.map((hunk) => (
<Fragment key={hunk.header}>
<tr>
<td colSpan={4} className="bg-shell-field px-3 py-1 text-[11.5px] text-shell-ink-muted">
{hunk.header}
</td>
</tr>
{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 (
<Fragment key={`${hunk.header}:${index}`}>
<tr
id={lineNo != null ? `diff-${side}-${encodeURIComponent(file.path)}-${lineNo}` : undefined}
className={lineTone(line)}
>
<td className="w-10 shrink-0 select-none border-r border-shell-hairline px-2 text-right text-[11px] text-shell-ink-muted">
{line.oldNo ?? ''}
</td>
<td className="w-10 shrink-0 select-none border-r border-shell-hairline px-2 text-right text-[11px] text-shell-ink-muted">
{line.newNo ?? ''}
</td>
<td className="w-6 select-none px-1 text-center text-[11px] text-shell-ink-muted">
{canComment && lineNo != null ? (
<button
type="button"
title={tr('pull.parts.addComment')}
onClick={() => onRequestComment?.({ path: file.path, line: lineNo, side })}
className="rounded bg-shell-active px-1 text-shell-ink-muted opacity-0 transition-opacity hover:text-shell-ink focus:opacity-100 group-hover/diff:opacity-100"
>
<MessageSquareText className="h-3 w-3" aria-hidden />
</button>
) : line.kind === 'add' ? (
'+'
) : line.kind === 'del' ? (
'−'
) : (
''
)}
</td>
<td className="whitespace-pre-wrap break-all px-2 py-0.5">{line.text || ' '}</td>
</tr>
{comments.map((comment) => (
<tr key={comment.id}>
<td colSpan={4} className="border-b border-shell-hairline bg-shell-field/60 px-3 py-1.5">
<Markdown compact className="font-sans">
{comment.body}
</Markdown>
</td>
</tr>
))}
{draft ? (
<tr>
<td colSpan={4} className="border-b border-shell-hairline bg-shell-field/60 px-3 py-2">
<div className="font-sans">{draft}</div>
</td>
</tr>
) : null}
</Fragment>
)
})}
</Fragment>
))}
</tbody>
</table>
{truncated ? (
<p className="border-t border-shell-hairline px-3 py-2 text-[12px] text-[#8a6100]">
{tr('pull.parts.truncated')}
</p>
) : null}
</div>
)
}
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 (
<span
role="button"
tabIndex={0}
title={text}
onClick={() => 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)}
</span>
)
}
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<HTMLDivElement | null>(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 (
<div ref={box} className="relative mt-2">
<button
type="button"
aria-expanded={open}
aria-haspopup="listbox"
disabled={disabled}
onClick={() => {
setQuery('')
setOpen((value) => !value)
}}
className="flex h-7 w-full items-center gap-1.5 rounded-md border border-shell-hairline bg-shell-field px-2 text-left text-[12px] text-shell-ink-muted outline-none transition-colors hover:text-shell-ink focus-visible:border-shell-tile disabled:pointer-events-none disabled:opacity-50"
>
<Search className="h-3 w-3 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 truncate">{placeholder}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-70" aria-hidden />
</button>
{open ? (
<div className="absolute left-0 right-0 top-full z-30 mt-1 rounded-lg border border-shell-hairline bg-shell-surface shadow-lg">
<input
autoFocus
value={query}
onChange={(event) => 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"
/>
<div role="listbox" className="max-h-44 overflow-y-auto py-1">
{filtered.length === 0 ? (
<p className="px-2 py-1.5 text-[12px] text-shell-ink-muted">{emptyText}</p>
) : (
filtered.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={false}
onClick={() => {
setOpen(false)
onSelect(option.value)
}}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-[12.5px] text-shell-ink transition-colors hover:bg-shell-active"
>
{option.icon ??
(option.color ? (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: option.color }}
aria-hidden
/>
) : null)}
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.hint ? (
<span className="shrink-0 text-[11.5px] text-shell-ink-muted">{option.hint}</span>
) : null}
</button>
))
)}
</div>
</div>
) : null}
</div>
)
}