609 lines
25 KiB
TypeScript
609 lines
25 KiB
TypeScript
import { Copy, FileText, RotateCcw } from 'lucide-react';
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useToast } from '../../components/toast';
|
|
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
|
import { PageTitle } from '../../layout/AppLayout';
|
|
import type { AppRelease } from '../../types/application';
|
|
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
|
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
|
import { AppSelector, useApps } from './ApplicationControls';
|
|
import {
|
|
getGiteaCommitDiff,
|
|
listGiteaCommitsPage,
|
|
listReleases,
|
|
listSources,
|
|
type GiteaCommit
|
|
} from './application-api';
|
|
|
|
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
|
|
const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? import.meta.env.GITEA_APP_ORG ?? 'platform';
|
|
const commitPageSize = 5;
|
|
|
|
type SourceComponent = ApiAppComponent & {
|
|
app?: ApiApp;
|
|
};
|
|
|
|
type CommitRange = 'week' | 'month' | 'all';
|
|
|
|
type DiffFile = {
|
|
id: string;
|
|
path: string;
|
|
lines: string[];
|
|
added: number;
|
|
removed: number;
|
|
};
|
|
|
|
type DiffTreeNode = {
|
|
name: string;
|
|
path: string;
|
|
children: DiffTreeNode[];
|
|
file?: DiffFile;
|
|
};
|
|
|
|
const commitRangeLabels: Record<CommitRange, string> = {
|
|
week: '일주일',
|
|
month: '한달',
|
|
all: '전체'
|
|
};
|
|
|
|
function isSourceComponent(source: SourceComponent) {
|
|
return (source.imageSource ?? 'SOURCE') === 'SOURCE' && source.type !== 'DATABASE' && source.type !== 'MIGRATION';
|
|
}
|
|
|
|
function normalizeRepository(repository?: string | null) {
|
|
if (!repository) return '';
|
|
const value = repository.trim().replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
|
|
return value.includes('/') ? value : `${giteaAppOrg}/${value}`;
|
|
}
|
|
|
|
function splitRepository(repository?: string | null) {
|
|
const normalized = normalizeRepository(repository);
|
|
const [owner, ...repoParts] = normalized.split('/');
|
|
const repo = repoParts.join('/');
|
|
return owner && repo ? { owner, repo } : null;
|
|
}
|
|
|
|
function repositoryGitUrl(repository?: string | null) {
|
|
const normalized = normalizeRepository(repository);
|
|
return normalized ? `${giteaBaseUrl.replace(/\/$/, '')}/${normalized}.git` : '-';
|
|
}
|
|
|
|
function releaseTime(release: AppRelease) {
|
|
const value = new Date(release.createdAt).getTime();
|
|
return Number.isNaN(value) ? 0 : value;
|
|
}
|
|
|
|
function commitAuthor(commit: GiteaCommit) {
|
|
return commit.author?.login
|
|
|| commit.author?.username
|
|
|| commit.author?.full_name
|
|
|| commit.author?.name
|
|
|| commit.commit?.author?.name
|
|
|| commit.committer?.login
|
|
|| '-';
|
|
}
|
|
|
|
function commitMessage(commit: GiteaCommit) {
|
|
return (commit.commit?.message || '').split(/\r?\n/)[0]?.trim() || '-';
|
|
}
|
|
|
|
function commitDate(commit: GiteaCommit) {
|
|
return commit.commit?.author?.date
|
|
|| commit.commit?.committer?.date
|
|
|| commit.created
|
|
|| '';
|
|
}
|
|
|
|
function relativeTime(value?: string | null) {
|
|
if (!value) return '-';
|
|
const time = new Date(value).getTime();
|
|
if (Number.isNaN(time)) return '-';
|
|
|
|
const seconds = Math.max(1, Math.floor((Date.now() - time) / 1000));
|
|
if (seconds < 60) return '방금 전';
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}분 전`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}시간 전`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days === 1) return '어제';
|
|
if (days === 2) return '그저께';
|
|
if (days < 7) return `${days}일 전`;
|
|
return new Intl.DateTimeFormat('ko-KR', { month: '2-digit', day: '2-digit' }).format(new Date(value));
|
|
}
|
|
|
|
function commitRangeSince(range: CommitRange) {
|
|
if (range === 'all') return undefined;
|
|
const date = new Date();
|
|
date.setDate(date.getDate() - (range === 'week' ? 7 : 30));
|
|
return date.toISOString();
|
|
}
|
|
|
|
function normalizeDiffPath(value?: string) {
|
|
if (!value) return '';
|
|
const path = value.trim();
|
|
if (path === '/dev/null') return path;
|
|
return path.replace(/^[ab]\//, '');
|
|
}
|
|
|
|
function diffPathFromHeader(line: string) {
|
|
const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
return normalizeDiffPath(match?.[2] ?? match?.[1]);
|
|
}
|
|
|
|
function parseDiffFiles(diff: string): DiffFile[] {
|
|
if (!diff.trim()) {
|
|
return [{ id: 'empty', path: '변경 내역', lines: ['표시할 변경 내역이 없습니다.'], added: 0, removed: 0 }];
|
|
}
|
|
|
|
const files: DiffFile[] = [];
|
|
let current: DiffFile | null = null;
|
|
const pushCurrent = () => {
|
|
if (current) files.push(current);
|
|
};
|
|
|
|
diff.split(/\r?\n/).forEach((line) => {
|
|
if (line.startsWith('diff --git ')) {
|
|
pushCurrent();
|
|
current = {
|
|
id: `file-${files.length}`,
|
|
path: diffPathFromHeader(line) || '변경 파일',
|
|
lines: [line],
|
|
added: 0,
|
|
removed: 0
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (!current) {
|
|
current = { id: 'file-0', path: '변경 내역', lines: [], added: 0, removed: 0 };
|
|
}
|
|
|
|
if (line.startsWith('+++ ')) {
|
|
const nextPath = normalizeDiffPath(line.slice(4));
|
|
if (nextPath && nextPath !== '/dev/null') current.path = nextPath;
|
|
}
|
|
if (line.startsWith('+') && !line.startsWith('+++')) current.added += 1;
|
|
if (line.startsWith('-') && !line.startsWith('---')) current.removed += 1;
|
|
current.lines.push(line);
|
|
});
|
|
|
|
pushCurrent();
|
|
return files;
|
|
}
|
|
|
|
function diffLineClass(line: string) {
|
|
if (line.startsWith('+++') || line.startsWith('---')) return 'bg-[#111827] text-sky-200';
|
|
if (line.startsWith('@@')) return 'bg-[#1e1b4b] text-violet-100';
|
|
if (line.startsWith('+')) return 'bg-[#123524] text-emerald-50';
|
|
if (line.startsWith('-')) return 'bg-[#3a1418] text-rose-50';
|
|
if (line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('new file mode') || line.startsWith('deleted file mode')) {
|
|
return 'bg-[#0f172a] text-slate-200';
|
|
}
|
|
return 'text-slate-300';
|
|
}
|
|
|
|
function buildDiffTree(files: DiffFile[]) {
|
|
const root: DiffTreeNode = { name: '', path: '', children: [] };
|
|
files.forEach((file) => {
|
|
const parts = file.path.split('/').filter(Boolean);
|
|
if (parts.length === 0) parts.push(file.path);
|
|
|
|
let current = root;
|
|
parts.forEach((part, index) => {
|
|
const path = parts.slice(0, index + 1).join('/');
|
|
let next = current.children.find((child) => child.name === part);
|
|
if (!next) {
|
|
next = { name: part, path, children: [] };
|
|
current.children.push(next);
|
|
}
|
|
if (index === parts.length - 1) next.file = file;
|
|
current = next;
|
|
});
|
|
});
|
|
|
|
const sortNodes = (nodes: DiffTreeNode[]) => {
|
|
nodes.sort((a, b) => {
|
|
if (Boolean(a.file) !== Boolean(b.file)) return a.file ? 1 : -1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
nodes.forEach((node) => sortNodes(node.children));
|
|
};
|
|
sortNodes(root.children);
|
|
return root.children;
|
|
}
|
|
|
|
function DiffTree({ nodes, depth = 0, onSelect }: { nodes: DiffTreeNode[]; depth?: number; onSelect: (file: DiffFile) => void }) {
|
|
return (
|
|
<div className="space-y-0.5">
|
|
{nodes.map((node) => (
|
|
<div key={node.path || node.name}>
|
|
{node.file ? (
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-start justify-between gap-2 rounded py-1.5 pr-2 text-left text-slate-300 transition-colors hover:bg-slate-800/80 hover:text-slate-50"
|
|
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
|
onClick={() => onSelect(node.file as DiffFile)}
|
|
title={node.file.path}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate font-mono text-[12px]">{node.name}</span>
|
|
<span className="flex shrink-0 items-center gap-1 font-mono text-[11px]">
|
|
<span className="text-emerald-300">+{node.file.added}</span>
|
|
<span className="text-rose-300">-{node.file.removed}</span>
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<div className="truncate py-1 pr-2 font-mono text-[12px] font-semibold text-slate-400" style={{ paddingLeft: `${8 + depth * 14}px` }} title={node.path}>
|
|
{node.name}
|
|
</div>
|
|
)}
|
|
{node.children.length ? <DiffTree nodes={node.children} depth={depth + 1} onSelect={onSelect} /> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DiffViewer({ diff }: { diff: string }) {
|
|
const files = parseDiffFiles(diff);
|
|
const tree = useMemo(() => buildDiffTree(files), [files]);
|
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
|
const sectionRefs = useRef<Record<string, HTMLElement | null>>({});
|
|
|
|
const scrollToFile = (file: DiffFile) => {
|
|
const scroller = scrollerRef.current;
|
|
const section = sectionRefs.current[file.id];
|
|
if (!scroller || !section) return;
|
|
scroller.scrollTo({ top: Math.max(section.offsetTop - 12, 0), behavior: 'smooth' });
|
|
};
|
|
|
|
return (
|
|
<div className="grid h-[56vh] min-h-0 overflow-hidden rounded-md border border-border bg-[#080b10] text-xs leading-5 lg:grid-cols-[280px_minmax(0,1fr)]">
|
|
<aside className="min-h-0 border-b border-slate-700/80 bg-[#0b0f14] lg:border-b-0 lg:border-r">
|
|
<div className="border-b border-slate-700/80 bg-[#111827] px-3 py-2">
|
|
<p className="text-[12px] font-semibold text-slate-100">변경 파일</p>
|
|
<p className="mt-0.5 text-[11px] text-slate-400">총 {files.length}개</p>
|
|
</div>
|
|
<div className="h-44 overflow-y-auto p-2 lg:h-[calc(56vh-50px)]">
|
|
<DiffTree nodes={tree} onSelect={scrollToFile} />
|
|
</div>
|
|
</aside>
|
|
<div ref={scrollerRef} className="min-h-0 min-w-0 overflow-auto p-3 font-mono">
|
|
<div className="space-y-4">
|
|
{files.map((file) => (
|
|
<section
|
|
id={`diff-file-${file.id}`}
|
|
key={file.id}
|
|
ref={(element) => {
|
|
sectionRefs.current[file.id] = element;
|
|
}}
|
|
className="min-w-max overflow-hidden rounded-md border border-slate-700/80 bg-[#0b0f14]"
|
|
>
|
|
<div className="sticky top-0 z-10 flex items-center justify-between gap-4 border-b border-slate-700/80 bg-[#111827] px-4 py-2">
|
|
<p className="truncate text-[13px] font-semibold text-slate-100">{file.path}</p>
|
|
<div className="flex shrink-0 items-center gap-2 text-[11px]">
|
|
<span className="rounded bg-emerald-500/15 px-2 py-0.5 font-semibold text-emerald-100">+{file.added}</span>
|
|
<span className="rounded bg-rose-500/15 px-2 py-0.5 font-semibold text-rose-100">-{file.removed}</span>
|
|
</div>
|
|
</div>
|
|
{file.lines.map((line, index) => (
|
|
<div key={`${file.id}-${index}-${line}`} className={`grid grid-cols-[72px_minmax(0,1fr)] ${diffLineClass(line)}`}>
|
|
<span className="select-none border-r border-white/10 px-3 py-0.5 text-right text-[11px] text-slate-500">{index + 1}</span>
|
|
<pre className="whitespace-pre px-3 py-0.5">{line || ' '}</pre>
|
|
</div>
|
|
))}
|
|
</section>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SourceManagementPage() {
|
|
const { toast } = useToast();
|
|
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
|
const [sources, setSources] = useState<SourceComponent[]>([]);
|
|
const [releases, setReleases] = useState<AppRelease[]>([]);
|
|
const [sourcesLoading, setSourcesLoading] = useState(true);
|
|
const [selectedSourceId, setSelectedSourceId] = useState('');
|
|
const [selectedBranch, setSelectedBranch] = useState('release');
|
|
const [commitRange, setCommitRange] = useState<CommitRange>('week');
|
|
const [commitPage, setCommitPage] = useState(1);
|
|
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
|
const [commitsLoading, setCommitsLoading] = useState(false);
|
|
const [commitsError, setCommitsError] = useState('');
|
|
const [totalCommits, setTotalCommits] = useState<number | null>(null);
|
|
const [hasNextCommitPage, setHasNextCommitPage] = useState(false);
|
|
const [diffTarget, setDiffTarget] = useState<GiteaCommit | null>(null);
|
|
const [diffContent, setDiffContent] = useState('');
|
|
const [diffLoading, setDiffLoading] = useState(false);
|
|
const [diffError, setDiffError] = useState('');
|
|
const sourcesLoadingVisible = useMinimumLoading(sourcesLoading);
|
|
|
|
const filteredSources = useMemo(
|
|
() => sources.filter((source) => isSourceComponent(source) && (!selectedAppId || source.appId === selectedAppId)),
|
|
[sources, selectedAppId]
|
|
);
|
|
const selectedSource = filteredSources.find((source) => source.id === selectedSourceId) ?? filteredSources[0] ?? null;
|
|
const repository = splitRepository(selectedSource?.repository);
|
|
const branch = selectedBranch || 'release';
|
|
const branchOptions = useMemo(
|
|
() => [...new Set(['release', 'develop', 'main', selectedSource?.branch].filter((item): item is string => Boolean(item)))],
|
|
[selectedSource?.branch]
|
|
);
|
|
const latestRelease = useMemo(
|
|
() => releases
|
|
.filter((release) => release.appId === selectedSource?.appId && release.status === 'SUCCEEDED')
|
|
.sort((a, b) => releaseTime(b) - releaseTime(a))[0],
|
|
[releases, selectedSource?.appId]
|
|
);
|
|
const totalCommitPages = totalCommits === null ? null : Math.max(1, Math.ceil(totalCommits / commitPageSize));
|
|
const selectedRepositoryGitUrl = repositoryGitUrl(selectedSource?.repository);
|
|
|
|
const refreshSources = () => {
|
|
setSourcesLoading(true);
|
|
Promise.all([
|
|
listSources(),
|
|
listReleases().catch(() => [])
|
|
])
|
|
.then(([sourceItems, releaseItems]) => {
|
|
setSources(sourceItems);
|
|
setReleases(releaseItems);
|
|
})
|
|
.catch(() => {
|
|
setSources([]);
|
|
setReleases([]);
|
|
})
|
|
.finally(() => setSourcesLoading(false));
|
|
};
|
|
|
|
useEffect(() => {
|
|
refreshSources();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setSelectedSourceId((current) => filteredSources.some((source) => source.id === current) ? current : filteredSources[0]?.id ?? '');
|
|
}, [filteredSources]);
|
|
|
|
useEffect(() => {
|
|
setCommitPage(1);
|
|
}, [selectedSourceId, selectedBranch, commitRange]);
|
|
|
|
useEffect(() => {
|
|
if (!repository) {
|
|
setCommits([]);
|
|
setCommitsError('');
|
|
setTotalCommits(null);
|
|
setHasNextCommitPage(false);
|
|
return;
|
|
}
|
|
|
|
let canceled = false;
|
|
setCommitsLoading(true);
|
|
setCommitsError('');
|
|
listGiteaCommitsPage(repository.owner, repository.repo, branch, {
|
|
page: commitPage,
|
|
limit: commitPageSize,
|
|
since: commitRangeSince(commitRange)
|
|
})
|
|
.then((result) => {
|
|
if (canceled) return;
|
|
setCommits(result.commits);
|
|
setTotalCommits(result.total);
|
|
setHasNextCommitPage(result.total === null ? result.commits.length === commitPageSize : commitPage < Math.max(1, Math.ceil(result.total / commitPageSize)));
|
|
})
|
|
.catch((error: Error) => {
|
|
if (canceled) return;
|
|
setCommits([]);
|
|
setTotalCommits(null);
|
|
setHasNextCommitPage(false);
|
|
setCommitsError(error.message);
|
|
})
|
|
.finally(() => {
|
|
if (!canceled) setCommitsLoading(false);
|
|
});
|
|
|
|
return () => {
|
|
canceled = true;
|
|
};
|
|
}, [repository?.owner, repository?.repo, branch, commitPage, commitRange]);
|
|
|
|
const openCommitDiff = (commit: GiteaCommit) => {
|
|
if (!repository) return;
|
|
setDiffTarget(commit);
|
|
setDiffContent('');
|
|
setDiffError('');
|
|
setDiffLoading(true);
|
|
getGiteaCommitDiff(repository.owner, repository.repo, commit.sha)
|
|
.then(setDiffContent)
|
|
.catch((error: Error) => setDiffError(error.message))
|
|
.finally(() => setDiffLoading(false));
|
|
};
|
|
|
|
const copyGitUrl = () => {
|
|
if (!selectedSource?.repository || selectedRepositoryGitUrl === '-') return;
|
|
navigator.clipboard?.writeText(selectedRepositoryGitUrl)
|
|
.then(() => toast({ type: 'success', message: 'Git URL을 복사했습니다.' }))
|
|
.catch(() => toast({ type: 'error', message: 'Git URL 복사에 실패했습니다.' }));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="어플리케이션"
|
|
title="소스 관리"
|
|
description="앱 구성요소 저장소의 커밋 내역과 변경 파일을 확인합니다."
|
|
actions={
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refreshSources} disabled={sourcesLoading}>
|
|
<RotateCcw className={sourcesLoading ? 'size-4 animate-spin' : 'size-4'} />
|
|
새로고침
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={selectApp} />
|
|
|
|
<Card className="mb-4 min-w-0 overflow-hidden">
|
|
<CardHeader title="소스 기준" description="커밋 내역을 확인할 앱 구성요소와 저장소입니다." />
|
|
<div className="grid gap-4 p-5 lg:grid-cols-2">
|
|
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
|
<label className="block min-w-0">
|
|
<span className="text-xs font-medium text-muted-foreground">구성요소</span>
|
|
<select
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={selectedSource?.id ?? ''}
|
|
onChange={(event) => setSelectedSourceId(event.target.value)}
|
|
disabled={filteredSources.length === 0}
|
|
>
|
|
{filteredSources.map((source) => (
|
|
<option key={source.id} value={source.id}>{source.app?.name ?? '-'} / {source.name}</option>
|
|
))}
|
|
{filteredSources.length === 0 ? <option value="">연결된 소스 없음</option> : null}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<div className="grid gap-3 rounded-md border border-border bg-muted/20 p-3 text-sm md:grid-cols-2">
|
|
<div className="min-w-0 md:col-span-2">
|
|
<p className="text-xs text-muted-foreground">저장소</p>
|
|
<div className="mt-2 rounded-md border border-border bg-background px-3 py-2">
|
|
<p className="truncate text-sm font-medium">{normalizeRepository(selectedSource?.repository) || '-'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="min-w-0 md:col-span-2">
|
|
<p className="text-xs text-muted-foreground">Git URL</p>
|
|
<div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
|
|
<p className="min-w-0 flex-1 truncate text-sm font-medium" title={selectedRepositoryGitUrl}>{selectedRepositoryGitUrl}</p>
|
|
<Button variant="ghost" className="size-8 shrink-0 px-0" title="Git URL 복사" aria-label="Git URL 복사" onClick={copyGitUrl} disabled={!selectedSource?.repository}>
|
|
<Copy className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">버전</p>
|
|
<div className="mt-2 rounded-md border border-border bg-background px-3 py-2">
|
|
<p className="truncate text-sm font-medium">{latestRelease?.version ?? '-'}</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">조회 브랜치</p>
|
|
<select
|
|
className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
|
value={branch}
|
|
onChange={(event) => setSelectedBranch(event.target.value)}
|
|
>
|
|
{branchOptions.map((option) => (
|
|
<option key={option} value={option}>{option}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader
|
|
title="커밋 목록"
|
|
description={selectedSource ? `${selectedSource.app?.name ?? '-'} / ${selectedSource.name} 기준 커밋 내역입니다.` : '소스를 선택하세요.'}
|
|
action={
|
|
<div className="flex items-center gap-2">
|
|
{(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => (
|
|
<Button key={range} variant={commitRange === range ? 'default' : 'outline'} className="h-8 px-3 text-[13px]" onClick={() => setCommitRange(range)}>
|
|
{commitRangeLabels[range]}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
}
|
|
/>
|
|
<div className="min-h-[284px] overflow-x-auto">
|
|
<table className="min-w-[760px] w-full text-left text-sm">
|
|
<thead className="border-y border-border bg-muted/30 text-xs text-muted-foreground">
|
|
<tr>
|
|
<th className="px-5 py-3 font-medium">작성자</th>
|
|
<th className="px-5 py-3 font-medium">SHA1</th>
|
|
<th className="px-5 py-3 font-medium">메시지</th>
|
|
<th className="px-5 py-3 font-medium">날짜</th>
|
|
<th className="w-16 px-5 py-3 text-right font-medium">상세</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{commitsLoading ? (
|
|
<tr><td colSpan={5} className="h-[240px] px-5 py-10 text-center text-sm text-muted-foreground">커밋을 불러오는 중입니다.</td></tr>
|
|
) : null}
|
|
{!commitsLoading && commitsError ? (
|
|
<tr><td colSpan={5} className="h-[240px] px-5 py-10 text-center text-sm text-muted-foreground">커밋 조회 실패: {commitsError}</td></tr>
|
|
) : null}
|
|
{!commitsLoading && !commitsError && commits.length === 0 ? (
|
|
<tr><td colSpan={5} className="h-[240px] px-5 py-10 text-center text-sm text-muted-foreground">표시할 커밋이 없습니다.</td></tr>
|
|
) : null}
|
|
{!commitsLoading && !commitsError ? commits.map((commit) => (
|
|
<tr key={commit.sha} className="border-b border-border last:border-0">
|
|
<td className="whitespace-nowrap px-5 py-3 font-medium">{commitAuthor(commit)}</td>
|
|
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-muted-foreground">{commit.sha.slice(0, 10)}</td>
|
|
<td className="max-w-[360px] truncate px-5 py-3">{commitMessage(commit)}</td>
|
|
<td className="whitespace-nowrap px-5 py-3 text-muted-foreground">{relativeTime(commitDate(commit))}</td>
|
|
<td className="whitespace-nowrap px-5 py-3 text-right">
|
|
<Button variant="outline" className="size-8 px-0" title="내역 상세" aria-label="내역 상세" onClick={() => openCommitDiff(commit)}>
|
|
<FileText className="size-4" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
)) : null}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4 text-sm">
|
|
<span className="text-muted-foreground">
|
|
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" className="h-8 px-3 text-[13px]" disabled={commitPage <= 1 || commitsLoading} onClick={() => setCommitPage((page) => Math.max(1, page - 1))}>
|
|
이전
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="h-8 px-3 text-[13px]"
|
|
disabled={commitsLoading || (totalCommitPages !== null ? commitPage >= totalCommitPages : !hasNextCommitPage)}
|
|
onClick={() => setCommitPage((page) => totalCommitPages === null ? page + 1 : Math.min(totalCommitPages, page + 1))}
|
|
>
|
|
다음
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Modal
|
|
open={Boolean(diffTarget)}
|
|
title="커밋 내역 상세"
|
|
onClose={() => setDiffTarget(null)}
|
|
className="!max-w-[1400px]"
|
|
bodyClassName="max-h-[70vh] px-4 py-4"
|
|
footer={<Button variant="outline" onClick={() => setDiffTarget(null)}>닫기</Button>}
|
|
>
|
|
{diffTarget ? (
|
|
<div className="space-y-4">
|
|
<div className="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm">
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
<span className="font-mono text-xs text-muted-foreground">{diffTarget.sha.slice(0, 10)}</span>
|
|
<span className="font-medium">{commitMessage(diffTarget)}</span>
|
|
</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{commitAuthor(diffTarget)} · {relativeTime(commitDate(diffTarget))}
|
|
</p>
|
|
</div>
|
|
{diffLoading ? (
|
|
<div className="flex min-h-40 items-center justify-center text-sm text-muted-foreground">Diff를 불러오는 중입니다.</div>
|
|
) : diffError ? (
|
|
<div className="rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">Diff 조회 실패: {diffError}</div>
|
|
) : (
|
|
<DiffViewer diff={diffContent} />
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</Modal>
|
|
|
|
<LoadingModal open={sourcesLoadingVisible} />
|
|
</>
|
|
);
|
|
}
|