import { useEffect, useState } from 'react'; import { Copy, ExternalLink, Link2, Plus } from 'lucide-react'; import { Badge, Button, Card, CardHeader } from '../../components/ui'; import { useToast } from '../../components/toast'; import { PageTitle } from '../../layout/AppLayout'; import type { GiteaRepo } from '../../types/application'; import type { ApiApp, ApiAppComponent } from '../../types/runtime'; import { LoadingModal } from '../operations/operation-utils'; import { createGiteaOrgRepo, linkComponentSource, listGiteaRepos, listSources } from './application-api'; import { AppSelector, useApps } from './ApplicationControls'; 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 ?? import.meta.env.GITEA_APP_ORG ?? 'platform'; type PlatformRepo = { id: number; owner: string; name: string; fullName: string; description?: string; private: boolean; htmlUrl: string; cloneUrl: string; sshUrl: string; defaultBranch: string; language?: string; }; type SourceComponent = ApiAppComponent & { app?: ApiApp; }; function useMinimumLoading(loading: boolean, minimumMs = 500) { const [visible, setVisible] = useState(loading); useEffect(() => { if (loading) { setVisible(true); return; } const timer = window.setTimeout(() => setVisible(false), minimumMs); return () => window.clearTimeout(timer); }, [loading, minimumMs]); return visible; } function toPlatformRepo(repo: GiteaRepo): PlatformRepo { const fullName = (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? repo.name; const owner = typeof repo.owner === 'string' ? repo.owner : repo.owner?.login ?? fullName.split('/')[0] ?? 'platform'; return { id: repo.id ?? Number(`${owner.length}${repo.name.length}`), owner, name: repo.name, fullName, description: (repo as GiteaRepo & { description?: string }).description, private: Boolean(repo.private), htmlUrl: (repo as GiteaRepo & { htmlUrl?: string }).htmlUrl ?? repo.html_url ?? `${giteaBaseUrl}/${fullName}`, cloneUrl: (repo as GiteaRepo & { cloneUrl?: string }).cloneUrl ?? repo.clone_url ?? `${giteaBaseUrl}/${fullName}.git`, sshUrl: (repo as GiteaRepo & { sshUrl?: string }).sshUrl ?? repo.ssh_url ?? `${giteaSshBaseUrl.replace(/\/$/, '')}/${fullName}.git`, defaultBranch: (repo as GiteaRepo & { defaultBranch?: string }).defaultBranch ?? 'main', language: (repo as GiteaRepo & { language?: string }).language }; } function normalizeRepositoryName(repository: string) { const trimmed = repository.trim().replace(/^\/+/, '').replace(/\.git$/, ''); return trimmed.includes('/') ? trimmed : `${giteaAppOrg}/${trimmed}`; } function getHttpCloneUrl(repository: string) { return `${giteaBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`; } function getSshCloneUrl(repository: string) { return `${giteaSshBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`; } export function SourceManagementPage() { const { toast } = useToast(); const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps(); const [giteaRepos, setGiteaRepos] = useState([]); const [apiSources, setApiSources] = useState([]); const [sourcesLoading, setSourcesLoading] = useState(true); const [giteaStatus, setGiteaStatus] = useState<'loading' | 'ready' | 'error'>('loading'); const sourcesLoadingVisible = useMinimumLoading(giteaStatus === 'loading' || sourcesLoading); const [isCreateRepoOpen, setIsCreateRepoOpen] = useState(false); const [repoName, setRepoName] = useState(''); const [repoDescription, setRepoDescription] = useState(''); const [repoPrivate, setRepoPrivate] = useState(true); const [createDevelopBranch, setCreateDevelopBranch] = useState(true); const [createReleaseBranch, setCreateReleaseBranch] = useState(true); const [repoCreateStatus, setRepoCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle'); const [sourceModalOpen, setSourceModalOpen] = useState(false); const [sourceTarget, setSourceTarget] = useState(null); const [sourceRepo, setSourceRepo] = useState(''); const [sourceBranch, setSourceBranch] = useState('develop'); const [sourceBuildTarget, setSourceBuildTarget] = useState(''); const [sourceSaveStatus, setSourceSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle'); const refreshRepos = () => { setGiteaStatus('loading'); listGiteaRepos(giteaAppOrg, 8) .then((repos) => { setGiteaRepos(repos.map(toPlatformRepo)); setGiteaStatus('ready'); }) .catch(() => setGiteaStatus('error')); }; useEffect(() => { let mounted = true; setSourcesLoading(true); listSources() .then((sources) => { if (!mounted) return; setApiSources(sources); setSourcesLoading(false); }) .catch(() => { if (!mounted) return; setApiSources([]); setSourcesLoading(false); }); listGiteaRepos(giteaAppOrg, 8) .then((repos) => { if (!mounted) return; setGiteaRepos(repos.map(toPlatformRepo)); setGiteaStatus('ready'); }) .catch(() => { if (!mounted) return; setGiteaStatus('error'); }); return () => { mounted = false; }; }, []); const createRepo = () => { if (!repoName.trim()) return; setRepoCreateStatus('saving'); createGiteaOrgRepo(giteaAppOrg, { name: repoName.trim(), description: repoDescription.trim() || undefined, private: repoPrivate, autoInit: true, createDevelopBranch, createReleaseBranch }) .then(() => { setRepoName(''); setRepoDescription(''); setRepoPrivate(true); setCreateDevelopBranch(true); setCreateReleaseBranch(true); setRepoCreateStatus('idle'); setIsCreateRepoOpen(false); refreshRepos(); toast({ type: 'success', message: '저장소가 생성되었습니다.' }); }) .catch(() => setRepoCreateStatus('error')); }; const refreshSources = () => { setSourcesLoading(true); listSources() .then((sources) => { setApiSources(sources); setSourcesLoading(false); }) .catch(() => { setApiSources([]); setSourcesLoading(false); }); }; const openSourceModal = (source: SourceComponent) => { setSourceTarget(source); setSourceRepo(source.repository ?? ''); setSourceBranch(source.branch ?? 'develop'); setSourceBuildTarget(source.buildTarget ?? ''); setSourceSaveStatus('idle'); setSourceModalOpen(true); }; const closeSourceModal = () => { setSourceModalOpen(false); setSourceTarget(null); setSourceRepo(''); setSourceBranch('develop'); setSourceBuildTarget(''); setSourceSaveStatus('idle'); }; const saveSourceLink = () => { if (!sourceTarget || !sourceRepo.trim()) return; setSourceSaveStatus('saving'); linkComponentSource(sourceTarget.id, { repository: sourceRepo, branch: sourceBranch || undefined, buildTarget: sourceBuildTarget || undefined }) .then(() => { closeSourceModal(); refreshSources(); toast({ type: 'success', message: '소스 연결이 저장되었습니다.' }); }) .catch(() => { setSourceSaveStatus('error'); toast({ type: 'error', message: '소스 연결 저장에 실패했습니다.' }); }); }; const copyToClipboard = (value: string) => { void navigator.clipboard?.writeText(value); toast({ type: 'success', message: '복사되었습니다.' }); }; const filteredSources = apiSources.filter((source) => !selectedAppId || source.appId === selectedAppId); return ( <> setIsCreateRepoOpen(true)}> 저장소 생성 } />
{giteaStatus === 'error' ? ( ) : giteaStatus !== 'loading' && giteaRepos.length === 0 ? ( ) : ( giteaRepos.map((repo) => ( )) )}
저장소 Owner 기본 브랜치 언어 Clone URL 상태
백엔드 연결 후 저장소가 표시됩니다.
등록된 저장소가 없습니다.
{repo.fullName}
{repo.description ?

{repo.description}

: null}
{repo.owner} {repo.defaultBranch} {repo.language || '-'} {repo.cloneUrl} {repo.private ? 'Private' : 'Public'}
{!sourcesLoading && filteredSources.length === 0 ? ( ) : ( !sourcesLoading && filteredSources.map((source) => ( )) )}
앱 구성요소 저장소 기본 브랜치 Commit 유형 빌드 대상 Clone URL 상태 관리
등록된 앱 구성요소가 없습니다.
{source.app?.name ?? '-'} {source.name} {source.repository || '미연결'} {source.branch || '-'} - {source.type} {source.buildTarget || '-'} {source.repository ? (

{getHttpCloneUrl(source.repository)}

{getSshCloneUrl(source.repository)}

) : '-'}
{source.repository ? '연결됨' : '미연결'}
{isCreateRepoOpen ? (

저장소 생성