Initial import
This commit is contained in:
335
src/components/previewer/CodexDiffPreviewer.tsx
Executable file
335
src/components/previewer/CodexDiffPreviewer.tsx
Executable file
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
CopyOutlined,
|
||||
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 [expandedSourcePaths, setExpandedSourcePaths] = useState<string[]>(() => files.slice(0, 1).map((file) => file.path));
|
||||
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 handleFullscreen(path: string) {
|
||||
setExpandedPreviewPath((currentPath) => (currentPath === path ? null : path));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!canShowSource) {
|
||||
if (diffText) {
|
||||
setActiveMode('diff');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedSourcePaths((currentPaths) => {
|
||||
const availablePaths = new Set(files.map((file) => file.path));
|
||||
const nextPaths = currentPaths.filter((path) => availablePaths.has(path));
|
||||
|
||||
if (nextPaths.length > 0) {
|
||||
return nextPaths;
|
||||
}
|
||||
|
||||
return files[0] ? [files[0].path] : [];
|
||||
});
|
||||
}, [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={() => {
|
||||
setExpandedSourcePaths(files.map((file) => file.path));
|
||||
}}
|
||||
>
|
||||
전체 펼치기
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setExpandedSourcePaths([]);
|
||||
}}
|
||||
>
|
||||
전체 접기
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{files.map((file) => {
|
||||
const isExpanded = expandedSourcePaths.includes(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={() => {
|
||||
setExpandedSourcePaths((currentPaths) =>
|
||||
currentPaths.includes(file.path)
|
||||
? currentPaths.filter((path) => path !== file.path)
|
||||
: [...currentPaths, 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={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}
|
||||
height={isPreviewExpanded ? 'calc(100vh - 120px)' : height}
|
||||
copyable={false}
|
||||
maximizable={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="표시할 소스 파일이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user