359 lines
12 KiB
TypeScript
Executable File
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>
|
|
</>
|
|
);
|
|
}
|