import { Copy, ExternalLink, FileText, Link2, 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 { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { getLauncherSource, getGiteaCommitDiff, listGiteaCommits, listLauncherVersions, listPlatformRepos, updateLauncherSource, type GiteaCommit, type LauncherVersion } from './launcher-api'; const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000'; const giteaSshBaseUrl = import.meta.env.VITE_GITEA_SSH_BASE_URL ?? 'ssh://git@localhost:2222'; const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? 'platform'; type SourceTarget = 'launcher-client' | 'launcher-backend'; type CommitRange = 'week' | 'month' | 'all'; type SourceForm = { repository: string; image: string; }; const targetLabels: Record = { 'launcher-client': { title: '클라이언트', description: '사용자 PC에 설치되는 Electron .deb 빌드 소스입니다.', defaultOrg: 'platform' }, 'launcher-backend': { title: '백엔드', description: 'portal-api 런처 백엔드 컨테이너 이미지 빌드 소스입니다.', defaultOrg: 'platform' } }; const commitRangeLabels: Record = { week: '일주일', month: '한달', all: '전체' }; const commitPageSize = 5; function repoFullName(repo: GiteaRepo) { return (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? normalizeRepository(repo.name); } function normalizeRepository(repository?: string | null, fallbackOrg = giteaAppOrg) { if (!repository) return ''; const value = repository.replace(/^\/+|\/+$/g, ''); return value.includes('/') ? value : `${fallbackOrg}/${value}`; } function splitRepository(repository?: string | null, fallbackOrg = giteaAppOrg) { const normalizedRepository = normalizeRepository(repository, fallbackOrg); const [owner, ...repoParts] = normalizedRepository.split('/'); const repo = repoParts.join('/'); return owner && repo ? { owner, repo } : null; } function httpCloneUrl(repository?: string | null, fallbackOrg?: string) { const normalizedRepository = normalizeRepository(repository, fallbackOrg); return normalizedRepository ? `${giteaBaseUrl.replace(/\/$/, '')}/${normalizedRepository}.git` : '-'; } function sshCloneUrl(repository?: string | null, fallbackOrg?: string) { const normalizedRepository = normalizeRepository(repository, fallbackOrg); return normalizedRepository ? `${giteaSshBaseUrl.replace(/\/$/, '')}/${normalizedRepository}.git` : '-'; } function emptyForm(): SourceForm { return { repository: '', image: 'localhost:5000/studio-launcher-be' }; } 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(); } export function LauncherSourcePage() { const { toast } = useToast(); const [activeTarget, setActiveTarget] = useState('launcher-client'); const [versions, setVersions] = useState([]); const [platformRepos, setPlatformRepos] = useState([]); const [repos, setRepos] = useState([]); const [forms, setForms] = useState>({ 'launcher-client': emptyForm(), 'launcher-backend': emptyForm() }); const [commits, setCommits] = useState([]); const [commitRange, setCommitRange] = useState('week'); const [commitPage, setCommitPage] = useState(1); const [hasNextCommitPage, setHasNextCommitPage] = useState(false); 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 [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const loadingVisible = useMinimumLoading(loading); const sourceBranch = 'develop'; const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]); const activeForm = forms[activeTarget]; const activeTargetMeta = targetLabels[activeTarget]; const updateForm = (patch: Partial) => { setForms((prev) => ({ ...prev, [activeTarget]: { ...prev[activeTarget], ...patch } })); }; const refresh = () => { setLoading(true); Promise.all([listLauncherVersions(), listPlatformRepos(), getLauncherSource()]) .then(([nextVersions, platformRepos, sourceResult]) => { const nextVersion = nextVersions.find((version) => version.isActive) ?? nextVersions[0]; const clientRepository = sourceResult.source.client?.repository ?? sourceResult.source.repository ?? nextVersion?.repository ?? (platformRepos[0] ? repoFullName(platformRepos[0]) : ''); const backendRepository = sourceResult.source.backend?.repository ?? (platformRepos[0] ? repoFullName(platformRepos[0]) : ''); setVersions(nextVersions); setPlatformRepos(platformRepos); setRepos(platformRepos); setForms({ 'launcher-client': { repository: clientRepository, image: '' }, 'launcher-backend': { repository: backendRepository, image: sourceResult.source.backend?.image ?? 'localhost:5000/studio-launcher-be' } }); }) .catch((error: Error) => { toast({ type: 'error', title: '소스 정보 조회 실패', message: error.message }); }) .finally(() => setLoading(false)); }; useEffect(() => { refresh(); }, []); useEffect(() => { setRepos(platformRepos); }, [platformRepos]); useEffect(() => { setCommitPage(1); }, [activeForm.repository, activeTarget, commitRange]); useEffect(() => { const repository = splitRepository(activeForm.repository, activeTargetMeta.defaultOrg); if (!repository) { setCommits([]); setCommitsError(''); setHasNextCommitPage(false); return; } let canceled = false; setCommitsLoading(true); setCommitsError(''); listGiteaCommits(repository.owner, repository.repo, sourceBranch, { page: commitPage, limit: commitPageSize + 1, since: commitRangeSince(commitRange) }) .then((nextCommits) => { if (!canceled) { setCommits(nextCommits.slice(0, commitPageSize)); setHasNextCommitPage(nextCommits.length > commitPageSize); } }) .catch((error: Error) => { if (!canceled) { setCommits([]); setHasNextCommitPage(false); setCommitsError(error.message); } }) .finally(() => { if (!canceled) setCommitsLoading(false); }); return () => { canceled = true; }; }, [activeForm.repository, activeTargetMeta.defaultOrg, sourceBranch, commitPage, commitRange]); const save = () => { if (!activeForm.repository.trim()) return; setSaving(true); updateLauncherSource({ target: activeTarget, repository: activeForm.repository.trim(), branch: sourceBranch, commitSha: '', image: activeTarget === 'launcher-backend' ? activeForm.image.trim() || undefined : undefined, dockerfile: activeTarget === 'launcher-backend' ? 'Dockerfile' : undefined }) .then(() => { toast({ type: 'success', title: '소스 연결', message: `${activeTargetMeta.title} 소스 연결을 저장했습니다.` }); refresh(); }) .catch((error: Error) => { toast({ type: 'error', title: '소스 연결 실패', message: error.message }); }) .finally(() => setSaving(false)); }; const copy = (value: string) => { navigator.clipboard?.writeText(value); toast({ type: 'success', message: '복사되었습니다.' }); }; const openCommitDiff = (commit: GiteaCommit) => { const repository = splitRepository(activeForm.repository, activeTargetMeta.defaultOrg); if (!repository) { toast({ type: 'error', title: 'Diff 조회 실패', message: '저장소가 연결되어 있지 않습니다.' }); return; } setDiffTarget(commit); setDiffContent(''); setDiffError(''); setDiffLoading(true); getGiteaCommitDiff(repository.owner, repository.repo, commit.sha) .then((diff) => setDiffContent(diff)) .catch((error: Error) => setDiffError(error.message)) .finally(() => setDiffLoading(false)); }; return ( <> 새로고침 } />
{(Object.keys(targetLabels) as SourceTarget[]).map((target) => ( ))}
{activeTarget === 'launcher-backend' ? ( <> ) : null}
기준 {activeTarget === 'launcher-client' ? activeVersion?.version ?? '-' : activeForm.image || '-'}

HTTP Clone URL

{httpCloneUrl(activeForm.repository, activeTargetMeta.defaultOrg)}

SSH Clone URL

{sshCloneUrl(activeForm.repository, activeTargetMeta.defaultOrg)}

{activeForm.repository ? ( 저장소 열기 ) : null}
{(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))}
페이지 {commitPage}
setDiffTarget(null)} className="max-w-5xl" footer={} > {diffTarget ? (
{diffTarget.sha.slice(0, 10)} {commitMessage(diffTarget)}

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

{diffLoading ? (
Diff를 불러오는 중입니다.
) : diffError ? (
Diff 조회 실패: {diffError}
) : (
                {diffContent || '표시할 변경 내역이 없습니다.'}
              
)}
) : null}
); }