import { FileText, RotateCcw } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui'; import { useToast } from '../../components/toast'; import { PageTitle } from '../../layout/AppLayout'; import { cn } from '../../lib/utils'; import type { GiteaRepo } from '../../types/application'; import { getGiteaCommitDiff, listGiteaCommitsPage, listStudioRepos, type GiteaCommit } from '../launcher/launcher-api'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { listCommonAppPipelines, type CommonAppPipeline } from './common-app-api'; import { commonAppComponents, formatRepositoryName } from './common-app-utils'; type CommitRange = 'week' | 'month' | 'all'; const commitRangeLabels: Record = { week: '일주일', month: '한달', all: '전체' }; const commitPageSize = 5; const branchOptions = ['develop', 'release', 'main']; function repoFullName(repo: GiteaRepo) { return repo.fullName ?? repo.full_name ?? repo.name; } function repoHtmlUrl(repo?: GiteaRepo) { return repo?.htmlUrl ?? repo?.html_url ?? ''; } function splitRepository(repository: string) { const [owner, ...repoParts] = repository.split('/'); const repo = repoParts.join('/'); return owner && repo ? { owner, repo } : null; } function cicdLabel(pipeline?: CommonAppPipeline) { if (!pipeline) return '-'; return pipeline.ciUrl ? `Woodpecker / ${pipeline.ciUrl}` : 'Woodpecker'; } 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 < 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 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 DiffViewer({ diff }: { diff: string }) { const lines = diff.trim() ? diff.split(/\r?\n/) : ['표시할 변경 내역이 없습니다.']; return (
{lines.map((line, index) => (
{index + 1} {line || ' '}
))}
); } export function CommonAppSourcePage() { const { toast } = useToast(); const [repos, setRepos] = useState([]); const [pipelines, setPipelines] = useState([]); const [selectedRepository, setSelectedRepository] = useState(commonAppComponents[0]?.repository ?? ''); const [selectedBranch, setSelectedBranch] = useState('develop'); const [commitRange, setCommitRange] = useState('week'); const [commitPage, setCommitPage] = useState(1); const [commits, setCommits] = useState([]); const [totalCommits, setTotalCommits] = useState(null); const [hasNextCommitPage, setHasNextCommitPage] = useState(false); const [loading, setLoading] = useState(true); const [commitsLoading, setCommitsLoading] = useState(false); const [commitsError, setCommitsError] = useState(''); const [diffTarget, setDiffTarget] = useState(null); const [diffContent, setDiffContent] = useState(''); const [diffLoading, setDiffLoading] = useState(false); const [diffError, setDiffError] = useState(''); const loadingVisible = useMinimumLoading(loading); const pipelineByRepo = useMemo(() => new Map(pipelines.map((pipeline) => [pipeline.repository, pipeline])), [pipelines]); const knownCommonRepos = useMemo(() => new Set(commonAppComponents.map((component) => component.repository)), []); const sortedRepos = useMemo(() => { return [...repos].sort((a, b) => { const aKnown = knownCommonRepos.has(repoFullName(a)) ? 0 : 1; const bKnown = knownCommonRepos.has(repoFullName(b)) ? 0 : 1; if (aKnown !== bKnown) return aKnown - bKnown; return repoFullName(a).localeCompare(repoFullName(b)); }); }, [knownCommonRepos, repos]); const selectedRepo = useMemo(() => repos.find((repo) => repoFullName(repo) === selectedRepository), [repos, selectedRepository]); const selectedPipeline = pipelineByRepo.get(selectedRepository); const repositoryParts = useMemo(() => splitRepository(selectedRepository), [selectedRepository]); const totalCommitPages = totalCommits === null ? null : Math.max(1, Math.ceil(totalCommits / commitPageSize)); const refresh = () => { setLoading(true); Promise.all([ listStudioRepos(100), listCommonAppPipelines() ]) .then(([nextRepos, nextPipelines]) => { setRepos(nextRepos); setPipelines(nextPipelines); if (!nextRepos.some((repo) => repoFullName(repo) === selectedRepository)) { setSelectedRepository(repoFullName(nextRepos.find((repo) => knownCommonRepos.has(repoFullName(repo))) ?? nextRepos[0]) || ''); } }) .catch((error: Error) => { toast({ type: 'error', title: '공통앱 소스 조회 실패', message: error.message }); }) .finally(() => setLoading(false)); }; useEffect(() => { refresh(); }, []); useEffect(() => { setCommitPage(1); }, [selectedRepository, selectedBranch, commitRange]); useEffect(() => { let canceled = false; if (!repositoryParts) { setCommits([]); setTotalCommits(null); setHasNextCommitPage(false); return; } setCommitsLoading(true); setCommitsError(''); listGiteaCommitsPage(repositoryParts.owner, repositoryParts.repo, selectedBranch, { 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; }; }, [commitPage, commitRange, repositoryParts?.owner, repositoryParts?.repo, selectedBranch]); const openCommitDiff = (commit: GiteaCommit) => { if (!repositoryParts) return; setDiffTarget(commit); setDiffContent(''); setDiffError(''); setDiffLoading(true); getGiteaCommitDiff(repositoryParts.owner, repositoryParts.repo, commit.sha) .then((diff) => setDiffContent(diff)) .catch((error: Error) => setDiffError(error.message)) .finally(() => setDiffLoading(false)); }; return ( <> 새로고침 } />
{sortedRepos.length === 0 && !loading ? (
표시할 저장소가 없습니다.
) : null}
{sortedRepos.map((repo) => { const fullName = repoFullName(repo); const selected = fullName === selectedRepository; return ( ); })}

저장소

{selectedRepository ? formatRepositoryName(selectedRepository) : '-'}

CI/CD

{cicdLabel(selectedPipeline)}

조회 브랜치

Gitea URL

{repoHtmlUrl(selectedRepo) || '-'}

{(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}
); }