import { useState, type ReactNode } from 'react' import { cn } from '@/lib/utils' import { CollapsibleCard } from '../basic/collapsible-card' import { Button } from '../basic/button' import { RequestChangesLine, CheckLine, CloseLine } from '../basic/icons-inline' const ICON_CLASS = 'w-[var(--font-size-sm)] h-[var(--font-size-sm)] shrink-0' /** Seti 图标:固定 16px,与 QodeChatPanel 的 w-4 h-4 一致 */ const SETI_ICON_CLASS = "font-['Seti'] shrink-0 inline-flex items-center justify-center w-4 h-4 text-[16px] leading-[16px]" // 与 QodeChatPanel FileReviewPart.jsx 内联映射完全一致(qoder-seti.woff) const SETI_CHAR_MAP: Record = { javascript: '\ue054', typescript: '\ue099', react: '\ue07d', json: '\ue059', html: '\ue048', css: '\ue01d', python: '\ue07b', java: '\ue050', go: '\ue039', rust: '\ue082', php: '\ue070', ruby: '\ue081', vue: '\ue09d', svelte: '\ue090', markdown: '\ue060', yaml: '\ue0a7', xml: '\ue0a5', shell: '\ue089', sass: '\ue084', settings: '\ue019', default: '\ue023', } const SETI_COLOR_MAP: Record = { javascript: 'var(--color-yellow)', typescript: 'var(--color-blue)', react: 'var(--color-blue)', json: 'var(--color-yellow)', html: 'var(--color-blue)', css: 'var(--color-blue)', python: 'var(--color-blue)', java: 'var(--color-error)', go: 'var(--color-blue)', rust: 'var(--color-lavender)', php: 'var(--color-purple)', ruby: 'var(--color-error)', vue: 'var(--color-success)', svelte: 'var(--color-error)', markdown: 'var(--color-blue)', yaml: 'var(--color-purple)', xml: 'var(--color-orange)', shell: 'var(--color-success)', sass: 'var(--color-error)', settings: 'var(--color-lavender)', default: 'var(--color-lavender)', } const FILE_TYPE_MAP: Record = { js: 'javascript', jsx: 'react', ts: 'typescript', tsx: 'typescript', json: 'json', html: 'html', css: 'css', scss: 'sass', sass: 'sass', less: 'css', py: 'python', java: 'java', go: 'go', rs: 'rust', php: 'php', rb: 'ruby', vue: 'vue', svelte: 'svelte', md: 'markdown', yml: 'yaml', yaml: 'yaml', xml: 'xml', sh: 'shell', bash: 'shell', zsh: 'shell', config: 'settings', conf: 'settings', } function getSetiIconType(fileType?: string, filename?: string): string { let iconType: string | null = null if (filename && typeof filename === 'string' && filename.trim()) { const lastDotIndex = filename.lastIndexOf('.') if (lastDotIndex > 0 && lastDotIndex < filename.length - 1) { const ext = filename.substring(lastDotIndex + 1).toLowerCase().trim() if (ext && FILE_TYPE_MAP[ext]) iconType = FILE_TYPE_MAP[ext] } } if (!iconType && fileType && typeof fileType === 'string' && fileType.trim()) { const lowerFileType = fileType.toLowerCase().trim() if (FILE_TYPE_MAP[lowerFileType]) iconType = FILE_TYPE_MAP[lowerFileType] else if (SETI_CHAR_MAP[lowerFileType]) iconType = lowerFileType } return iconType || 'default' } function getSetiIcon(fileType?: string, filename?: string): string { const iconType = getSetiIconType(fileType, filename) return SETI_CHAR_MAP[iconType] ?? SETI_CHAR_MAP.default } function getSetiIconColor(fileType?: string, filename?: string): string { const iconType = getSetiIconType(fileType, filename) return SETI_COLOR_MAP[iconType] ?? SETI_COLOR_MAP.default } export interface FileReviewFile { filename: string fileType?: string additions?: number deletions?: number } export interface FileReviewPartProps { files?: FileReviewFile[] defaultExpanded?: boolean className?: string /** 卡片标题区图标,未传则用内联 SVG */ headerIcon?: ReactNode /** 接受按钮图标,未传则用内联 SVG */ acceptIcon?: ReactNode /** 拒绝按钮图标,未传则用内联 SVG */ rejectIcon?: ReactNode /** 按文件类型/文件名渲染图标;未传则用 Seti 字体字符(需加载 qoder-seti.woff) */ getFileIcon?: (fileType?: string, filename?: string) => ReactNode } export function FileReviewPart({ files = [], defaultExpanded = true, className, headerIcon, acceptIcon, rejectIcon, getFileIcon, }: FileReviewPartProps) { const [hoveredIndex, setHoveredIndex] = useState(null) const defaultHeaderIcon = const actionIconSize = 'w-[var(--font-size-sm)] h-[var(--font-size-sm)]' const defaultAcceptIcon = const defaultRejectIcon = const actionBtnClass = 'flex items-center justify-center shrink-0 rounded cursor-pointer transition-colors duration-200 text-text-secondary hover:text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-primary' return ( {files.length} {files.length === 1 ? 'File' : 'Files'} } headerRight={
} defaultExpanded={defaultExpanded} contentPadding="var(--spacing-1\.5) var(--spacing-1) var(--spacing-1\.5)" className={className} >
{files.map((file, index) => { const isHovered = hoveredIndex === index const add = file.additions != null && Number.isFinite(file.additions) ? file.additions : null const del = file.deletions != null && Number.isFinite(file.deletions) ? file.deletions : null return (
setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(null)} > {getFileIcon ? ( {getFileIcon(file.fileType, file.filename)} ) : ( {getSetiIcon(file.fileType, file.filename)} )} {file.filename} {/* 默认展示 diff 统计,hover 时淡出 */}
{add != null && +{add}} {del != null && -{del}}
{/* Hover 时显示的对号/错号按钮,始终挂载用 opacity 控制显隐;用原生 button 保证图标可见 */}
e.stopPropagation()} >
) })}
) } FileReviewPart.displayName = 'FileReviewPart' export const FileReviewCard = FileReviewPart export type FileReviewCardProps = FileReviewPartProps