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 = { 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 (
{nodes.map((node) => (
{node.file ? ( ) : (
{node.name}
)} {node.children.length ? : null}
))}
); } function DiffViewer({ diff }: { diff: string }) { const files = parseDiffFiles(diff); const tree = useMemo(() => buildDiffTree(files), [files]); const scrollerRef = useRef(null); const sectionRefs = useRef>({}); 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 (
{files.map((file) => (
{ sectionRefs.current[file.id] = element; }} className="min-w-max overflow-hidden rounded-md border border-slate-700/80 bg-[#0b0f14]" >

{file.path}

+{file.added} -{file.removed}
{file.lines.map((line, index) => (
{index + 1}
{line || ' '}
))}
))}
); } export function SourceManagementPage() { const { toast } = useToast(); const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps(); const [sources, setSources] = useState([]); const [releases, setReleases] = useState([]); const [sourcesLoading, setSourcesLoading] = useState(true); const [selectedSourceId, setSelectedSourceId] = useState(''); const [selectedBranch, setSelectedBranch] = useState('release'); const [commitRange, setCommitRange] = useState('week'); const [commitPage, setCommitPage] = useState(1); const [commits, setCommits] = useState([]); const [commitsLoading, setCommitsLoading] = useState(false); const [commitsError, setCommitsError] = useState(''); const [totalCommits, setTotalCommits] = useState(null); const [hasNextCommitPage, setHasNextCommitPage] = useState(false); const [diffTarget, setDiffTarget] = useState(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 ( <> 새로고침 } />

저장소

{normalizeRepository(selectedSource?.repository) || '-'}

Git URL

{selectedRepositoryGitUrl}

버전

{latestRelease?.version ?? '-'}

조회 브랜치

{(Object.keys(commitRangeLabels) as CommitRange[]).map((range) => ( ))} } />
{commitsLoading ? ( ) : null} {!commitsLoading && commitsError ? ( ) : null} {!commitsLoading && !commitsError && commits.length === 0 ? ( ) : null} {!commitsLoading && !commitsError ? commits.map((commit) => ( )) : null}
작성자 SHA1 메시지 날짜 상세
커밋을 불러오는 중입니다.
커밋 조회 실패: {commitsError}
표시할 커밋이 없습니다.
{commitAuthor(commit)} {commit.sha.slice(0, 10)} {commitMessage(commit)} {relativeTime(commitDate(commit))}
{totalCommits === null ? `페이지 ${commitPage}` : `총 ${totalCommits}건 · ${commitPage} / ${totalCommitPages}`}
setDiffTarget(null)} className="!max-w-[1400px]" bodyClassName="max-h-[70vh] px-4 py-4" footer={} > {diffTarget ? (
{diffTarget.sha.slice(0, 10)} {commitMessage(diffTarget)}

{commitAuthor(diffTarget)} · {relativeTime(commitDate(diffTarget))}

{diffLoading ? (
Diff를 불러오는 중입니다.
) : diffError ? (
Diff 조회 실패: {diffError}
) : ( )}
) : null}
); }