Files
ai-code-app/src/components/previewer/CodexDiffPreviewer.tsx

359 lines
12 KiB
TypeScript
Executable File

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<Record<CodexDiffPreviewerFile['status'], number>>(
(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<string | null>(() => files[0]?.path ?? null);
const [expandedPreviewPath, setExpandedPreviewPath] = useState<string | null>(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 <Empty description="기록된 소스 미리보기가 없습니다." />;
}
return (
<>
{contextHolder}
<div className="codex-diff-previewer">
<div className="codex-diff-previewer__header">
<div>
<div className="codex-diff-previewer__title">{title}</div>
<Text type="secondary">{description}</Text>
</div>
<Space wrap size={[6, 6]} className="codex-diff-previewer__stats">
{files.length ? <Tag bordered={false}> {files.length}</Tag> : null}
{statusCount.added ? <Tag color="green">{`신규 ${statusCount.added}`}</Tag> : null}
{statusCount.modified ? <Tag color="blue">{`변경 ${statusCount.modified}`}</Tag> : null}
{statusCount.renamed ? <Tag color="gold">{`이동 ${statusCount.renamed}`}</Tag> : null}
{statusCount.deleted ? <Tag color="red">{`삭제 ${statusCount.deleted}`}</Tag> : null}
{statusCount.binary ? <Tag>{`바이너리 ${statusCount.binary}`}</Tag> : null}
</Space>
</div>
{showModeSwitch && canShowSource && canShowDiff ? (
<Segmented
value={activeMode}
onChange={(value) => {
setActiveMode(value as 'source' | 'diff');
}}
options={[
{ label: '전체소스', value: 'source' },
{ label: 'raw diff', value: 'diff' },
]}
/>
) : null}
{resolvedMode === 'diff' && diffText ? (
<CodexDiffBlock diffText={diffText} />
) : canShowSource ? (
<div className="codex-diff-previewer__diff-list">
<div className="codex-diff-previewer__diff-toolbar">
<Text type="secondary">{`파일 ${files.length}개 기준으로 전체 소스를 분리해 표시합니다.`}</Text>
<Space size={8} wrap>
<Button
size="small"
onClick={() => {
setExpandedSourcePath(files[0]?.path ?? null);
}}
>
</Button>
<Button
size="small"
onClick={() => {
setExpandedSourcePath(null);
}}
>
</Button>
</Space>
</div>
{files.map((file) => {
const isExpanded = expandedSourcePath === file.path;
const isPreviewExpanded = expandedPreviewPath === file.path;
const displayPath = file.previousPath ? `${file.previousPath} -> ${file.path}` : file.path;
return (
<section
className={[
'codex-diff-previewer__diff-section',
isPreviewExpanded ? 'codex-diff-previewer__diff-section--expanded' : '',
]
.filter(Boolean)
.join(' ')}
key={file.path}
>
<button
type="button"
className="codex-diff-previewer__diff-toggle"
onClick={() => {
setExpandedSourcePath((currentPath) => (currentPath === file.path ? null : file.path));
}}
>
<Space align="center" size={10} className="codex-diff-previewer__diff-toggle-main">
{isExpanded ? <DownOutlined /> : <RightOutlined />}
{resolvePreviewType(file) === 'image' ? <FileImageOutlined /> : <FileTextOutlined />}
<span className="codex-diff-previewer__file-name">{file.path.split('/').pop() ?? file.path}</span>
<Tag bordered={false} color={resolveTagColor(file.status)}>
{CODEX_DIFF_STATUS_LABEL_MAP[file.status]}
</Tag>
</Space>
<Text type="secondary" className="codex-diff-previewer__file-path">
{displayPath}
</Text>
</button>
<Space size={4} className="codex-diff-previewer__diff-actions">
<Button
size="small"
type="text"
aria-label="복사"
icon={<CopyOutlined />}
onClick={(event) => {
event.stopPropagation();
void handleCopy(file.content);
}}
/>
<Button
size="small"
type="text"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={(event) => {
event.stopPropagation();
handleDownload(file.path, file.content);
}}
/>
<Button
size="small"
type="text"
aria-label={isPreviewExpanded ? '최대화 해제' : '최대화'}
icon={isPreviewExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={(event) => {
event.stopPropagation();
handleFullscreen(file.path);
}}
/>
</Space>
{isExpanded ? (
<div className="codex-diff-previewer__diff-body">
<PreviewerUI
type={resolvePreviewType(file)}
showHeader={false}
value={file.content}
language={file.language}
imageAlt={file.path.split('/').pop() ?? file.path}
downloadFileName={file.path.split('/').pop() ?? file.path}
height={isPreviewExpanded ? 'calc(100vh - 120px)' : height}
copyable={false}
maximizable={false}
/>
</div>
) : null}
</section>
);
})}
</div>
) : (
<Empty description="표시할 소스 파일이 없습니다." />
)}
</div>
</>
);
}