import { CopyOutlined, DownloadOutlined, DownOutlined, FileImageOutlined, FileTextOutlined, FullscreenExitOutlined, FullscreenOutlined, RightOutlined, } from '@ant-design/icons'; import { Button, Empty, Segmented, Space, Tag, Typography, message } from 'antd'; import { useEffect, useMemo, useState } from 'react'; import { CODEX_DIFF_STATUS_LABEL_MAP, CodexDiffBlock, type CodexDiffFileStatus, } from './CodexDiffBlock'; import { PreviewerUI } from './PreviewerUI'; import './CodexDiffPreviewer.css'; const { Text } = Typography; export type CodexDiffPreviewerFileStatus = CodexDiffFileStatus; export type CodexDiffPreviewerFile = { path: string; previousPath: string | null; status: CodexDiffPreviewerFileStatus; previewType?: 'code' | 'image' | 'empty'; language: string; content: string; }; type CodexDiffPreviewerProps = { title?: string; description?: string; files: CodexDiffPreviewerFile[]; diffText?: string | null; height?: number | string; mode?: 'auto' | 'source' | 'diff'; showModeSwitch?: boolean; }; function buildStatusCount(files: CodexDiffPreviewerFile[]) { return files.reduce>( (counts, file) => ({ ...counts, [file.status]: (counts[file.status] ?? 0) + 1, }), { added: 0, modified: 0, deleted: 0, renamed: 0, binary: 0, unknown: 0, }, ); } function resolvePreviewType(file: CodexDiffPreviewerFile) { return file.previewType ?? 'code'; } function resolveTagColor(status: CodexDiffPreviewerFileStatus) { if (status === 'added') { return 'green'; } if (status === 'modified') { return 'blue'; } if (status === 'deleted') { return 'red'; } if (status === 'renamed') { return 'gold'; } return 'default'; } export function CodexDiffPreviewer({ title = 'Codex Diff Preview', description = '변경 및 신규 소스 전체 미리보기', files, diffText, height = 'calc(100vh - 280px)', mode = 'auto', showModeSwitch = true, }: CodexDiffPreviewerProps) { const [messageApi, contextHolder] = message.useMessage(); const [activeMode, setActiveMode] = useState<'source' | 'diff'>(files.length > 0 ? 'source' : 'diff'); const [expandedSourcePath, setExpandedSourcePath] = useState(() => files[0]?.path ?? null); const [expandedPreviewPath, setExpandedPreviewPath] = useState(null); const statusCount = useMemo(() => buildStatusCount(files), [files]); const canShowSource = files.length > 0; const canShowDiff = Boolean(diffText); const resolvedMode = mode === 'auto' ? activeMode : mode; async function copyText(text: string) { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } if (typeof document === 'undefined') { throw new Error('클립보드 API를 사용할 수 없습니다.'); } const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', 'true'); textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } async function handleCopy(content: string) { try { await copyText(content); messageApi.success('복사했습니다.'); } catch { messageApi.error('복사에 실패했습니다.'); } } function handleDownload(path: string, content: string) { if (typeof document === 'undefined') { messageApi.error('다운로드를 사용할 수 없습니다.'); return; } const fileName = path.split('/').pop() || 'preview.txt'; const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const objectUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = objectUrl; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(objectUrl); } function handleFullscreen(path: string) { setExpandedPreviewPath((currentPath) => (currentPath === path ? null : path)); } useEffect(() => { if (!canShowSource) { if (diffText) { setActiveMode('diff'); } return; } setExpandedSourcePath((currentPath) => { if (currentPath && files.some((file) => file.path === currentPath)) { return currentPath; } return files[0]?.path ?? null; }); }, [canShowSource, diffText, files]); useEffect(() => { if (typeof document === 'undefined' || !expandedPreviewPath) { return; } const previousOverflow = document.body.style.overflow; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setExpandedPreviewPath(null); } }; document.body.style.overflow = 'hidden'; document.addEventListener('keydown', handleKeyDown); return () => { document.body.style.overflow = previousOverflow; document.removeEventListener('keydown', handleKeyDown); }; }, [expandedPreviewPath]); useEffect(() => { if (mode === 'source' && canShowSource) { setActiveMode('source'); return; } if (mode === 'diff' && canShowDiff) { setActiveMode('diff'); } }, [canShowDiff, canShowSource, mode]); if (!files.length && !diffText) { return ; } return ( <> {contextHolder}
{title}
{description}
{files.length ? 전체 {files.length} : null} {statusCount.added ? {`신규 ${statusCount.added}`} : null} {statusCount.modified ? {`변경 ${statusCount.modified}`} : null} {statusCount.renamed ? {`이동 ${statusCount.renamed}`} : null} {statusCount.deleted ? {`삭제 ${statusCount.deleted}`} : null} {statusCount.binary ? {`바이너리 ${statusCount.binary}`} : null}
{showModeSwitch && canShowSource && canShowDiff ? ( { setActiveMode(value as 'source' | 'diff'); }} options={[ { label: '전체소스', value: 'source' }, { label: 'raw diff', value: 'diff' }, ]} /> ) : null} {resolvedMode === 'diff' && diffText ? ( ) : canShowSource ? (
{`파일 ${files.length}개 기준으로 전체 소스를 분리해 표시합니다.`}
{files.map((file) => { const isExpanded = expandedSourcePath === file.path; const isPreviewExpanded = expandedPreviewPath === file.path; const displayPath = file.previousPath ? `${file.previousPath} -> ${file.path}` : file.path; return (
); })}
) : ( )}
); }